ligand

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: May 24, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

Ligand

Ligand is a Zero Trust microservice framework for Go — an in-process library that binds to the service it protects, not a sidecar or proxy sitting beside it. In biochemistry, a ligand binds to a receptor to form a single functional complex; neither is complete without the other. The analogy is exact: once initialized, the service and its enforcement layer are a single process.

mTLS identity, policy evaluation, rate limiting, secrets access, and service discovery are the only paths through which the service can communicate.

The problem it solves

The individual components of a zero trust service mesh are mature: SPIFFE/SPIRE for workload identity, OPA for policy evaluation, TLS 1.3 for transport security. What has been missing is their composition — a single import that gives a service all zero trust properties with a structural guarantee that bypassing any one of them is impossible, not just a convention violation. This is what Google's internal Stubby framework provided at scale and what the open source ecosystem has lacked.

Ligand assembles these components and defines the interfaces between them. A service that imports and initializes Ligand cannot make unauthenticated calls, skip policy evaluation, or access secrets outside the audit path — not because the documentation says not to, but because the framework is the only path to those operations.

What it's not

Ligand is not the only defensible approach to zero trust service security. Service mesh solutions (Istio, Linkerd, Cilium) are production-proven at significant scale and have real strengths: they apply transparently to existing services without code changes, security patches can be deployed to the proxy fleet independently of application deployments, and they work across any language running in the cluster.

The tradeoffs of the sidecar model:

  • Configuration complexity — the policy and routing surface is large; getting mTLS and authorization policy right across a mesh requires coordinating multiple interacting resource types.
  • Operational complexity — a control plane, webhook-based sidecar injection, and proxy upgrades independent of application releases each introduce their own failure modes.
  • Latency overhead — each sidecar adds a hop that compounds in deep call chains; for latency-sensitive services the cost is measurable.
  • Policy scope — sidecars see network-layer attributes (TLS identity, HTTP headers); application-layer context that your policy should reason about requires additional instrumentation to reach the policy engine.
  • Kubernetes dependency — sidecars are a Kubernetes primitive; services running on VMs, bare metal, or at the edge require entirely separate solutions.
  • Pod-internal trust gap — traffic between the application container and sidecar typically travels over localhost without authentication; an attacker with process-level access can bypass the proxy.

Ligand trades some of those strengths for different properties:

  • Go-only today — the initial implementation targets Go; polyglot environments need separate language ports, which do not yet exist.
  • Upgrade coupling — a framework security patch requires rebuilding and redeploying the application, not just pushing a new proxy image to the fleet.
  • Requires code adoption — Ligand cannot be applied transparently to an existing service; the service must call Init() and use the framework for all outbound operations.
  • Softer network enforcement — sidecars enforce at the iptables level; Ligand enforces structurally, but a determined developer can still open a raw connection. A go vet plugin under analysis/ is planned to catch common cases at compile time; until it ships, this is by convention and code review (see Known limitations).

Getting started

The root module requires Go 1.25 or later:

go get codeberg.org/arichard/ligand

Unit tests for the root module run with no external dependencies:

go test ./...

For a 5-minute look at the framework without installing any external infrastructure, testkit/ ships configurable fakes for every interface (identity, policy, binding, telemetry, rate limiter, secrets, etc.) and provider/ratelimiter/inprocess is a real token-bucket implementation. Together these are enough to wire Init() and exercise the inbound enforcement pipeline — rate limit → policy → handler — from a Go test, with no SPIRE, OPA, or Valkey running.

examples/bank/ is a multi-service banking demo (gateway, accounts, transfers, fraud) that wires all the reference providers together. It requires SPIRE, Valkey, OpenBao, and etcd to be running; examples/bank/run.sh starts the required infrastructure and launches all services. If you do not have the binaries installed, scripts/install-bins.sh fetches them first.

How it works

Init() is the single entry point. It is fail-closed: if any required component fails to initialize — workload identity unavailable, policy bundle unreachable, required configuration absent — Init() returns an error and the process must not start. A partially initialized service is not a degraded service; it is an unenforced service.

After Init() returns, Server.Enforce() is the enforcement entry point for every inbound request: rate limit check → policy evaluation → application handler. The Binding interface is the transport abstraction; the binding factory passed in Config.Binding receives an EnforceFunc and calls it once per inbound request, translating the returned error into the appropriate protocol-level denial (e.g. HTTP 429 or 403). The service supplies its application logic; the framework owns every path through which that logic is reached.

Trust-boundary services (those that accept external traffic — browsers, partner APIs) use InitTrustBoundary() instead of Init(), and call Server.EnforceInbound() from their own handlers rather than relying on a Binding.

// Illustrative shape — API is not yet stable.
cfg := &ligand.Config{
    Identity:    spireProvider,
    Policy:      opaProvider,
    Binding:     httpbinding.Factory(httpbinding.Config{Addr: ":8443"}),
    RateLimiter: valkeyProvider,
}
srv, err := ligand.Init(ctx, cfg)
if err != nil {
    log.Fatal(err) // process must not start
}
defer srv.Close()

See pkg.go.dev for the full API reference.

What it enforces

  • Mutual TLS 1.3 only — the listener and dialer have no plaintext path; there is no configuration option that downgrades to TLS without mutual authentication.
  • Workload identity — SPIFFE X.509 SVIDs obtained at startup and rotated automatically throughout the process lifetime, without connection drops or service restart.
  • Policy evaluation on every request — every inbound request is evaluated before reaching the application handler; if the policy engine is unavailable, the request is denied, not allowed.
  • Identity chain propagation — the immediate peer's SPIFFE ID is cryptographically verified at each hop (mTLS, or WIT/WPT on the trust-boundary path) and the full identity chain is forwarded on outbound calls automatically. Per-hop cryptographic integrity of the upstream chain links is not yet shipped — see Known limitations.
  • Secrets access — application code requests secrets through the framework interface; direct contact with the secrets backend is not a supported path. All access is audited and TTL-enforced.
  • Rate limiting — configurable per service, enforced before policy evaluation.
  • Fail-closed health transitions — all enforcement components expose health state; Close() marks components unavailable before draining, so load balancers see the transition before connections close.
  • Build-time verificationplanned: a go vet plugin (analysis/) will catch incorrect framework usage at compile time. Until it ships, framework usage discipline is by convention and code review. See Known limitations.

What you bring

The framework defines clean interfaces and ships reference implementations. Organizations may substitute their own at any of these points:

Concern Reference implementation Interface contract
Workload identity SPIFFE/SPIRE via go-spiffe Any WIMSE-conforming X.509 provider
Policy engine OPA embedded (Go native); Wasm planned Synchronous evaluation of structured JSON input
Secrets OpenBao via HTTP API Named retrieval, renewal, revocation
Service registry etcd-backed Logical name → verified endpoint set
Transport binding Standard net/http ServeMux Per-request Enforce + Shutdown + Close
Rate limiter Valkey (Redis-compatible) Token bucket; configurable per service

TLS is not pluggable — it is a security property of the framework, not an implementation choice. FIPS 140-3 builds are supported via a BoringSSL-backed Go toolchain.

Repository layout

The repository is a Go workspace of independent modules. The root module (codeberg.org/arichard/ligand) contains all interface definitions and the enforcement engine — it is the only import most consumers need directly.

Implementations live under provider/{capability}/{impl}/, one module per implementation, so consumers carry only the providers they select:

provider/workloadidentity/spire/           # SPIFFE/SPIRE identity
provider/policyengine/opa/                 # OPA embedded (Go native)
provider/ratelimiter/valkey/               # Valkey (Redis-compatible)
provider/ratelimiter/inprocess/            # in-process token bucket
provider/secrets/openbao/                  # OpenBao secrets
provider/serviceregistry/etcd/             # etcd-backed service registry
provider/binding/http/                     # HTTP binding (mTLS-only path)
provider/outbound/http/                    # outbound HTTP with chain propagation
provider/context/inline/                   # inline Context Service (Transaction Tokens)
provider/circuitbreaker/inprocess/         # per-service circuit breaker
provider/outlierdetector/inprocess/        # endpoint outlier detection
provider/selector/inprocess/               # per-pool endpoint selector
provider/retrybudget/inprocess/            # sliding-window retry budget
provider/retrypolicy/static/               # static per-service retry config
provider/telemetry/{slog,otel}/            # slog and OpenTelemetry sinks
# Skeleton/planned: provider/policyengine/opawasm, provider/loadbalancer/ringhash,
# provider/healthchecker/{http,grpc,tcp}, provider/sessionstore/stub.

A few top-level modules serve specific roles:

  • examples/bank/ — a multi-service banking demo (gateway, accounts, transfers, fraud) that wires SPIRE, OPA, Valkey, OpenBao, and etcd together with both inbound and outbound paths.
  • analysis/planned. Will ship as a go vet plugin for build-time framework verification (e.g. flagging raw listeners or direct outbound calls that bypass the framework). Currently a skeleton — see Roadmap.
  • bundleserver/ — standalone binary for compiling and serving signed OPA policy bundles. Core functionality shipped: Git push webhook receiver (HMAC-SHA256), per-service Rego compilation via OPA, OPA-native bundle signing (.signatures.json, ES256) verified by the policy engine via Config.VerifyKeys, per-service bundle scoping by SPIFFE ID, bundle fetch audit log, filesystem bundle store with atomic updates and retention, and an HTTP handler implementing the OPA bundle protocol (If-None-Match, Prefer: wait=N long-polling). Compiler test coverage, end-to-end integration test wiring, and Wasm bundle mode are deferred — see Roadmap.

Contributing

See CONTRIBUTING.md for commit conventions, dependency policy, and how to add a new provider module. File bugs and feature requests as issues on the Codeberg repository.

Security

To report a vulnerability, use Codeberg's private security advisory feature rather than a public issue. See SECURITY.md for the full policy, scope, and safe harbor terms.

Status

Early development. No API stability guarantees. Not ready for production use. Before evaluating Ligand for any deployment, read Known limitations below — it lists the current security gaps with their mitigations.

v0.1.0 is the first tagged release. The API will break between v0.x releases: until v1, breaking interface changes are preferred over compatibility shims — if a cleaner design requires breaking an API, it is chosen. Each module is versioned independently in v0.x.

Known limitations

The most security-relevant current limitations:

  • Unauthenticated upstream chain links. The X-Ligand-Chain header carrying the upstream identity chain is currently unauthenticated. The immediate peer's SPIFFE ID is verified via mTLS (or WIT/WPT on the trust-boundary path), but upstream links are supplied by the peer and accepted without cryptographic verification. Policies must not grant trust to upstream chain links beyond the immediate peer until signed-chain integrity ships (audit finding C3). See SECURITY.md for the full description.
  • Inline Context Service co-locates signing key. The inline ContextServiceClient signs Transaction Tokens with the workload's own SVID private key. A process-level compromise at a trust-boundary entry point can forge Transaction Tokens for that entry point until the SVID expires. This is an acceptable tradeoff where a dedicated separate Context Service is not operationally feasible; deployments requiring key separation should not use the inline provider.
  • Build-time verification not yet shipped. The analysis/ vet plugin and several pluggability invariants exist as design only. A determined developer can open a raw TCP connection, call an HTTP client directly, or access a secrets backend outside the framework. Until the vet plugin ships, framework usage discipline is by convention and code review, not enforcement.
  • Identity Server WIT issuance is upstream-blocked. The WIMSE Workload Identity Token path depends on SPIRE implementing SPIFFE issue #315. Until then WorkloadIdentity.FetchWIT returns ErrNotSupported, the testkit fake exercises the verification path, and WPT/WIT headers are omitted on outbound calls in production deployments.
  • Go-only. The initial implementation targets Go; polyglot environments need separate language ports, which do not yet exist. Section 7 of the requirements document was written so the design generalises, but no port has been started.

Roadmap

What's shipped today:

  • mTLS 1.3 listener and dialer with automatic SVID rotation (SPIFFE/SPIRE)
  • OPA policy enforcement on every inbound request, with hot bundle reload
  • Fail-closed Init; draining health state; pre-stop hooks; identity rotation grace period
  • In-process and Valkey-backed rate limiters
  • Secrets via OpenBao with framework-owned cache, proactive renewal, and active revocation
  • Service registry (etcd), load balancers (round-robin, random, least-request), and endpoint selector with per-pool ejection and bulkhead
  • Circuit breaker, outlier detection, retry with per-instance budget, retry policy provider
  • Outbound mTLS with chain propagation and WPT/WIT signing (cross-trust-domain path)
  • Inline Context Service for Transaction Token exchange and verification
  • Trust-boundary entry point (InitTrustBoundary, EnforceInbound) for gateway services
  • Telemetry providers (slog, OTel); HTTP health signal exposer
  • Multi-service banking demo (examples/bank/)

What's planned for v1 (see docs/claude/active/phase_plan.md for the full track breakdown):

  • Policy completeness — OPA Wasm engine and a worked geographic-constraint policy example (tier-sensitive decision cache and active revocation were considered and dropped; both close a staleness window that does not exist for in-process OPA evaluation)
  • Bundle distribution finishingbundleserver/ compiler test coverage, end-to-end integration test wiring, Wasm bundle mode (signing and verification, per-service scoping, and the bundle audit log are shipped; Cosign was considered and dropped in favour of OPA-native .signatures.json)
  • Chain integrity — cryptographic per-hop chain verification (closes the open audit finding); HTTP Message Signatures for cross-trust-domain body integrity
  • Framework metrics — Prometheus telemetry provider and distributed-tracing header propagation (the RecordMetrics interface and MetricsSnapshot types are shipped)
  • PIPs and session invalidationPIPSource interface and continuous re-evaluation pipeline
  • Connection management and resilience finishing — per-endpoint pool config, distributed retry budget, HealthChecker-driven outlier reinstatement, ring-hash LB
  • Istio traffic-management compatibility — per-service timeouts, header-based routing, traffic splitting, fault injection
  • Configuration validation--validate mode, startup audit event, config drift detection
  • Build-time verification — the analysis/ go vet plugin and pluggability-taxonomy enforcement
  • Supply-chain compliance — reproducible builds, SBOM, SLSA provenance, OpenSSF Best Practices badge

License

Apache 2.0. See LICENSE.

Documentation

Overview

Package ligand is the Ligand Zero Trust microservice framework.

Call Init to initialize the framework before accepting any connections. Init wires together the pluggable providers, enforces the non-negotiable security invariants (mTLS, fail-closed policy evaluation, rate limiting before policy evaluation), and returns a configured Server ready to serve.

Ligand is an in-process library, not a sidecar or proxy. Once initialized, the service and its enforcement layer are a single binary.

Index

Examples

Constants

View Source
const (
	// ChainHeader carries a JSON-encoded [Chain] propagated between services.
	// Intentionally transitional: WIMSE WPT/TT do not carry the full
	// intermediate hop chain, so X-Ligand-Chain is used for the mTLS path.
	// The header is currently unauthenticated; policy must not trust upstream
	// chain links beyond the immediate peer until C3 from security_audit_2026-05-21.md is addressed.
	ChainHeader = "X-Ligand-Chain"

	// WITHeader carries a Workload-Identity-Token per draft-ietf-wimse-workload-creds-00.
	// WITs must be signed by the Identity Server (SPIRE) using its JWT authority key,
	// which is distributed via the SPIFFE trust bundle JWTAuthorities field. Verification
	// is fail-closed: requests with WIT/WPT headers are rejected unless valid JWT
	// authorities are present in the trust bundle.
	//
	// Full support requires SPIRE to implement WIMSE WIT issuance (SPIFFE issue #315).
	// Until then, [WorkloadIdentity.FetchWIT] returns [ErrNotSupported] and
	// the app-layer auth path is effectively disabled.
	//
	// HTTP Message Signatures (draft-ietf-wimse-http-signature-01) for body integrity
	// at external trust domain boundaries are not yet implemented.
	WITHeader = "Workload-Identity-Token"

	// WPTHeader carries a Workload-Proof-Token per draft-ietf-wimse-wpt-00.
	WPTHeader = "Workload-Proof-Token"

	// TxnTokenHeader carries a Transaction Token per draft-ietf-oauth-transaction-tokens.
	TxnTokenHeader = "Txn-Token"
)

WIMSE header names. Single point of change when the drafts finalise.

View Source
const ComponentLigand = "ligand"

ComponentLigand is the value used for the "component" log attribute injected by the framework on every logger it uses. Callers can use this constant to filter or route log records emitted by the framework.

View Source
const DefaultSecretRenewalLeadTime = 10 * time.Minute

DefaultSecretRenewalLeadTime is the default duration before a secret's TTL expires at which the framework begins proactive renewal. The actual trigger is min(DefaultSecretRenewalLeadTime, ttl/2), so short-lived secrets (TTL < 2×lead time) are renewed at their halfway point instead.

Variables

View Source
var (
	// DefaultPreStopTimeout is the default wall-clock budget for all pre-stop
	// hooks when Config.PreStopTimeout is zero.
	DefaultPreStopTimeout = 5 * time.Second

	// ErrIdentityGraceExpired is the cause passed to Monitor.Set when the
	// identity rotation grace period expires without a successful renewal.
	// It indicates the framework is transitioning KindIdentity to Unavailable
	// as a security-driven hard failure rather than a transient error.
	ErrIdentityGraceExpired = errors.New("ligand: identity rotation grace period expired without SVID renewal")

	// DefaultPolicyLatencyWarnThreshold is the policy evaluation duration above
	// which the framework logs a latency warning. Config.PolicyLatencyWarnThreshold
	// defaults to this value when zero.
	DefaultPolicyLatencyWarnThreshold = 50 * time.Millisecond

	// DefaultPolicyEvalTimeout is the maximum time policy.Evaluate may run before
	// the framework cancels the call and denies the request fail-closed.
	// Config.PolicyEvalTimeout defaults to this value when zero.
	DefaultPolicyEvalTimeout = 5 * time.Second

	// ErrNoSVIDs is returned by Init when the identity provider supplies an empty
	// SVID list on startup.
	ErrNoSVIDs = errors.New("ligand: identity provider returned no SVIDs")

	// ErrEmptyInitialBundle is returned by Init when the bundle fetcher returns
	// a nil or empty bundle on the initial fetch. The framework requires a
	// non-empty bundle to proceed.
	ErrEmptyInitialBundle = errors.New("ligand: bundle fetcher returned empty bundle on initial fetch")

	// ErrPolicyEngineReloadRequired is returned by Init when a BundleFetcher is
	// configured but the PolicyEngine does not support Reload (returns
	// ErrNotSupported). Configure a policy engine that implements Reload, or
	// remove the BundleFetcher from Config.
	ErrPolicyEngineReloadRequired = errors.New("ligand: policy engine does not support Reload; required when BundleFetcher is configured")

	// ErrNotSupported is returned by [Unimplemented] stub methods to indicate
	// that the provider does not implement an optional capability. The framework
	// distinguishes this sentinel from genuine failures: on ErrNotSupported it
	// applies the conservative safe default for that capability; on any other
	// error it treats the capability as failed.
	ErrNotSupported = errors.New("ligand: capability not supported by this provider")

	// ErrServiceRegistrationRequired is returned by [Init] when
	// [Config.ServiceRegistrar] is set but [Config.ServiceRegistration] is nil.
	ErrServiceRegistrationRequired = errors.New("ligand: ServiceRegistrar is configured but ServiceRegistration is nil")

	// ErrNoEndpoints is returned by [PoolSelector.Pick] when the
	// candidate list is empty, and by outbound transports when the service
	// registry resolves to zero endpoints. Callers may use errors.Is to detect
	// this condition and apply service-specific fallback logic.
	ErrNoEndpoints = errors.New("ligand: no endpoints available")
)
View Source
var (
	// ErrRateLimitDenial is returned by [Server.Enforce] and
	// [Server.EnforceInbound] when the rate limiter rejects a request within
	// normal operating parameters (Allowed: false). It is distinct from
	// [ErrPolicyDenial] so callers can map the two cases to different response
	// codes (e.g. HTTP 429 vs HTTP 403).
	ErrRateLimitDenial = errors.New("ligand: request denied by rate limiter")

	// ErrRateLimiterFailure is returned by [Server.Enforce] and
	// [Server.EnforceInbound] when the rate limiter returns an unexpected error
	// (e.g. its backend is unreachable). It is distinct from [ErrRateLimitDenial]
	// so callers can map it to a server-error response code (e.g. HTTP 503)
	// rather than a retryable rate-limit response (HTTP 429).
	ErrRateLimiterFailure = errors.New("ligand: rate limiter internal error")

	// ErrPolicyDenial is returned by [Server.Enforce] and
	// [Server.EnforceInbound] when the policy engine rejects a request. It is
	// distinct from [ErrRateLimitDenial] so callers can map the two cases to
	// different response codes (e.g. HTTP 403 vs HTTP 429).
	ErrPolicyDenial = errors.New("ligand: request denied by policy")
)
View Source
var (
	// ErrNoPeerCertificate is returned by verifyPeerChain when the peer presents
	// no certificate. RequireAnyClientCert normally prevents this, but we guard
	// defensively in case the callback is invoked outside that context.
	ErrNoPeerCertificate = errors.New("ligand: peer presented no certificate")

	// ErrNoServerCertificate is returned by verifyServerChain when the server
	// presents no certificate. TLS requires servers to present a certificate,
	// so this indicates a protocol violation; the guard exists for defensive
	// completeness.
	ErrNoServerCertificate = errors.New("ligand: server presented no certificate")

	// ErrChosenSVIDAbsent is wrapped by newTLSStateFor when the rotation update
	// does not contain the SVID that was selected at Init time.
	ErrChosenSVIDAbsent = errors.New("ligand: chosen SVID absent from rotation update")
)
View Source
var ErrBulkheadFull = errors.New("ligand: bulkhead concurrency limit reached")

ErrBulkheadFull is returned by [PoolSelector.Pick] when healthy endpoints exist but the concurrency limit for the pool has been reached.

View Source
var ErrCircuitOpen = errors.New("ligand: circuit breaker open")

ErrCircuitOpen is returned by [CircuitBreaker.Allow] when the circuit for the requested service is open or the half-open call limit has been reached. Callers should treat this as a fast-fail: the outbound transport returns it immediately without attempting a connection.

View Source
var (
	// ErrEmptySVIDList is returned when the identity provider supplies an empty
	// SVID list during a rotation update, which is treated as an identity expiry.
	ErrEmptySVIDList = errors.New("ligand: SVID rotation returned empty SVID list")
)
View Source
var ErrRetryBudgetExhausted = errors.New("ligand: retry budget exhausted")

ErrRetryBudgetExhausted is returned by the outbound transport when a retry is needed but [RetryBudget.Allow] has denied it.

View Source
var ErrWPTKeyNotECDSA = errors.New("ligand: WPT signing requires an ECDSA private key")

ErrWPTKeyNotECDSA is returned by WPTSigner.BuildWIMSETokens when the current SVID private key is not an *ecdsa.PrivateKey. SPIFFE SVIDs use ECDSA, so this indicates an unsupported identity provider configuration.

Functions

func AttrBackoff

func AttrBackoff(d time.Duration) slog.Attr

AttrBackoff returns a "backoff" slog.Attr for a retry delay duration.

func AttrComponent

func AttrComponent(name string) slog.Attr

AttrComponent returns a "component" slog.Attr identifying the framework subsystem. Use ComponentLigand as the value for framework-emitted logs.

func AttrErr

func AttrErr(err error) slog.Attr

AttrErr returns an "err" slog.Attr for the given error. Uses slog.Any to preserve the error type for handlers that inspect it.

func AttrLatency

func AttrLatency(d time.Duration) slog.Attr

AttrLatency returns a "latency" slog.Attr for a wall-clock elapsed time.

func AttrPanic

func AttrPanic(r any) slog.Attr

AttrPanic returns a "panic" slog.Attr for a value recovered by recover(). Always pair with AttrStack.

func AttrSpiffeID

func AttrSpiffeID(id string) slog.Attr

AttrSpiffeID returns a "spiffe_id" slog.Attr for a SPIFFE ID string.

func AttrStack

func AttrStack() slog.Attr

AttrStack returns a "stack" slog.Attr containing the current goroutine stack trace. Call immediately after recover() alongside AttrPanic.

func AttrThreshold

func AttrThreshold(d time.Duration) slog.Attr

AttrThreshold returns a "threshold" slog.Attr for a configured duration limit that is being compared against.

func InboundTransactionToken

func InboundTransactionToken(ctx context.Context) string

InboundTransactionToken returns the Transaction Token stored by WithInboundTransactionToken, or "" if none is present.

func Latest

func Latest[T any](ctx context.Context, ch LatestRecv[T]) (v T, ok bool)

Latest blocks until ch delivers a value or ctx is cancelled, then drains any additional pending values and returns only the most recent. The second return value is false if ctx was cancelled or ch was closed before any value was received.

Latest and Set are both required even when used together on the same channel: Latest drains values that arrive in the gap between waking up and reading, while Set guarantees at most one value is buffered at send time.

Only valid for LatestRecv channels, where skipping intermediate values is safe. For EventStream channels, use range.

func Set

func Set[T any](ch LatestSend[T], v T)

Set publishes v to ch, evicting any unseen prior value so the consumer always sees the most recent update. ch must be a buffered channel of size 1.

Set and Latest are both required even when used together on the same channel: Set guarantees at most one value is buffered at send time, while Latest drains any value that arrives in the gap between waking up and reading, ensuring the consumer always returns the most recent value.

func SleepWithContext

func SleepWithContext(ctx context.Context, clock quartz.Clock, d time.Duration) bool

SleepWithContext sleeps for d using clock, returning true when the sleep completes or false immediately if ctx is cancelled. Callers should return when SleepWithContext returns false.

func WithInboundChain

func WithInboundChain(ctx context.Context, chain Chain) context.Context

WithInboundChain stores chain in ctx so the outbound transport prepends it to the X-Ligand-Chain header on downstream calls. The HTTP binding calls this automatically; application code only needs it to override the chain in unusual cases.

func WithInboundTransactionToken

func WithInboundTransactionToken(ctx context.Context, token string) context.Context

WithInboundTransactionToken stores the verified/minted Transaction Token in ctx so the outbound transport forwards it unmodified on downstream calls, per draft-ietf-oauth-transaction-tokens §13.2. The HTTP binding calls this automatically after Handle succeeds; application code does not need to call it.

Types

type Action

type Action struct {
	// Name is the operation, e.g. "GET", "POST", "read", "invoke".
	Name string
}

Action identifies the operation being performed on a Resource.

type Binding

type Binding interface {
	// Serve starts accepting requests and blocks until [Binding.Shutdown] or
	// [Binding.Close] is called, or a fatal error occurs. ctx governs listener
	// establishment only: implementations should use it for the underlying
	// Listen call so that a cancelled or timed-out context aborts startup
	// cleanly. Cancelling ctx after the listener is established has no effect;
	// callers must use [Binding.Shutdown] or [Binding.Close] to stop a running
	// server.
	Serve(ctx context.Context) error

	// Shutdown gracefully drains in-flight requests within the deadline
	// imposed by ctx, then stops accepting new ones. Called by the framework
	// when a drain signal is received.
	Shutdown(ctx context.Context) error

	io.Closer

	// Listening returns a channel that is closed once the binding's listener
	// is established and accepting connections. The framework waits on this
	// channel before transitioning [KindBinding] to [Ready], so the health
	// signal reflects actual listener readiness rather than the end of [Init].
	//
	// Implementations must also close the channel (via the same mechanism) if
	// [Binding.Serve] returns an error before establishing the listener, and in
	// [Binding.Close] in case Serve was never called — so the framework goroutine
	// waiting on this channel does not leak.
	//
	// [UnimplementedBinding] returns a pre-closed channel so test fakes that do
	// not model a real listener are marked [Ready] synchronously at [Init] return.
	Listening() <-chan struct{}

	// Addr returns the address the binding is listening on. It blocks until
	// [Binding.Listening] is closed, making it safe to call from a goroutine
	// that races with [Binding.Serve] startup. Returns an empty string if Serve
	// failed before establishing the listener or if the binding does not
	// establish a real listener.
	//
	// The framework calls Addr to determine the address to publish to the
	// service registry when [Config.ServiceRegistrar] is configured alongside
	// a [Config.Binding].
	Addr() string
}

Binding is the seam between the transport layer and the enforcement pipeline. Each implementation accepts inbound connections on a specific transport (HTTP, gRPC, etc.), applies the framework-provided TLS configuration, and calls [Server.Enforce] for each request. If Enforce returns nil the binding invokes the application handler; if it returns an error the binding translates it to an appropriate protocol-level denial (e.g. HTTP 429 or 403).

All methods are required. Embed UnimplementedBinding to remain forward-compatible if optional methods are added in the future.

type BindingArgs

type BindingArgs struct {
	// Enforce is the enforcement pipeline entry point. Call it once per
	// inbound request; translate the returned error to the appropriate
	// protocol-level denial (e.g. HTTP 429 for [ErrRateLimitDenial], 403
	// for all other non-nil errors).
	Enforce EnforceFunc

	// InboundTLSConfig is the framework-managed *tls.Config for the inbound
	// mTLS listener. Apply it to the transport listener in
	// [Binding.Serve]; do not construct a separate TLS config.
	InboundTLSConfig *tls.Config

	// GetJWTAuthorities returns the current map of trust domain → JWT
	// authority public keys from the SPIFFE trust bundle. Used by bindings
	// to verify Workload Identity Token (WIT) signatures on the app-layer
	// auth path. Returns nil when the Identity Server has not yet populated
	// JWT authorities (e.g. before SPIRE implements SPIFFE issue #315), in
	// which case the WIT/WPT path is fail-closed.
	GetJWTAuthorities func() map[string][]crypto.PublicKey

	// IsTrustBoundaryPath reports whether path is a configured trust-boundary
	// entry point. The HTTP binding uses this to strip the Authorization header
	// after Exchange has converted the bearer token into a Transaction Token,
	// preventing the raw credential from reaching the application handler.
	// Returns false for all paths when no trust boundary is configured.
	IsTrustBoundaryPath func(path string) bool
}

BindingArgs holds the ligand-internal values that BindingFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type BindingFactory

type BindingFactory func(*BindingArgs) Binding

BindingFactory is a constructor called by Init after the workload identity and TLS config are established. It returns a ready-to-use Binding. The BindingArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Implementations should expose a factory function alongside their concrete constructor; see package httpbinding for the canonical example.

type BundleFetchEvent

type BundleFetchEvent struct {
	// Timestamp is the time the fetch result was received by the framework.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// SPIFFEID is the workload SPIFFE ID of the service that fetched the bundle.
	// Always populated.
	SPIFFEID string `json:"spiffe_id"`

	// ETag is the version identifier of the fetched bundle. Empty on error or
	// when the server does not support conditional fetches.
	ETag string `json:"etag,omitempty"`

	// Version is the human-readable bundle version string, if provided by the
	// server. Empty when the server does not expose version metadata.
	Version string `json:"version,omitempty"`

	// Outcome describes the result of the fetch attempt. Always populated.
	Outcome BundleFetchOutcome `json:"outcome"`

	// Err is set when Outcome is [BundleFetchOutcomeError]. Excluded from
	// default JSON marshaling; providers that need a string representation
	// should emit err.Error() as a separate attribute.
	Err error `json:"-"`
}

BundleFetchEvent is emitted after each bundle fetch attempt — both the initial fetch during Init and each update delivered by the watch loop.

type BundleFetchOutcome

type BundleFetchOutcome string

BundleFetchOutcome describes the result of a bundle fetch attempt emitted in a BundleFetchEvent.

const (
	// BundleFetchOutcomeSuccess indicates a new bundle was returned and loaded.
	BundleFetchOutcomeSuccess BundleFetchOutcome = "success"

	// BundleFetchOutcomeNotModified indicates the server confirmed the bundle
	// has not changed since the caller's last known ETag (HTTP 304).
	BundleFetchOutcomeNotModified BundleFetchOutcome = "not_modified"

	// BundleFetchOutcomeError indicates the fetch failed with an error.
	BundleFetchOutcomeError BundleFetchOutcome = "error"
)

type BundleFetcher

type BundleFetcher interface {
	// Fetch retrieves the current bundle. Returns the bundle bytes, a version
	// identifier (ETag or equivalent), and an error. The caller passes
	// req.KnownETag to enable conditional fetches; the implementation may
	// return a response with nil Bundle and the same ETag to indicate that
	// the bundle has not changed since req.KnownETag.
	//
	// The framework calls Fetch once during Init with an empty KnownETag to
	// obtain the initial bundle. A nil or empty Bundle in the response is
	// treated as an error by the framework.
	Fetch(ctx context.Context, req *FetchBundleRequest) (*FetchBundleResponse, error)

	// WatchBundles returns a channel delivering a [BundleUpdate] whenever a
	// new bundle version is available. The channel follows the
	// [LatestRecv]/[LatestSend] pattern: only the most recent update matters
	// and intermediate values may be discarded.
	//
	// The channel is closed when ctx is cancelled or when the provider
	// encounters an unrecoverable error (in which case the framework
	// re-establishes the watch with exponential backoff). Error updates
	// ([BundleUpdate.Err] non-nil) cause the framework to set
	// [KindBundleFetcher] to [Unavailable] and retry.
	//
	// The framework calls WatchBundles after Init returns, using the ETag
	// from the initial Fetch as the baseline. Providers should track their
	// own ETag state so that the first WatchBundles call only delivers an
	// update when the bundle has changed since the initial Fetch.
	WatchBundles(ctx context.Context) LatestRecv[BundleUpdate]

	io.Closer
}

BundleFetcher retrieves signed policy bundles from the bundle server and delivers them to the PolicyEngine via the framework's hot-reload path.

Required methods: [BundleFetcher.Fetch], [BundleFetcher.WatchBundles], and io.Closer.

The framework calls [BundleFetcher.Fetch] once during Init to obtain the initial bundle. After Init returns, the framework calls [BundleFetcher.WatchBundles] to receive subsequent bundle updates and passes each update's bytes to [PolicyEngine.Reload].

Failure mode: while the bundle server is unreachable the last successfully loaded policy remains active. The health monitor transitions KindBundleFetcher to Unavailable on the first watch error and back to Ready when a successful update is delivered. Grace window: indefinite — the framework never evicts a loaded policy due to bundle-fetcher unavailability alone. Recovery: the bundle watch loop retries with exponential backoff; no operator action required.

type BundleFetcherArgs

type BundleFetcherArgs struct {
	// OutboundTLSConfig is a *tls.Config for outbound mTLS connections from
	// this workload to the bundle server. It presents the current X.509 SVID
	// via GetClientCertificate (updated atomically on each SVID rotation) and
	// verifies the server certificate against the SPIFFE trust bundle.
	OutboundTLSConfig *tls.Config

	// SPIFFEID is the workload SPIFFE ID of this service. Bundle fetcher
	// implementations use it to request only the policy bundle scoped to this
	// service (e.g. URL-encoded and appended as a path segment to the base URL).
	SPIFFEID string
}

BundleFetcherArgs holds the ligand-internal values that BundleFetcherFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type BundleFetcherFactory

type BundleFetcherFactory func(*BundleFetcherArgs) (BundleFetcher, error)

BundleFetcherFactory is a constructor called by Init after the workload identity and TLS config are established. The BundleFetcherArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Providers expose both a Factory function (for use in Config) and a New constructor (for tests and advanced use).

type BundleUpdate

type BundleUpdate struct {
	Response *FetchBundleResponse
	Err      error
}

BundleUpdate is delivered on the LatestRecv returned by [BundleFetcher.WatchBundles]. Err is non-nil if the watch encountered an error; in that case Response is nil.

type CallOutcome

type CallOutcome struct {
	// Type is the result class of the call.
	Type CallOutcomeType

	// Latency is the elapsed time from the start of the call attempt to when
	// the outcome was determined. Available for latency-based ejection (S9).
	Latency time.Duration
}

CallOutcome is the result of one outbound call attempt, including its latency. Passed to [PoolSelector.Release] and [CircuitBreaker.RecordOutcome] after every attempt.

type CallOutcomeType

type CallOutcomeType int

CallOutcomeType is the result class of an outbound call, used by CircuitBreaker, PoolSelector, and the outbound transport to drive ejection and state-machine transitions.

const (
	// CallOutcomeSuccess indicates the call completed and the response status
	// was below 5xx.
	CallOutcomeSuccess CallOutcomeType = iota

	// CallOutcomeGatewayError indicates the upstream returned HTTP 502, 503,
	// or 504. Tracked separately from other 5xx responses because Envoy's
	// outlier detection counts consecutive gateway errors independently.
	CallOutcomeGatewayError

	// CallOutcomeServerError indicates the upstream returned a non-gateway
	// 5xx response (e.g. 500, 501, 505).
	CallOutcomeServerError

	// CallOutcomeConnectionError indicates the call failed at the TCP/TLS
	// level or timed out before a response was received.
	CallOutcomeConnectionError
)

func (CallOutcomeType) String

func (t CallOutcomeType) String() string

String returns a human-readable name for the outcome type.

type Chain

type Chain []Link

Chain is the ordered sequence of hops that have handled a request, from the most distal (the originating user or device) to the most proximate (the immediate upstream caller). Each element represents one hop; len(chain) is the number of hops. An empty Chain indicates that provenance information was not available or has not yet been propagated.

func InboundChain

func InboundChain(ctx context.Context) Chain

InboundChain returns the Chain stored by WithInboundChain, or nil.

type CircuitAllowRequest

type CircuitAllowRequest struct {
	// SPIFFEID is the SPIFFE ID of the destination service.
	SPIFFEID string
}

CircuitAllowRequest is the argument to [CircuitBreaker.Allow].

type CircuitBreaker

type CircuitBreaker interface {
	// Allow reports whether an outbound call to req.SPIFFEID is permitted.
	// Returns false with [ErrCircuitOpen] when the circuit is open or the
	// half-open concurrent call limit has been reached. Returns false with
	// another error only on infrastructure failure. Returns true, nil when the
	// call should proceed.
	Allow(ctx context.Context, req *CircuitAllowRequest) (bool, error)

	// RecordOutcome records the result of an outbound call to req.SPIFFEID and
	// drives state-machine transitions.
	RecordOutcome(ctx context.Context, req *RecordCircuitOutcomeRequest)

	// State returns the current [CircuitState] for req.SPIFFEID. Unknown SPIFFE
	// IDs return [CircuitStateClosed].
	State(ctx context.Context, req *CircuitStateRequest) CircuitState

	io.Closer
}

CircuitBreaker stops outbound calls to a service that has exceeded its error threshold, giving the service time to recover before traffic resumes.

The OutboundTransport calls [CircuitBreaker.Allow] before each attempt and [CircuitBreaker.RecordOutcome] after each attempt. The state machine transitions: Closed → Open (on threshold breach) → HalfOpen (after OpenDuration) → Closed (on probe success) or Open (on probe failure).

All three methods are optional in enforcement semantics: if no CircuitBreaker is configured, the framework skips circuit-breaking silently. Implementations that do not need all three methods should embed UnimplementedCircuitBreaker.

type CircuitState

type CircuitState int

CircuitState is the state of a per-service circuit breaker.

const (
	// CircuitStateClosed is the normal operating state: calls are forwarded.
	CircuitStateClosed CircuitState = iota

	// CircuitStateOpen means the circuit has tripped: calls are rejected
	// immediately without attempting to reach the service.
	CircuitStateOpen

	// CircuitStateHalfOpen means the circuit is probing for recovery: a
	// limited number of calls are forwarded; a success closes the circuit,
	// a failure reopens it.
	CircuitStateHalfOpen
)

func (CircuitState) String

func (s CircuitState) String() string

String returns a human-readable name for the state.

type CircuitStateEvent

type CircuitStateEvent struct {
	// Timestamp is the time the transition was recorded. Always populated.
	Timestamp time.Time `json:"timestamp"`

	// SPIFFEID is the SPIFFE ID of the service whose circuit changed state.
	// Always populated.
	SPIFFEID string `json:"spiffe_id"`

	// From is the state before the transition; To is the state after.
	// Always populated.
	From CircuitState `json:"from"`
	To   CircuitState `json:"to"`
}

CircuitStateEvent is emitted by the CircuitBreaker when a per-service circuit transitions between states.

type CircuitStateRequest

type CircuitStateRequest struct {
	// SPIFFEID is the SPIFFE ID of the destination service.
	SPIFFEID string
}

CircuitStateRequest is the argument to [CircuitBreaker.State].

type Config

type Config struct {
	// Identity provides the service's workload identity for mTLS. Required.
	Identity WorkloadIdentity

	// Policy evaluates access control policy for inbound requests. Required.
	// The enforcement pipeline is fail-closed: Init returns an error if
	// Policy is nil, and any evaluation error denies the request.
	Policy PolicyEngine

	// Binding constructs the [Binding] that accepts inbound connections and
	// calls [Server.Enforce] for each request. [Init] calls the factory with
	// a [BindingArgs] once workload identity and TLS are established. Required.
	Binding BindingFactory

	// GracefulDrainer signals the framework to begin shutting down. When
	// Wait returns nil, Init's background goroutine calls [Server.Close].
	// Optional; nil means the caller is responsible for calling [Server.Close] directly.
	GracefulDrainer GracefulDrainer

	// RateLimiter constructs the [RateLimiter] that enforces rate limits before
	// policy evaluation. [Init] calls the factory after workload identity and TLS
	// are established, providing the outbound mTLS config for the rate limit
	// backend connection via [RateLimiterArgs.OutboundTLSConfig]. Unlike other
	// factory types, [RateLimiterFactory] returns an error; Init fails if the
	// factory returns an error (e.g. because the Valkey backend is unreachable).
	//
	// Optional; nil defaults to [NoopRateLimiterFactory] (all requests allowed).
	RateLimiter RateLimiterFactory

	// Telemetry receives structured events and operational metrics emitted by
	// the enforcement pipeline. Optional; nil defaults to [NoopTelemetry]
	// (all events discarded).
	Telemetry Telemetry

	// BundleFetcher constructs the [BundleFetcher] that retrieves signed policy
	// bundles from the bundle server. [Init] calls the factory with a
	// [BundleFetcherArgs] after workload identity and TLS are established,
	// providing the outbound mTLS config for the bundle server connection. On startup, Init calls
	// [BundleFetcher.Fetch] to load the initial bundle and passes the bytes to
	// [PolicyEngine.Reload]; Init fails if either call fails. After Init
	// returns, a background goroutine calls [BundleFetcher.WatchBundles] and
	// forwards each update to [PolicyEngine.Reload].
	//
	// Configuring BundleFetcher alongside a [PolicyEngine] that returns
	// [ErrNotSupported] from Reload is a misconfiguration; Init returns
	// [ErrPolicyEngineReloadRequired] in that case.
	//
	// Optional; nil means no bundle-based hot-reload — the policy engine is
	// responsible for its own policy loading.
	BundleFetcher BundleFetcherFactory

	// Secrets constructs the [Secrets] that retrieves secrets from
	// a backing store. [Init] calls the factory after workload identity and
	// TLS are established, providing the outbound mTLS config for the secrets
	// backend connection via [SecretsArgs.OutboundTLSConfig]. [Init]
	// starts a background goroutine that watches for revocation events and
	// evicts affected cache entries. Application code accesses secrets via
	// [Server.Secrets], never by holding a reference to this provider
	// directly.
	//
	// Optional; nil means no secrets provider is configured and
	// [Server.Secrets].Get always returns [ErrNotSupported].
	Secrets SecretsFactory

	// SecretRenewalLeadTime is how long before a secret's TTL expires the
	// framework begins proactive background renewal. The actual trigger is
	// min(SecretRenewalLeadTime, ttl/2) so that short-lived secrets are
	// renewed at their halfway point rather than before they are meaningfully
	// used. Zero uses [DefaultSecretRenewalLeadTime].
	//
	// Only meaningful when [Config.Secrets] is non-nil.
	SecretRenewalLeadTime time.Duration

	// ServiceRegistry constructs the [ServiceRegistry] used to resolve
	// logical service names to endpoint sets for outbound calls. [Init] calls
	// the factory after workload identity and TLS are established, providing
	// the outbound TLS config via [ServiceRegistryArgs]. Required when
	// [Config.Outbound] is non-nil; Init returns an error if ServiceRegistry
	// is set without EndpointSelector and Outbound.
	//
	// Optional; nil means the framework cannot make outbound calls.
	ServiceRegistry ServiceRegistryFactory

	// EndpointSelector creates per-pool selectors for outbound calls. Each pool
	// combines load balancing, outlier detection, and concurrency limiting for
	// one fixed endpoint set. Required when [Config.Outbound] is non-nil; Init
	// returns an error if EndpointSelector is set without ServiceRegistry and
	// Outbound.
	//
	// Optional; nil means the framework cannot make outbound calls.
	EndpointSelector EndpointSelector

	// Outbound constructs the [OutboundTransport] that manages outbound mTLS
	// connections to other services. [Init] calls the factory after workload
	// identity and TLS are established, providing service discovery and WPT
	// signing material via [OutboundTransportArgs]. Requires both
	// [Config.ServiceRegistry] and [Config.EndpointSelector] to be non-nil.
	//
	// Optional; nil means the framework cannot make outbound calls.
	Outbound OutboundTransportFactory

	// CircuitBreaker stops outbound calls to a service that has exceeded its
	// error threshold. The outbound transport checks the breaker before each
	// call attempt and records outcomes after each attempt. Optional; nil means
	// circuit breaking is disabled.
	CircuitBreaker CircuitBreaker

	// RetryBudget gates retry attempts with a sliding-window budget across all
	// services. Optional; nil means no budget cap.
	RetryBudget RetryBudget

	// RetryPolicy looks up the retry configuration for a named service.
	// Optional; nil means no retry for any service. The static implementation
	// ([provider/retrypolicy/static]) wraps a fixed map; a dynamic
	// implementation may reload policies from etcd without restarting.
	RetryPolicy RetryPolicyProvider

	// ContextServiceClient constructs the [ContextServiceClient] used at
	// trust-domain entry points to exchange incoming OAuth access tokens for
	// Transaction Tokens. [Init] calls the factory after workload identity is
	// established.
	//
	// Optional; nil means no Context Service is configured and the framework
	// never calls [ContextServiceClient.Exchange].
	ContextServiceClient ContextServiceClientFactory

	// TrustBoundary configures the paths at which the framework calls
	// [ContextServiceClient.Exchange]. Ignored when [Config.ContextServiceClient]
	// is nil.
	TrustBoundary TrustBoundaryConfig

	// ServiceRegistrar constructs the [ServiceRegistrar] that publishes this
	// service instance to the registry backend at startup and withdraws it at
	// graceful shutdown. [Init] calls the factory after workload identity and
	// TLS are established.
	//
	// Required when [Config.ServiceRegistration] is non-nil; [Init] returns
	// [ErrServiceRegistrationRequired] if ServiceRegistrar is set without
	// ServiceRegistration. The converse ([Config.ServiceRegistration] set
	// without ServiceRegistrar) returns [MissingRequiredProvidersError].
	//
	// Optional; nil means this service does not self-register.
	ServiceRegistrar ServiceRegistrarFactory

	// ServiceRegistration describes the endpoint to publish to the registry at
	// startup. The framework populates [Endpoint.SPIFFEID] from the current
	// SVID before calling [ServiceRegistrar.Register].
	//
	// Required when [Config.ServiceRegistrar] is non-nil.
	ServiceRegistration *ServiceRegistration

	// HealthSignalExposer constructs the [HealthSignalExposer] that exposes the
	// framework's aggregate health state to external observers (e.g. a
	// Kubernetes readiness probe). [Init] calls the factory with a
	// [HealthSignalExposerArgs] once the monitor is created. Optional; nil
	// Optional; nil means health state is not exposed externally.
	HealthSignalExposer HealthSignalExposerFactory

	// PolicyLatencyWarnThreshold is the policy evaluation duration above which
	// the framework logs a warning. Zero uses [DefaultPolicyLatencyWarnThreshold].
	PolicyLatencyWarnThreshold time.Duration

	// PolicyEvalTimeout is the maximum time allowed for a single policy
	// evaluation. Exceeding this deadline cancels the evaluation context and
	// denies the request fail-closed. Zero uses [DefaultPolicyEvalTimeout].
	PolicyEvalTimeout time.Duration

	// Clock is the time source used for timestamps and backoff timers. Optional;
	// nil defaults to [quartz.NewReal]. Inject [quartz.NewMock] in tests to
	// control time deterministically.
	Clock quartz.Clock

	// Logger receives structured log events from the framework. Optional;
	// nil defaults to [slog.Default]. Callers can bridge any third-party
	// logger (zap, zerolog, etc.) by supplying a [slog.Handler] adapter.
	Logger *slog.Logger

	// PreStopHooks are called sequentially after the service transitions to
	// [Draining] and before [Binding.Shutdown]. Each hook receives a context
	// bounded by [Config.PreStopTimeout]. Errors are logged at warn but do not
	// abort shutdown — hooks must not be able to delay shutdown indefinitely.
	//
	// Optional; nil means no pre-stop hooks are configured.
	PreStopHooks []func(context.Context) error

	// PreStopTimeout bounds the total wall-clock time allowed for all
	// [Config.PreStopHooks] to complete. Zero defaults to 5s.
	//
	// Only meaningful when [Config.PreStopHooks] is non-nil.
	PreStopTimeout time.Duration

	// IdentityGracePeriod is how long the framework continues using the last
	// valid SVID after a rotation error before transitioning [KindIdentity] to
	// [Unavailable]. During the grace window [KindIdentity] is [Degraded] and
	// the existing SVID remains in use for mTLS.
	//
	// Zero uses the current SVID's NotAfter as the implicit deadline — the
	// framework keeps serving until the certificate actually expires.
	IdentityGracePeriod time.Duration

	// SPIFFEID, if non-empty, selects the SVID with this SPIFFE ID from the
	// list returned by [Config.Identity] at Init. When empty, the framework
	// uses the first SVID in the list. Init returns an error if this ID is
	// set but absent from the identity provider's response.
	SPIFFEID string

	// MetricsInterval is the period at which the framework calls
	// [Telemetry.RecordMetrics] with a snapshot of aggregated framework health
	// metrics. Zero defaults to 30s. The metrics loop is skipped entirely when
	// the configured [Telemetry] provider returns [ErrNotSupported] from
	// [Telemetry.RecordMetrics] (e.g. when using the slog provider).
	MetricsInterval time.Duration
}

Config holds the providers and settings passed to Init.

The zero value is not a valid configuration: Identity, Policy, and Binding are required. All other fields have safe zero-value defaults.

type ContextServiceClient

type ContextServiceClient interface {
	// Exchange performs RFC 8693 token exchange: validates the incoming subject
	// token against the configured IdP, then issues a signed Transaction Token
	// carrying the verified caller identity and scope.
	//
	// If the subject token is invalid or the Context Service is unavailable,
	// Exchange returns an error. The framework is fail-closed: an Exchange
	// error at a configured trust-boundary path denies the request rather
	// than forwarding the raw credential downstream.
	Exchange(ctx context.Context, req *TokenExchangeRequest) (*TransactionToken, error)

	// VerifyTransactionToken validates the signature and expiry of a
	// Transaction Token received from an upstream service leg. Returns the
	// decoded claims on success. On error, the framework treats the token as
	// absent (fail-safe) rather than denying the request, because internal
	// services that receive a forwarded token should not fail if the upstream
	// leg used a different authentication path.
	VerifyTransactionToken(ctx context.Context, token string) (*TransactionTokenClaims, error)

	io.Closer
}

ContextServiceClient exchanges incoming credentials for Transaction Tokens and verifies inbound Transaction Tokens at trust-domain boundaries.

A trust-domain boundary is an API gateway that terminates mTLS and receives an OAuth 2.0 access token from an external caller. The framework calls [ContextServiceClient.Exchange] at configured boundary paths to convert the incoming credential into a signed Transaction Token that can be propagated across all subsequent internal hops.

Required methods: [ContextServiceClient.Exchange], [ContextServiceClient.VerifyTransactionToken], and io.Closer.

There are no optional methods today. Embed UnimplementedContextServiceClient to remain forward-compatible when optional methods are added.

type ContextServiceClientArgs

type ContextServiceClientArgs struct {
	// OutboundTLSConfig is the framework-managed *tls.Config for outbound
	// mTLS connections, e.g. for fetching JWKS over mTLS.
	OutboundTLSConfig *tls.Config

	// GetSnapshot returns an atomic snapshot of the workload's current SVID
	// material. Callers must invoke it once per operation and read all needed
	// fields from the returned value to guarantee consistency across a rotation.
	GetSnapshot func() SVIDSnapshot

	// Monitor is the framework health monitor. Providers use it to transition
	// [KindContextServiceClient] between [Ready] and [Unavailable] when their
	// external dependencies (e.g. a JWKS endpoint) become unavailable or
	// recover. Nil when no [Config.ContextServiceClient] is configured.
	Monitor *Monitor
}

ContextServiceClientArgs holds the ligand-internal values that ContextServiceClientFactory implementations receive from Init.

type ContextServiceClientFactory

type ContextServiceClientFactory func(*ContextServiceClientArgs) ContextServiceClient

ContextServiceClientFactory is a constructor called by Init after workload identity and TLS are established. It returns a ready-to-use ContextServiceClient. The ContextServiceClientArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

type DecisionEvent

type DecisionEvent struct {
	// Timestamp is the time the request arrived at the enforcement pipeline.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// Chain is the full verified identity chain as received and evaluated,
	// with the immediate peer as the last element. Always populated; contains
	// at least the immediate peer link.
	Chain Chain `json:"chain"`

	// Resource and Action identify the access being requested. Always populated.
	Resource Resource `json:"resource"`
	Action   Action   `json:"action"`

	// Allowed reports whether the request was permitted by policy.
	// Always populated.
	Allowed bool `json:"allowed"`

	// Latency is the wall-clock time from request arrival to the enforcement
	// decision, including time spent in the rate limiter. Always populated.
	Latency time.Duration `json:"latency"`

	// PolicyLatency is the wall-clock time spent in policy evaluation only,
	// excluding rate-limiter time. Always populated.
	PolicyLatency time.Duration `json:"policy_latency"`

	// PolicyVersion is the version identifier of the policy bundle active
	// during evaluation, as reported by the policy engine. Optional; empty if
	// the engine does not expose version information.
	PolicyVersion string `json:"policy_version,omitempty"`

	// Reason is the human-readable explanation returned by the policy engine.
	// Optional; empty when the engine does not provide one.
	Reason string `json:"reason,omitempty"`

	// AuditMetadata contains arbitrary key-value pairs emitted by the policy
	// engine for audit purposes. Optional; nil when not provided. Not currently set.
	AuditMetadata map[string]any `json:"audit_metadata,omitempty"`

	// Err is set when policy evaluation itself failed (as opposed to returning
	// an explicit denial). Optional; nil for normal allowed or denied decisions.
	// Providers should serialize this as err.Error() if they need a string
	// representation; the field is excluded from default JSON marshaling.
	Err error `json:"-"`
}

DecisionEvent is emitted by the framework for every request that reaches policy evaluation, whether the request was ultimately allowed or denied. It is the tamper-evident decision audit record required by Section 7.

type DeregisterRequest

type DeregisterRequest struct {
	// SPIFFEID is the SPIFFE ID under which this endpoint was registered.
	SPIFFEID string

	// Address is the host:port to remove. The framework uses the same
	// address that was passed to [RegisterRequest.Endpoint.Address].
	Address string
}

DeregisterRequest is the argument to [ServiceRegistrar.Deregister].

type EjectionEvent

type EjectionEvent struct {
	// Timestamp is the time the ejection or reinstatement was recorded.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// Endpoint is the affected endpoint. Always populated.
	Endpoint Endpoint `json:"endpoint"`

	// Ejected is true when the endpoint was ejected, false when reinstated.
	Ejected bool `json:"ejected"`

	// EjectionCount is the total number of times this endpoint has been
	// ejected, including this event. Used to compute the exponential backoff
	// duration. Zero on reinstatement.
	EjectionCount int `json:"ejection_count,omitempty"`

	// Duration is the length of the ejection window. Zero on reinstatement.
	Duration time.Duration `json:"duration,omitempty"`
}

EjectionEvent is emitted by the OutlierDetector when an endpoint is ejected from or reinstated into the load-balancing pool.

type Endpoint

type Endpoint struct {
	// Address is the host:port to connect to.
	Address string

	// HealthAddr is the host:port of the plain-HTTP health endpoint (/healthz).
	// Empty when the instance does not expose a health signal.
	HealthAddr string

	// SPIFFEID is the SPIFFE ID the endpoint's TLS certificate must present.
	// The framework verifies this before establishing a connection; a mismatch
	// is treated as a refused connection regardless of TLS handshake outcome.
	SPIFFEID string

	// Locality groups endpoints by failure domain. Used by locality-aware
	// load-balancing algorithms to prefer same-zone endpoints.
	Locality Locality

	// Metadata carries arbitrary key-value pairs from the registry. Consumers
	// must not assume any specific keys are present.
	Metadata map[string]string
}

Endpoint is a single healthy instance of a logical service that the framework may route outbound calls to.

type EndpointSelector

type EndpointSelector interface {
	// ForPool returns a [PoolSelector] bound to the endpoint set in req.
	// The caller owns the returned PoolSelector and must Close it when the
	// endpoint set is replaced.
	ForPool(ctx context.Context, req *ForPoolRequest) PoolSelector

	io.Closer
}

EndpointSelector is a factory that creates per-pool selectors. Configured as [Config.EndpointSelector]. Implementations embed any combination of ejection algorithm, load balancing policy, and concurrency limiting.

Why a factory: per-pool state (round-robin position, in-flight counters, consecutive error counts, ejection timers) lives exactly as long as the endpoint set. When the set changes, the old PoolSelector is closed and a fresh one starts clean — which is what blue/green deployment requires. A single stateful selector would need complex delta reconciliation on endpoint set changes; the factory eliminates that entirely.

type EndpointUpdate

type EndpointUpdate struct {
	// Endpoints is the new complete set of healthy endpoints. Replaces any
	// previously received set.
	Endpoints []Endpoint

	// Err is set when the registry encountered an error delivering the update.
	// Consumers should continue watching; the registry will recover and send
	// a fresh update when connectivity is restored.
	Err error
}

EndpointUpdate is delivered on the channel returned by [ServiceRegistry.WatchEndpoints] whenever the endpoint set changes.

type EnforceFunc

type EnforceFunc func(ctx context.Context, req *InboundRequest) (context.Context, error)

EnforceFunc is the enforcement pipeline entry point passed to Binding implementations via BindingArgs. It has the same signature as [Server.Enforce]: call it once per inbound request, use the returned context for the application handler (it carries the inbound chain and, when present, the Transaction Token), and translate the returned error to the appropriate protocol-level denial.

type EvalRequest

type EvalRequest struct {
	// Chain is the ordered sequence of verified principals that have handled
	// the request before it reached this service. The policy engine may
	// evaluate any or all links when making an authorization decision.
	// The immediate caller is the last link whose Workload field is non-empty.
	// See the chain package for the full identity model.
	Chain Chain

	// Resource identifies the resource being accessed.
	Resource Resource

	// Action identifies the operation being performed on the resource.
	Action Action

	// PIPs holds signal data contributed by Policy Information Points before
	// policy evaluation. Reserved for future PIP composition; always nil in the
	// current implementation.
	PIPs map[string]any
}

EvalRequest is the request type for [PolicyEngine.Evaluate].

type EvalResponse

type EvalResponse struct {
	// Allowed indicates whether the request is permitted by policy.
	// The framework denies the request if Allowed is false.
	Allowed bool

	// Reason is a human-readable explanation of the policy decision, intended
	// for operator logs and audit records. It is empty when the policy engine
	// does not provide one.
	Reason string

	// PolicyVersion is the version identifier of the policy bundle that was
	// active during evaluation. Empty when the engine does not expose version
	// information. The framework records this in every [DecisionEvent].
	PolicyVersion string

	// AuditMetadata contains arbitrary key-value pairs emitted by the policy
	// engine for audit and observability. The framework records these alongside
	// the decision but does not interpret them. Nil when not provided. Not currently set.
	AuditMetadata map[string]any
}

EvalResponse is the response type for [PolicyEngine.Evaluate].

type EventStream

type EventStream[T any] <-chan T

EventStream carries events that must all be processed in order. No events may be discarded. Consume with range. The channel is closed when the watch ends or the context passed to the Watch method is cancelled.

type FetchBundleRequest

type FetchBundleRequest struct {
	// KnownETag is the ETag of the last bundle fetched by this caller. The
	// server may return a response with the same ETag and nil Bundle to
	// indicate no change. Empty on the first call.
	KnownETag string
}

FetchBundleRequest is the request type for [BundleFetcher.Fetch].

type FetchBundleResponse

type FetchBundleResponse struct {
	// Bundle contains the raw bundle bytes (typically an OPA bundle tar.gz).
	// Nil when the bundle has not changed since KnownETag.
	Bundle []byte

	// ETag is the version identifier for this bundle, suitable for use as
	// KnownETag in a subsequent request. Empty if the server does not support
	// conditional fetches.
	ETag string

	// Version is a human-readable bundle version string for logs and
	// telemetry. Empty if the server does not expose version metadata.
	Version string
}

FetchBundleResponse is the response type for [BundleFetcher.Fetch].

type FetchWITRequest

type FetchWITRequest struct {
	// Audience is the intended recipient's SPIFFE ID or URI.
	Audience string

	// CNFPublicKey is the workload's public key to embed in the WIT's
	// cnf.jwk claim. The Identity Server signs the WIT but never holds the
	// corresponding private key; the workload retains it for WPT signing.
	CNFPublicKey crypto.PublicKey
}

FetchWITRequest is the request type for [WorkloadIdentity.FetchWIT].

type FetchWITResponse

type FetchWITResponse struct {
	// WIT is the signed Workload Identity Token JWT string, ready to send
	// in the Workload-Identity-Token HTTP header.
	WIT string

	// ExpiresAt is when the WIT expires, derived from the JWT exp claim.
	ExpiresAt time.Time
}

FetchWITResponse is the response type for [WorkloadIdentity.FetchWIT].

type FetchX509ContextRequest

type FetchX509ContextRequest struct{}

FetchX509ContextRequest is the request type for [WorkloadIdentity.FetchX509Context].

type FetchX509ContextResponse

type FetchX509ContextResponse struct {
	// SVIDs is the list of X.509 SVIDs issued to this workload. There is
	// typically one, but a workload may hold multiple — for example during
	// key-rotation overlap or when operating across multiple trust domains.
	SVIDs []*X509SVID

	// TrustBundles maps each trust domain name to its CA certificates.
	// A workload must include at least its own trust domain; federation
	// requires entries for each federated domain.
	TrustBundles map[string][]*x509.Certificate

	// JWTAuthorities maps each trust domain name to the public keys that
	// its Identity Server uses to sign Workload Identity Tokens (WITs).
	// These are the SPIFFE trust bundle's jwt_authorities, populated by the
	// Identity Server (SPIRE) once it implements draft-ietf-wimse-workload-creds
	// (tracked in SPIFFE issue #315). Until then, providers may return nil.
	// [FakeWorkloadIdentity] populates this with the SVID public key
	// so that the full WIT/WPT verification path is exercisable in tests.
	JWTAuthorities map[string][]crypto.PublicKey
}

FetchX509ContextResponse is the response type for [WorkloadIdentity.FetchX509Context] and the value carried by X509ContextUpdate.

type ForPoolRequest

type ForPoolRequest struct {
	// SPIFFEID is the SPIFFE ID of the service pool being created.
	SPIFFEID string

	// Endpoints is the fixed set of endpoints for this pool.
	Endpoints []Endpoint
}

ForPoolRequest is the argument to [EndpointSelector.ForPool].

type GetSecretRequest

type GetSecretRequest struct {
	// Name is the full backend path for the secret, e.g.
	// "secret/data/myapp/db-password" for a Vault/OpenBao KV v2 mount.
	Name string
}

GetSecretRequest is the request type for [Secrets.Get].

type GetSecretResponse

type GetSecretResponse struct {
	// Value is the raw secret bytes returned by the backend.
	Value []byte

	// TTL is how long the framework should consider this value valid. The
	// framework schedules a proactive renewal before TTL expires. A zero TTL
	// means the backend did not specify an expiry; the framework treats it as
	// a non-renewable secret and does not schedule renewal.
	TTL time.Duration

	// LeaseID is an opaque backend identifier used by the framework to match
	// revocation events from [Secrets.WatchRevocations] to cached
	// entries. Providers set this to whatever token the backend uses to
	// identify the lease (e.g. a Vault lease ID). Empty means the backend
	// does not issue leases for this secret.
	LeaseID string
}

GetSecretResponse is the response type for [Secrets.Get].

This type is the provider-to-framework communication layer. Application code never holds a GetSecretResponse directly — they receive only the raw bytes via Server.GetSecret. All fields are therefore safe to export for use by provider implementations in other packages.

type GracefulDrainer

type GracefulDrainer interface {
	// Wait blocks until a drain signal is received or ctx is cancelled.
	// It returns nil when a signal is received and the framework should begin
	// draining. It returns ctx.Err() when ctx is cancelled.
	Wait(ctx context.Context) error

	io.Closer
}

GracefulDrainer signals to the framework that a shutdown is imminent, triggering orderly connection draining before the process exits. The framework stops accepting new connections and waits for in-flight requests to complete within a configured deadline.

type HealthChecker

type HealthChecker interface {
	io.Closer
}

HealthChecker probes an endpoint to determine whether it is healthy and eligible to receive traffic. Results feed the OutlierDetector and the ServiceRegistry's view of available endpoints.

type HealthReport

type HealthReport struct {
	// SPIFFEID is the SPIFFE ID under which this endpoint is registered.
	SPIFFEID string

	// Address is the host:port of the endpoint to update.
	Address string

	// Healthy controls whether this endpoint appears in [ServiceRegistry.Resolve]
	// results. When false the endpoint is removed from the registry but the
	// backend lease is kept alive for fast recovery. The framework calls
	// ReportHealth with Healthy false during graceful shutdown.
	Healthy bool
}

HealthReport is the argument to [ServiceRegistrar.ReportHealth].

type HealthSignalExposer

type HealthSignalExposer interface {
	// Serve starts the exposer and blocks until ctx is cancelled or [io.Closer]
	// is called. It returns nil when stopped cleanly and a non-nil error for
	// unexpected failures.
	Serve(ctx context.Context) error

	// Listening returns a channel that is closed once the exposer's listener is
	// bound and its address is known. [Init] blocks on this channel so that
	// [HealthSignalExposer.Addr] is guaranteed to return the final address before
	// Init returns.
	Listening() <-chan struct{}

	// Addr returns the host:port the exposer is listening on. It blocks until
	// [Listening] is closed. Returns "" for implementations that do not listen
	// on a network address.
	Addr() string

	io.Closer
}

HealthSignalExposer exposes the framework's aggregate health state to external observers. The most common implementation is an HTTP endpoint used as a Kubernetes readiness or liveness probe.

All methods are required. Embed UnimplementedHealthSignalExposer to remain forward-compatible if optional methods are added in the future.

type HealthSignalExposerArgs

type HealthSignalExposerArgs struct {
	// Monitor is the framework health monitor. Call [Monitor.Overall] to
	// determine the aggregate health state to expose to external observers.
	Monitor *Monitor
}

HealthSignalExposerArgs holds the ligand-internal values that HealthSignalExposerFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type HealthSignalExposerFactory

type HealthSignalExposerFactory func(*HealthSignalExposerArgs) HealthSignalExposer

HealthSignalExposerFactory is a constructor called by Init after the framework monitor is created. It returns a ready-to-use HealthSignalExposer. The HealthSignalExposerArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Implementations should expose a factory function alongside their concrete constructor; see package httphealth for the canonical example.

type HealthTransitionEvent

type HealthTransitionEvent struct {
	// Timestamp is the time the transition was recorded by the monitor.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// Kind identifies the provider whose state changed. Always populated.
	Kind ProviderKind `json:"kind"`

	// From is the state before the transition; To is the state after.
	// Always populated.
	From State `json:"from"`
	To   State `json:"to"`

	// Err is the error that caused the transition. Optional; nil for normal
	// lifecycle transitions (e.g. Initializing → Ready on startup).
	// Providers should serialize this as err.Error() if they need a string
	// representation; the field is excluded from default JSON marshaling.
	Err error `json:"-"`
}

HealthTransitionEvent is emitted when a provider's health state changes.

type InboundRequest

type InboundRequest struct {
	// Chain is the upstream principal chain propagated from callers that
	// handled the request before the immediate peer. May be empty if the
	// peer did not propagate chain context.
	//
	// For [Server.EnforceInbound] callers: populate this field directly with
	// whatever identity signals are available for the originating caller.
	// [Server.EnforceInbound] uses Chain as-is without appending a peer link.
	Chain Chain

	// Peer is the immediate upstream caller, represented as a [Link].
	// [Binding] implementations set Peer from the mTLS peer certificate:
	//
	//	Peer: ligand.Link{Workload: spiffeID}
	//
	// [Server.Enforce] appends Peer to Chain to form the evaluation chain
	// delivered to rate limiting and policy. Leave Peer as the zero [Link]{}
	// when calling [Server.EnforceInbound] — that method uses Chain directly.
	Peer Link

	// Resource and Action describe what the caller is requesting.
	Resource Resource
	Action   Action

	// SubjectToken is an OAuth 2.0 access token extracted by the binding from
	// the inbound request (e.g. "Authorization: Bearer <token>"). Present only
	// at trust-domain entry points where an external caller presents a bearer
	// token instead of a client TLS certificate. The framework exchanges it for
	// a [TransactionToken] when the request path matches a configured
	// [TrustBoundaryConfig.Paths] entry and a [ContextServiceClient] is
	// configured. Application code must never read this field directly.
	SubjectToken string

	// TransactionToken is a signed JWT forwarded from an upstream service leg,
	// extracted by the binding from the inbound request. The framework verifies
	// its signature via [ContextServiceClient.VerifyTransactionToken]; on
	// failure it is cleared (fail-safe) and the request continues without it.
	// Application code must never read this field directly.
	TransactionToken string
}

InboundRequest carries the per-request metadata extracted by a Binding from an inbound connection and passed to [Server.Enforce].

type IsEjectedRequest

type IsEjectedRequest struct {
	// Endpoint is the endpoint to check.
	Endpoint *Endpoint
}

IsEjectedRequest is the argument to [OutlierDetector.IsEjected].

type JTICache

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

JTICache tracks JWT ID (jti) values to detect replay attacks within a token's validity window. Entries are pruned when their exp time has passed — once a token expires its replay cannot succeed via the exp check, so there is no security benefit to retaining old entries indefinitely.

func NewJTICache

func NewJTICache(clock quartz.Clock) *JTICache

NewJTICache returns a new JTICache backed by clock.

func (*JTICache) Len

func (c *JTICache) Len() int

Len returns the number of entries currently in the cache.

func (*JTICache) Seen

func (c *JTICache) Seen(jti string, exp time.Time) bool

Seen returns true if jti has already been recorded and its exp has not yet passed, indicating a replay. Otherwise it records the jti and returns false. Up to maxPrunePerSeen expired entries are pruned per call; expiry is also checked at lookup time so uncapped stale entries never cause false replays.

type LatestRecv

type LatestRecv[T any] <-chan T

LatestRecv is the consumer side of a latest-value channel. Only the most recent value matters; intermediate values may be safely discarded. Consume with Latest. The channel is closed when the watch ends or the context passed to the Watch method is cancelled.

type LatestSend

type LatestSend[T any] chan T

LatestSend is the publisher side of a latest-value channel. Publish with Set, which evicts any unseen prior value before sending. Return it to consumers as LatestRecv[T].

type Link struct {
	// Workload is the SPIFFE ID of the workload (microservice, batch job,
	// serverless function, CI/CD step, or AI agent) that handled this hop.
	// Empty if this hop did not originate from a workload.
	Workload string

	// User is the IdP subject string of the human principal associated with
	// this hop (e.g. an email address or an opaque subject claim). Empty if no
	// user identity is associated with this hop.
	User string

	// Device is the hardware or hypervisor-level attestation identity of the
	// execution environment that handled this hop. Rooted in hardware
	// attestation where possible (e.g. TPM). Empty if not attested.
	// Distinct from [Link.Host]: a device may have a valid hardware identity
	// while its host OS is out of compliance, and policy must evaluate each
	// independently.
	Device string

	// Host is the OS and software-stack identity of the host running on the
	// [Link.Device] at the time of this hop — the specific kernel, OS
	// configuration, and installed software. Empty if not attested.
	// Unlike device identity, host identity is mutable: it changes across
	// reboots, OS updates, and configuration changes.
	Host string

	// ExternalParty is the credential subject of an entity outside the
	// organisation's direct control (a third-party SaaS vendor, a customer
	// system, a partner API, or an external service). Empty if this hop is not
	// from an external party.
	ExternalParty string
}

Link represents one hop in the request Chain — a single principal that has handled the request. Each field captures a distinct identity dimension for that hop, corresponding to the identity categories in §3.3. Fields are empty strings when the identity was not attested or is not applicable to the hop.

All fields are singular. The multi-principal case (e.g. a support agent acting on behalf of a customer) is a known limitation of this model; it will be revisited when the WIMSE standard matures.

MAINTENANCE: When adding support for a new identity dimension, add a field here and update pipeline in serve.go to populate it. AI agents are currently represented via the Workload field per §3.3 (non-adoption note).

func (*Link) Principal

func (l *Link) Principal() string

Principal returns the first non-empty identity field in order: Workload, User, ExternalParty, Device, Host. Returns empty string if no field is set.

type LoadBalancer

type LoadBalancer interface {
	// Select chooses one endpoint from req.Candidates for the current request.
	// Returns an error if candidates is empty or selection fails. Must not
	// modify the candidates slice.
	Select(ctx context.Context, req *SelectRequest) (*SelectResponse, error)

	// RecordOutcome informs the load balancer of the result of a call to the
	// previously selected endpoint. Algorithms that track in-flight count
	// (e.g. least-request) use this to decrement their counter.
	//
	// RecordOutcome is notification-style: it has no return value and must
	// not block. Embed [UnimplementedLoadBalancer] to receive a no-op
	// implementation for algorithms that do not need outcome feedback.
	RecordOutcome(ctx context.Context, req *RecordLoadBalancerOutcomeRequest)

	io.Closer
}

LoadBalancer selects an endpoint from the set of candidates supplied by the ServiceRegistry for each outbound call.

type Locality

type Locality struct {
	// Zone is the availability zone within Region (e.g. "us-east-1a").
	Zone string

	// Region is the geographic region (e.g. "us-east-1"). Empty when
	// locality-aware load balancing is not in use.
	Region string
}

Locality describes the physical or logical placement of an Endpoint.

type MetricsSnapshot

type MetricsSnapshot struct {
	// Timestamp is the time the snapshot was collected. Always populated.
	Timestamp time.Time `json:"timestamp"`

	// SVIDAge is the wall-clock age of the current SVID leaf certificate,
	// computed as clock.Now() minus the certificate's NotBefore time.
	// Zero when the SVID has not yet been loaded.
	SVIDAge time.Duration `json:"svid_age,omitempty"`

	// SVIDTimeToNextRotation is the remaining valid lifetime of the current
	// SVID leaf certificate, computed as the certificate's NotAfter time
	// minus clock.Now(). Zero when the SVID has not yet been loaded.
	SVIDTimeToNextRotation time.Duration `json:"svid_time_to_next_rotation,omitempty"`

	// PolicyBundleVersion is the version identifier of the most recently
	// loaded policy bundle, as reported by the bundle server. Empty when no
	// [Config.BundleFetcher] is configured or no bundle has been loaded yet.
	PolicyBundleVersion string `json:"policy_bundle_version,omitempty"`

	// PolicyBundleAge is the wall-clock time since the current policy bundle
	// was loaded or reloaded. Zero when no [Config.BundleFetcher] is configured.
	PolicyBundleAge time.Duration `json:"policy_bundle_age,omitempty"`

	// PolicyEvalP50, PolicyEvalP95, PolicyEvalP99 are the 50th, 95th, and
	// 99th percentile policy evaluation latencies computed over a rolling
	// window of the most recent 1000 evaluations. Zero until at least one
	// evaluation has been recorded.
	PolicyEvalP50 time.Duration `json:"policy_eval_p50,omitempty"`
	PolicyEvalP95 time.Duration `json:"policy_eval_p95,omitempty"`
	PolicyEvalP99 time.Duration `json:"policy_eval_p99,omitempty"`

	// SecretsCacheHits is the cumulative number of [Server.GetSecret] calls
	// served from the in-process secrets cache since [Init]. Zero when no
	// [Config.Secrets] is configured.
	SecretsCacheHits int64 `json:"secrets_cache_hits,omitempty"`

	// SecretsCacheMisses is the cumulative number of [Server.GetSecret] calls
	// that required a synchronous fetch from the secrets backend since [Init].
	// Zero when no [Config.Secrets] is configured.
	SecretsCacheMisses int64 `json:"secrets_cache_misses,omitempty"`
}

MetricsSnapshot carries a point-in-time snapshot of aggregated framework health metrics, delivered to [Telemetry.RecordMetrics] at each [Config.MetricsInterval] tick. Fields are zero when the corresponding subsystem is not configured or has not yet produced data.

type MissingRequiredProvidersError

type MissingRequiredProvidersError struct {
	// Missing lists each required [ProviderKind] that was nil in the Config.
	Missing []ProviderKind
}

MissingRequiredProvidersError is returned by Init when one or more required providers are nil in the supplied Config. All missing providers are reported at once so the caller can fix every gap in a single iteration.

func (*MissingRequiredProvidersError) Error

type Monitor

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

Monitor tracks the health state of all framework providers and derives an aggregate service state. It is safe for concurrent use.

All provider states start as Initializing. Required providers that become Unavailable cause the overall state to become Unavailable; required providers that are still Initializing cause it to remain Initializing. A required provider may be Degraded during a grace window after a transient error — the service continues accepting traffic but reports Degraded. Optional providers that become Unavailable or Degraded cause the overall state to become Degraded but do not prevent traffic.

func NewMonitor

func NewMonitor(clock quartz.Clock, telemetry Telemetry, logger *slog.Logger) *Monitor

NewMonitor returns a new Monitor with all providers in the Initializing state.

func (*Monitor) Overall

func (m *Monitor) Overall() State

Overall returns the aggregate service health state derived from all provider states.

Priority order: Unavailable > Initializing > Draining > Degraded > Ready.

func (*Monitor) Provider

func (m *Monitor) Provider(kind ProviderKind) (State, bool)

Provider returns the current state of the given provider kind, and whether kind is valid. Returns Initializing and false for unknown kinds.

func (*Monitor) Set

func (m *Monitor) Set(ctx context.Context, kind ProviderKind, s State, cause error)

Set updates the health state of the given provider kind. cause is the error responsible for the transition, or nil for normal lifecycle transitions (e.g. Initializing → Ready on startup). If the state changes, or there is an error, [Telemetry.RecordHealthTransition] is called synchronously after the lock is released.

type NoopRateLimiter

type NoopRateLimiter struct{}

NoopRateLimiter is a RateLimiter that allows all requests and does nothing on Close. It is used as the default when [Config.RateLimiter] is nil and may be used directly in tests or development environments where rate limiting is not yet needed.

func (NoopRateLimiter) Allow

Allow always returns Allowed: true.

func (NoopRateLimiter) Close

func (NoopRateLimiter) Close() error

Close is a no-op.

type NoopTelemetry

type NoopTelemetry struct{}

NoopTelemetry is a Telemetry that silently discards all events and metrics. It is used as the default when [Config.Telemetry] is nil and may be used directly in tests or environments where observability is not needed.

func (NoopTelemetry) Close

func (NoopTelemetry) Close() error

Close is a no-op and returns nil.

func (NoopTelemetry) RecordBundleFetch

func (NoopTelemetry) RecordBundleFetch(_ context.Context, _ *BundleFetchEvent) error

RecordBundleFetch discards the event and returns nil.

func (NoopTelemetry) RecordCircuitState

func (NoopTelemetry) RecordCircuitState(_ context.Context, _ *CircuitStateEvent) error

RecordCircuitState discards the event and returns nil.

func (NoopTelemetry) RecordDecision

func (NoopTelemetry) RecordDecision(_ context.Context, _ *DecisionEvent) error

RecordDecision discards the event and returns nil.

func (NoopTelemetry) RecordEjection

func (NoopTelemetry) RecordEjection(_ context.Context, _ *EjectionEvent) error

RecordEjection discards the event and returns nil.

func (NoopTelemetry) RecordHealthTransition

func (NoopTelemetry) RecordHealthTransition(_ context.Context, _ *HealthTransitionEvent) error

RecordHealthTransition discards the event and returns nil.

func (NoopTelemetry) RecordMetrics

func (NoopTelemetry) RecordMetrics(_ context.Context, _ *MetricsSnapshot) error

RecordMetrics discards the snapshot and returns nil.

func (NoopTelemetry) RecordOutboundCall

func (NoopTelemetry) RecordOutboundCall(_ context.Context, _ *OutboundCallEvent) error

RecordOutboundCall discards the event and returns nil.

func (NoopTelemetry) RecordRateLimit

func (NoopTelemetry) RecordRateLimit(_ context.Context, _ *RateLimitEvent) error

RecordRateLimit discards the event and returns nil.

type OutboundCallEvent

type OutboundCallEvent struct {
	// Timestamp is the time the outbound call was initiated.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// LocalSPIFFEID is the SPIFFE ID of the workload making the call.
	// Always populated.
	LocalSPIFFEID string `json:"local_spiffe_id"`

	// DestinationSPIFFEID is the SPIFFE ID of the target service, as passed
	// to [OutboundTransport.Transport] and verified against both the registry
	// and the peer TLS certificate. Always populated.
	DestinationSPIFFEID string `json:"destination_spiffe_id"`

	// Method is the request method (e.g. "GET", "POST" for HTTP).
	// May be empty if the call was refused before a request was sent.
	Method string `json:"method,omitempty"`

	// Outcome describes the result of the call. Always populated.
	Outcome OutboundOutcome `json:"outcome"`

	// Latency is the wall-clock time from call initiation to completion.
	// Always populated.
	Latency time.Duration `json:"latency"`

	// Err is the error returned by the outbound call, if any. Optional; nil
	// for successful calls. Excluded from default JSON marshaling.
	Err error `json:"-"`
}

OutboundCallEvent is emitted by the OutboundTransport on every outbound service call. Aggregated fleet-wide, these records form the service call graph required by Section 7 (Structured Telemetry).

type OutboundOutcome

type OutboundOutcome string

OutboundOutcome describes the result of an outbound service call emitted in an OutboundCallEvent.

const (
	// OutboundOutcomeSuccess indicates the call completed without a
	// connection-level error and the response status was below 5xx.
	OutboundOutcomeSuccess OutboundOutcome = "success"

	// OutboundOutcomeError indicates the call failed due to a connection-level
	// error, timeout, or a 5xx response status.
	OutboundOutcomeError OutboundOutcome = "error"

	// OutboundOutcomeRefused indicates the call was rejected before a
	// connection was established — for example, because the endpoint's
	// SPIFFE ID did not match the registry record, or the outbound transport
	// is shutting down.
	OutboundOutcomeRefused OutboundOutcome = "refused"
)

type OutboundTransport

type OutboundTransport interface {
	// Shutdown gracefully drains in-flight outbound calls within the deadline
	// imposed by ctx, then stops accepting new ones. Called by the framework
	// between [Binding.Shutdown] and SVID rotation cancellation so that live
	// outbound requests still see a valid certificate during drain. Symmetric
	// with [Binding.Shutdown].
	Shutdown(ctx context.Context) error

	io.Closer
}

OutboundTransport manages the lifecycle of outbound service connectivity: mTLS connection management, service discovery integration, and protocol-level security (SPIFFE ID verification, WPT/chain attachment).

The method for obtaining a transport handle is intentionally NOT in this interface, because different protocol implementations return incompatible types (http.RoundTripper for HTTP, *grpc.ClientConn for gRPC). Each concrete implementation exposes its own typed method:

// HTTP implementation:
func (p *Provider) Transport(ctx context.Context, spiffeID string) (http.RoundTripper, error)

Callers that need the concrete implementation use [Capture] on the provider:

var p *outboundhttp.Provider
srv, _ := ligand.Init(ctx, &ligand.Config{
    Outbound: outboundhttp.Capture(nil, &p),
})
rt, _ := p.Transport(ctx, "my-service")
client := &http.Client{Transport: rt}

type OutboundTransportArgs

type OutboundTransportArgs struct {
	// OutboundTLSConfig is the framework-managed *tls.Config for
	// outbound mTLS connections. The returned config reads [tlsState]
	// atomically so SVID and trust-bundle rotations take effect for new
	// connections without restart.
	OutboundTLSConfig *tls.Config

	// GetSnapshot returns an atomic snapshot of the workload's current SVID
	// material. Callers must invoke it once per operation and read all needed
	// fields from the returned value to guarantee consistency across a rotation.
	GetSnapshot func() SVIDSnapshot

	// ServiceRegistry resolves logical service names to endpoint sets.
	// Populated when [Config.ServiceRegistry] is non-nil.
	ServiceRegistry ServiceRegistry

	// EndpointSelector creates per-pool selectors that combine load balancing,
	// outlier detection, and concurrency limiting. Nil when
	// [Config.EndpointSelector] is not set; the transport uses basic resolution
	// without ejection or concurrency limiting in that case.
	EndpointSelector EndpointSelector

	// WPTSigner builds WIT and WPT tokens for outbound WIMSE authentication.
	// Always populated when the factory is called.
	WPTSigner *WPTSigner

	// Telemetry receives structured events emitted by the outbound transport
	// (e.g. [OutboundCallEvent]). Never nil; defaults to [NoopTelemetry].
	Telemetry Telemetry

	// Clock is the time source used for token expiry and latency measurement.
	// Never nil; defaults to [quartz.NewReal].
	Clock quartz.Clock

	// CircuitBreaker stops calls to a service that has exceeded its error
	// threshold. Nil when [Config.CircuitBreaker] is not set; the transport
	// skips circuit-breaking in that case.
	CircuitBreaker CircuitBreaker

	// RetryBudget gates retry attempts with a sliding-window budget. Nil means
	// no budget cap; retries are bounded only by [RetryConfig.MaxAttempts].
	RetryBudget RetryBudget

	// RetryPolicy looks up the retry configuration for a named service.
	// Nil means no retry for any service.
	RetryPolicy RetryPolicyProvider

	// Logger receives structured log events. Never nil.
	Logger *slog.Logger
}

OutboundTransportArgs holds the ligand-internal values that OutboundTransportFactory implementations receive from Init.

type OutboundTransportFactory

type OutboundTransportFactory func(*OutboundTransportArgs) OutboundTransport

OutboundTransportFactory is a constructor called by Init after workload identity and TLS are established. It returns a ready-to-use OutboundTransport. The OutboundTransportArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

type OutlierDetector

type OutlierDetector interface {
	// RecordOutcome reports the result of an outbound call to req.Endpoint.
	// The detector uses this signal to decide whether to eject the endpoint.
	RecordOutcome(ctx context.Context, req *RecordOutlierOutcomeRequest)

	// IsEjected reports whether req.Endpoint is currently ejected and should
	// be excluded from load-balancer selection. An ejected endpoint whose
	// ejection duration has expired is considered reinstated.
	IsEjected(ctx context.Context, req *IsEjectedRequest) bool

	// EjectedEndpoints returns the current set of ejected endpoints whose
	// ejection duration has not yet expired. Used for observability and by
	// active health-check loops.
	EjectedEndpoints(ctx context.Context) []Endpoint

	io.Closer
}

OutlierDetector identifies endpoints that are performing significantly worse than their peers and ejects them from the load balancing pool until they recover.

The OutboundTransport calls [OutlierDetector.RecordOutcome] after every outbound call and filters ejected endpoints via [OutlierDetector.IsEjected] before load-balancer selection. Ejections are time-limited with exponential backoff; endpoints auto-reinstate when their ejection duration expires.

All three methods are optional in enforcement semantics: if no OutlierDetector is configured, the framework skips outlier detection silently. Implementations that do not need all three methods should embed UnimplementedOutlierDetector.

type PolicyEngine

type PolicyEngine interface {
	// Evaluate evaluates policy for a single inbound request. The framework
	// calls Evaluate after rate limiting and identity verification have
	// completed. If Evaluate returns an error or EvalResponse.Allowed is
	// false, the request is denied.
	Evaluate(ctx context.Context, req *EvalRequest) (*EvalResponse, error)

	// Reload loads a new policy bundle into the engine. The framework calls
	// Reload whenever [BundleFetcher] delivers a new bundle version, passing
	// the raw bundle bytes received from the fetcher. In-flight [Evaluate]
	// calls complete against the current policy; subsequent calls use the
	// newly loaded policy.
	//
	// The framework treats [ErrNotSupported] as a misconfiguration error when
	// a [BundleFetcher] is configured: Init will return an error if the
	// engine does not support Reload but a fetcher is present.
	//
	// Optional: embed [UnimplementedPolicyEngine] if not supported.
	Reload(ctx context.Context, bundle []byte) error

	// WatchRevocations returns an [EventStream] that delivers a
	// [RevocationUpdate] each time the policy engine detects that a
	// previously-issued credential has been actively revoked. The policy
	// engine is the source of revocation events because it has direct
	// visibility into the policy store; the framework is the consumer and
	// uses the stream to invalidate cached decisions. The channel is closed
	// when ctx is cancelled. Every event must be processed; use range to
	// consume.
	//
	// Optional: embed [UnimplementedPolicyEngine] if not supported. The
	// framework falls back to TTL-based expiry on [ErrNotSupported].
	WatchRevocations(ctx context.Context) EventStream[RevocationUpdate]

	io.Closer
}

PolicyEngine evaluates access control policy for inbound requests. Policy evaluation is fail-closed: if the engine is unavailable or returns an error, the request is denied.

Required methods: [PolicyEngine.Evaluate] and io.Closer.

Optional methods: [PolicyEngine.Reload] and [PolicyEngine.WatchRevocations]. Providers that do not support these should embed UnimplementedPolicyEngine, which returns ErrNotSupported. The framework applies conservative safe defaults: no hot reload (TTL expiry only), no active revocation push.

type PoolSelector

type PoolSelector interface {
	// Pick selects one endpoint. Implementations may filter ejected endpoints,
	// enforce concurrency limits, and apply any load balancing algorithm.
	//
	// [ErrNoEndpoints] is returned when no healthy endpoint is available.
	// [ErrBulkheadFull] is returned when healthy endpoints exist but the
	// concurrency limit is reached.
	Pick(ctx context.Context) (Endpoint, error)

	// Release records the outcome and releases any concurrency resources
	// (semaphore slots, in-flight counters) acquired by Pick. Must be called
	// after every successful Pick regardless of call outcome. No-op after Close.
	Release(ctx context.Context, req *ReleaseRequest)

	io.Closer
}

PoolSelector handles endpoint selection for one fixed endpoint set. Lifetime matches the endpoint set: created by [EndpointSelector.ForPool], closed when the set changes. Release on a closed pool is a no-op so that in-flight calls completing after a pool replacement do not corrupt new-pool state.

type ProviderKind

type ProviderKind uint8

ProviderKind identifies a pluggable provider tracked by the health monitor.

MAINTENANCE: When adding a new pluggable interface to the framework, add a corresponding Kind constant to the required or optional block below. When changing a provider's required/optional status, move its constant across the numRequiredKinds boundary and update its doc comment. Failure to do so will cause the new provider to be silently ignored by the health monitor.

const (
	KindIdentity ProviderKind = iota // WorkloadIdentity
	KindPolicy                       // PolicyEngine
	KindBinding                      // Binding

)

Required provider kinds. The service denies all traffic while any required provider is not Ready. Add new required kinds above numRequiredKinds.

const (
	KindBundleFetcher        ProviderKind = numRequiredKinds + iota // BundleFetcher
	KindRateLimiter                                                 // RateLimiter
	KindTelemetry                                                   // Telemetry
	KindSecrets                                                     // Secrets
	KindServiceRegistry                                             // ServiceRegistry
	KindServiceRegistrar                                            // ServiceRegistrar
	KindSession                                                     // SessionStore
	KindDrain                                                       // GracefulDrainer
	KindEndpointSelector                                            // EndpointSelector
	KindRetryBudget                                                 // RetryBudget
	KindRetryPolicy                                                 // RetryPolicyProvider
	KindCircuitBreaker                                              // CircuitBreaker
	KindHealthChecker                                               // HealthChecker
	KindHealthSignalExposer                                         // HealthSignalExposer
	KindOutbound                                                    // OutboundTransport
	KindContextServiceClient                                        // ContextServiceClient

)

Optional provider kinds. The service becomes Degraded but continues accepting traffic when an optional provider is Unavailable. Add new optional kinds above numKinds.

func (ProviderKind) String

func (k ProviderKind) String() string

String returns a human-readable name for the provider kind.

type RateLimitEvent

type RateLimitEvent struct {
	// Timestamp is the time the request arrived at the enforcement pipeline.
	// Always populated.
	Timestamp time.Time `json:"timestamp"`

	// Chain is the identity chain as received at the enforcement boundary,
	// with the immediate peer as the last element. Always populated; contains
	// at least the immediate peer link.
	Chain Chain `json:"chain"`

	// Resource and Action identify the access being requested. Always populated.
	Resource Resource `json:"resource"`
	Action   Action   `json:"action"`

	// Allowed reports whether the rate limiter permitted the request.
	// Always populated.
	Allowed bool `json:"allowed"`

	// Latency is the wall-clock time spent in the rate limiter. Always populated.
	Latency time.Duration `json:"latency"`

	// CurrentRPS is the observed request rate for the calling identity over
	// the current window, as reported by the rate limiter. Optional; zero
	// when the implementation does not report utilisation.
	CurrentRPS float64 `json:"current_rps,omitempty"`

	// LimitRPS is the configured rate limit applied to the calling identity.
	// Optional; zero when the implementation does not report the configured limit.
	LimitRPS float64 `json:"limit_rps,omitempty"`

	// Err is set when the rate limiter returned an error rather than a normal
	// allow or deny decision. Optional; nil for normal outcomes.
	// Providers should serialize this as err.Error() if they need a string
	// representation; the field is excluded from default JSON marshaling.
	Err error `json:"-"`
}

RateLimitEvent is emitted by the framework on every call to [RateLimiter.Allow], whether the request was allowed or denied. It carries utilisation information when the rate limiter implementation reports it.

type RateLimitRequest

type RateLimitRequest struct {
	// Chain is the ordered sequence of verified principals that have handled
	// the request. The rate limiter may use it to apply per-identity limits.
	Chain Chain

	// Resource and Action identify what is being requested, allowing the
	// limiter to apply resource-scoped or action-scoped limits.
	Resource Resource
	Action   Action
}

RateLimitRequest is the request type for [RateLimiter.Allow].

type RateLimitResponse

type RateLimitResponse struct {
	// Allowed indicates whether the request is within the rate limit.
	// The framework denies the request if Allowed is false.
	Allowed bool

	// Reason is a human-readable explanation of the rate limit decision,
	// intended for operator logs. It is empty when the limiter does not
	// provide one.
	Reason string

	// CurrentRPS is the observed request rate for the calling identity over
	// the current window, as measured by the implementation. Zero means the
	// implementation does not report utilisation.
	CurrentRPS float64

	// LimitRPS is the configured rate limit for the calling identity. Zero
	// means the implementation does not report the configured limit.
	LimitRPS float64
}

RateLimitResponse is the response type for [RateLimiter.Allow].

type RateLimiter

type RateLimiter interface {
	// Allow reports whether the request identified by req is within the
	// configured rate limits. The framework calls Allow before policy
	// evaluation; a denial short-circuits the pipeline and returns a distinct
	// rejection to the caller.
	//
	// A nil error with Allowed false is a normal rate-limit denial.
	// A non-nil error is treated as a transient failure; the framework
	// applies the conservative safe default (deny).
	Allow(ctx context.Context, req *RateLimitRequest) (*RateLimitResponse, error)

	io.Closer
}

RateLimiter enforces request rate limits before policy evaluation. The framework applies rate limiting as the first enforcement step so that policy evaluation is never reached for requests that exceed configured limits.

Required methods: [RateLimiter.Allow] and io.Closer.

type RateLimiterArgs

type RateLimiterArgs struct {
	// OutboundTLSConfig is a *tls.Config for outbound mTLS connections from
	// this workload to the rate limit backend. It presents the current X.509
	// SVID via GetClientCertificate (updated atomically on each SVID rotation)
	// and verifies the server certificate against the SPIFFE trust bundle.
	// Nil when the framework has no outbound TLS configured.
	OutboundTLSConfig *tls.Config
}

RateLimiterArgs holds the ligand-internal values that RateLimiterFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type RateLimiterFactory

type RateLimiterFactory func(*RateLimiterArgs) (RateLimiter, error)

RateLimiterFactory is a constructor called by Init after the workload identity and TLS config are established. Unlike other factory types in Ligand, RateLimiterFactory returns an error because rate limiter providers verify connectivity at construction time — the Valkey provider issues a PING and returns an error if Valkey is unreachable. Other factory types (BindingFactory, BundleFetcherFactory, HealthSignalExposerFactory, OutboundTransportFactory) perform no network I/O in their constructors; blocking operations are deferred to Serve/Fetch/WatchBundles. Init fails if the factory returns an error, satisfying the fail-closed guarantee.

The RateLimiterArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Providers expose both a Factory function (for use in Config) and a New constructor (for tests and advanced use).

func NoopRateLimiterFactory

func NoopRateLimiterFactory() RateLimiterFactory

NoopRateLimiterFactory returns a RateLimiterFactory that creates a NoopRateLimiter. It is used as the default when [Config.RateLimiter] is nil.

type RecordCircuitOutcomeRequest

type RecordCircuitOutcomeRequest struct {
	// SPIFFEID is the SPIFFE ID of the destination service.
	SPIFFEID string

	// Outcome is the result of the outbound call.
	Outcome CallOutcome
}

RecordCircuitOutcomeRequest is the argument to [CircuitBreaker.RecordOutcome].

type RecordLoadBalancerOutcomeRequest

type RecordLoadBalancerOutcomeRequest struct {
	// Endpoint is the endpoint that was called.
	Endpoint *Endpoint

	// Success is true when the call completed without a connection-level error
	// and the response status was below 5xx.
	Success bool
}

RecordLoadBalancerOutcomeRequest is the argument to [LoadBalancer.RecordOutcome].

type RecordOutlierOutcomeRequest

type RecordOutlierOutcomeRequest struct {
	// Endpoint is the endpoint that was called.
	Endpoint *Endpoint

	// Outcome is the result of the outbound call.
	Outcome CallOutcome
}

RecordOutlierOutcomeRequest is the argument to [OutlierDetector.RecordOutcome].

type RecordRetryRequest

type RecordRetryRequest struct {
	// SPIFFEID is the SPIFFE ID of the service being retried.
	SPIFFEID string
}

RecordRetryRequest is the argument to [RetryBudget.RecordRetry].

type RegisterRequest

type RegisterRequest struct {
	// SPIFFEID is the SPIFFE ID of the workload registering itself. Used as
	// the registry key so callers can discover endpoints by SPIFFE ID.
	// The framework derives this from the SVID; callers must not set it
	// directly.
	SPIFFEID string

	// Endpoint is the endpoint data to publish. The framework populates
	// Endpoint.SPIFFEID from the current SVID before calling Register.
	Endpoint Endpoint
}

RegisterRequest is the argument to [ServiceRegistrar.Register].

type ReleaseRequest

type ReleaseRequest struct {
	// Endpoint is the endpoint that was called.
	Endpoint *Endpoint

	// Outcome is the result of the outbound call.
	Outcome CallOutcome
}

ReleaseRequest is the argument to [PoolSelector.Release].

type ResolveRequest

type ResolveRequest struct {
	// SPIFFEID is the SPIFFE ID of the service to resolve.
	SPIFFEID string
}

ResolveRequest is the argument to [ServiceRegistry.Resolve].

type ResolveResponse

type ResolveResponse struct {
	// Endpoints is the current set of healthy endpoints for the requested
	// service. May be empty if no healthy endpoints are registered.
	Endpoints []Endpoint
}

ResolveResponse is returned by [ServiceRegistry.Resolve].

type Resource

type Resource struct {
	// Kind scopes the Value to a namespace, e.g. "http.path",
	// "grpc.method", or "resource.arn".
	Kind string

	// Value is the resource identifier within its Kind, e.g.
	// "/api/v1/users", "/package.Service/Method".
	Value string
}

Resource identifies the target of a request for policy evaluation. Using both Kind and Value allows the policy engine to handle multiple resource namespaces (HTTP paths, gRPC methods, custom resource types) without conflating them.

type RetryAllowRequest

type RetryAllowRequest struct {
	// SPIFFEID is the SPIFFE ID of the service being retried.
	SPIFFEID string
}

RetryAllowRequest is the argument to [RetryBudget.Allow].

type RetryBudget

type RetryBudget interface {
	// Allow reports whether a retry attempt is permitted for req.SPIFFEID.
	// Returns false when the retry/request ratio has exceeded the configured
	// cap. The caller must call [RetryBudget.RecordRetry] before attempting
	// the retry if Allow returns true.
	Allow(ctx context.Context, req *RetryAllowRequest) bool

	// RecordRetry records that a retry was consumed for req.SPIFFEID. Called
	// only after [RetryBudget.Allow] returns true.
	RecordRetry(ctx context.Context, req *RecordRetryRequest)

	io.Closer
}

RetryBudget gates retry attempts with a sliding-window budget, preventing a single caller from multiplying load on an already-degraded downstream through unbounded retries.

Configured in [Config.RetryBudget]. Nil means no budget cap.

The inprocess implementation tracks (retryCount, requestCount) per service in a sliding time window and rejects retries once retryCount/requestCount exceeds the configured ratio, with a floor of at least one retry regardless of ratio. A Valkey-backed implementation (T7.7) shares counters across all instances so the budget applies to aggregate traffic, preventing the N-instance amplification that per-instance budgets cannot address.

type RetryCondition

type RetryCondition int

RetryCondition names a condition under which a failed outbound call is eligible for retry.

const (
	// RetryOnConnectionError retries when the call failed at the TCP/TLS level
	// or timed out. Safe for all HTTP methods.
	RetryOnConnectionError RetryCondition = iota

	// RetryOnServerError retries when the endpoint returned a 5xx response.
	// Only applied to idempotent methods (GET, HEAD, PUT, OPTIONS) unless the
	// caller has explicitly enabled it for non-idempotent methods.
	RetryOnServerError
)

type RetryConfig

type RetryConfig struct {
	// MaxAttempts is the maximum number of additional attempts after the first
	// failure. 0 means no retry (one attempt total).
	MaxAttempts int

	// RetryOn lists the conditions under which a failed call is retried.
	// Empty means no retry regardless of MaxAttempts.
	RetryOn []RetryCondition

	// BackoffBase is the initial backoff duration before the second attempt.
	// Subsequent backoffs double up to [RetryConfig.BackoffMax].
	// Zero means no backoff (immediate retry).
	BackoffBase time.Duration

	// BackoffMax caps the exponential backoff. Zero means no cap (unbounded
	// doubling, which is rarely desirable).
	BackoffMax time.Duration

	// BackoffJitter is the fraction of the computed backoff to randomise,
	// expressed as a value in [0, 1]. For example, 0.25 adds or subtracts up
	// to 25% of the backoff duration. Zero means no jitter.
	BackoffJitter float64

	// PerCallTimeout is the per-attempt deadline. Zero means use the caller's
	// context deadline only. Non-zero implements the two-timeout model: a hung
	// first attempt is cancelled after PerCallTimeout so a retry can still
	// reach a healthy endpoint before the overall deadline expires.
	PerCallTimeout time.Duration
}

RetryConfig controls retry behaviour for outbound calls to a single logical service. Returned by [RetryPolicyProvider.RetryPolicy].

A zero-value RetryConfig means no retry: [RetryConfig.MaxAttempts] is 0.

type RetryPolicyProvider

type RetryPolicyProvider interface {
	// RetryPolicy returns the retry configuration for req.SPIFFEID.
	// The bool reports whether an entry exists; false means no retry.
	RetryPolicy(ctx context.Context, req *RetryPolicyRequest) (RetryConfig, bool)

	io.Closer
}

RetryPolicyProvider looks up the retry configuration for a named service. Configured as [Config.RetryPolicy] and [OutboundTransportArgs.RetryPolicy]. Nil means no retry for any service.

The static implementation wraps a fixed map; a dynamic implementation may reload policies from etcd or another config store without restarting the service. Both satisfy this interface.

type RetryPolicyRequest

type RetryPolicyRequest struct {
	// SPIFFEID is the SPIFFE ID of the destination service.
	SPIFFEID string
}

RetryPolicyRequest is the argument to [RetryPolicyProvider.RetryPolicy].

type RevocationEvent

type RevocationEvent struct{}

RevocationEvent carries information about an actively revoked credential.

type RevocationUpdate

type RevocationUpdate struct {
	Event *RevocationEvent
	Err   error
}

RevocationUpdate is delivered on the EventStream returned by [PolicyEngine.WatchRevocations]. Err is non-nil if the watch encountered an error; in that case Event is nil.

type SVIDSnapshot

type SVIDSnapshot struct {
	PrivateKey   crypto.PrivateKey
	SPIFFEID     string
	Certificates []*x509.Certificate // leaf-first, no root CA; matches x5c header format
	TrustBundles map[string][]*x509.Certificate
}

SVIDSnapshot is an immutable view of the workload's current SVID material captured from a single atomic tlsState load. Providers that need multiple fields (key + identity, certificates + trust bundle) must call GetSnapshot once per operation and read all fields from the returned value — never call GetSnapshot more than once per operation, as a rotation between calls would produce mismatched fields.

type SecretRevocationUpdate

type SecretRevocationUpdate struct {
	// LeaseID is the opaque identifier of the revoked lease, matching the
	// leaseID returned in the original [GetSecretResponse]. The framework uses
	// this to identify and evict the affected cache entry.
	LeaseID string

	// Err is non-nil when the revocation watch itself encountered an error.
	// The framework logs the error and continues watching; the stream is not
	// closed on transient errors.
	Err error
}

SecretRevocationUpdate is delivered on the EventStream returned by [Secrets.WatchRevocations]. Err is non-nil if the watch encountered an error; in that case LeaseID is empty.

type Secrets

type Secrets interface {
	// Get retrieves the named secret from the backend. name is the full path
	// as understood by the backend (e.g. "secret/data/myapp/db-password" for
	// a Vault/OpenBao KV v2 mount). The framework caches the returned value
	// for the duration of TTL and calls Get again proactively before expiry
	// so callers rarely block on a cold fetch.
	//
	// Get must be safe for concurrent use. The framework may call it from
	// multiple goroutines simultaneously for different names.
	Get(ctx context.Context, req *GetSecretRequest) (*GetSecretResponse, error)

	// WatchRevocations returns an [EventStream] that delivers a
	// [SecretRevocationUpdate] each time the backend reports that a
	// previously-issued credential has been actively revoked. The channel is
	// closed when ctx is cancelled. Every event must be processed; use range
	// to consume.
	//
	// Optional: embed [UnimplementedSecrets] if not supported. The
	// framework falls back to TTL-based expiry when the stream is closed
	// immediately.
	WatchRevocations(ctx context.Context) EventStream[SecretRevocationUpdate]

	io.Closer
}

Secrets retrieves secrets (API keys, database credentials, TLS certificates) from a backing secrets store such as OpenBao. The framework caches each secret until its TTL expires and renews transparently before expiry. Application code accesses secrets via Server.GetSecret and never holds a reference to the backend client.

Required methods: [Secrets.Get] and io.Closer.

Optional methods: [Secrets.WatchRevocations]. Providers that do not support push revocation should embed UnimplementedSecrets, which returns a closed EventStream. The framework falls back to TTL-based expiry on the closed stream.

Failure mode: while the backend is unreachable the framework serves cached values until each entry's TTL expires naturally; once an entry expires and the backend is still unreachable, Server.GetSecret returns an error. Grace window: each secret's individual TTL. Recovery: automatic — the next [Secrets.Get] call after the backend recovers succeeds and repopulates the cache. Health transitions: KindSecrets moves to Unavailable when [Secrets.Get] fails and back to Ready when a subsequent call succeeds. A revocation event moves KindSecrets to Degraded while the affected entry is evicted and re-fetched; it returns to Ready once re-fetch succeeds.

type SecretsArgs

type SecretsArgs struct {
	// OutboundTLSConfig is a *tls.Config for outbound mTLS connections from
	// this workload to the secrets backend. It presents the current X.509
	// SVID via GetClientCertificate (updated atomically on each SVID rotation)
	// and verifies the server certificate against the SPIFFE trust bundle.
	// Nil when the framework has no outbound TLS configured.
	OutboundTLSConfig *tls.Config
}

SecretsArgs holds the ligand-internal values that SecretsFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type SecretsFactory

type SecretsFactory func(*SecretsArgs) (Secrets, error)

SecretsFactory is a constructor called by Init after the workload identity and TLS config are established. Init fails if the factory returns an error, satisfying the fail-closed guarantee.

The SecretsArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Providers expose both a Factory function (for use in Config) and a New constructor (for tests and advanced use).

type SelectRequest

type SelectRequest struct {
	// Candidates is the set of available endpoints to choose from. Must not
	// be modified by the implementation.
	Candidates []Endpoint
}

SelectRequest is the argument to [LoadBalancer.Select].

type SelectResponse

type SelectResponse struct {
	// Endpoint is the chosen endpoint.
	Endpoint Endpoint
}

SelectResponse is returned by [LoadBalancer.Select].

type Server

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

Server is an initialized Ligand framework instance. Call [Server.Enforce] to run an inbound request through the enforcement pipeline from a Binding, or Server.EnforceInbound from a trust-boundary service's own HTTP handlers. Obtain a Server by calling Init or InitTrustBoundary. Close the Server when the process is shutting down.

func Init

func Init(ctx context.Context, cfg *Config) (*Server, error)

Init initializes the Ligand framework. It must be called once at process startup before any listener is started. Init validates all required providers, establishes workload identity, and configures the TLS stack.

Init is fail-closed: if any required provider is nil, or if establishing initial identity or loading the initial policy bundle fails, Init returns an error. The caller must not proceed to accept connections on error.

Example

ExampleInit shows the minimal Config required to initialize Ligand for a service that accepts mTLS traffic from peer services. Identity, Policy, and Binding are required; all other providers are optional and default to safe no-op behaviour. Init is fail-closed: any error means the process must not start.

In production, Identity is a SPIFFE/SPIRE provider, Policy is an OPA engine, and Binding is a real HTTP or gRPC binding. This example uses testkit fakes for brevity; see examples/bank/ for a fully wired multi-service deployment.

package main

import (
	"context"
	"fmt"

	"codeberg.org/arichard/ligand"
	"codeberg.org/arichard/ligand/testkit"
)

func main() {
	identity, err := testkit.NewFakeWorkloadIdentity("spiffe://example.org/service/accounts")
	if err != nil {
		fmt.Println("identity setup failed:", err)
		return
	}

	cfg := &ligand.Config{
		Identity: identity,
		Policy:   testkit.NewFakePolicyEngine(),
		Binding:  testkit.FakeBindingFactory(),
	}

	srv, err := ligand.Init(context.Background(), cfg)
	if err != nil {
		fmt.Println("init failed:", err)
		return
	}
	defer func() {
		if err := srv.Close(); err != nil {
			fmt.Println("close error:", err)
		}
	}()

	fmt.Println("running as", srv.SPIFFEID())
}
Output:
running as spiffe://example.org/service/accounts

func InitTrustBoundary

func InitTrustBoundary(ctx context.Context, cfg *Config) (*Server, error)

InitTrustBoundary initialises a Server for services that accept external traffic (browsers, partner APIs, etc.) and manage their own inbound port rather than using a Ligand-managed mTLS Binding.

All providers initialise identically to Init. [Config.Binding] may be nil when the service has no Ligand-managed inbound port (batch jobs, CLI tools, event consumers), or non-nil when the service also accepts mTLS calls from other Ligand services. The returned Server exposes Server.EnforceInbound for use in the service's own HTTP handlers.

InitTrustBoundary is fail-closed: Identity and Policy are still required.

Example

ExampleInitTrustBoundary shows how a trust-boundary service initializes Ligand. A trust boundary accepts external traffic (browsers, partner APIs) rather than peer mTLS, so Config.Binding may be nil — the service uses its own HTTP handlers and calls Server.EnforceInbound directly per request.

Pre-authentication requests use Peer: Link{ExternalParty: "..."}; post- authentication requests use Peer: Link{User: accountID}. The returned context carries the verified chain and must be threaded through any subsequent outbound call so chain propagation works correctly.

package main

import (
	"context"
	"fmt"

	"codeberg.org/arichard/ligand"
	"codeberg.org/arichard/ligand/testkit"
)

func main() {
	identity, err := testkit.NewFakeWorkloadIdentity("spiffe://example.org/service/gateway")
	if err != nil {
		fmt.Println("identity setup failed:", err)
		return
	}

	cfg := &ligand.Config{
		Identity: identity,
		Policy:   testkit.NewFakePolicyEngine(),
		// Binding intentionally nil — this service has no Ligand-managed
		// inbound port. It accepts external HTTP and calls EnforceInbound
		// from its own handlers.
	}

	srv, err := ligand.InitTrustBoundary(context.Background(), cfg)
	if err != nil {
		fmt.Println("init failed:", err)
		return
	}
	defer func() {
		if err := srv.Close(); err != nil {
			fmt.Println("close error:", err)
		}
	}()

	fmt.Println("trust boundary running as", srv.SPIFFEID())
}
Output:
trust boundary running as spiffe://example.org/service/gateway

func (*Server) Close

func (s *Server) Close() error

Close shuts down the framework and releases all resources held by the providers registered with Init.

Shutdown order:

  1. The drain goroutine (if any) is cancelled so it cannot call Close again.
  2. KindBinding transitions to Draining; all other configured providers transition to Unavailable. Health checks see Draining so load balancers stop routing new traffic while in-flight requests complete.
  3. [Config.PreStopHooks] run sequentially within [Config.PreStopTimeout]. Errors are logged at warn but never abort shutdown.
  4. [ServiceRegistrar.ReportHealth] marks this instance unhealthy (5 s deadline) so callers stop routing new traffic here before draining.
  5. [ServiceRegistrar.Deregister] removes the endpoint from the registry entirely (10 s deadline).
  6. [Binding.Shutdown] drains in-flight inbound requests (30 s deadline). The SVID rotation loop intentionally remains running during drain so that live requests still see a valid certificate.
  7. [OutboundTransport.Shutdown] drains in-flight outbound calls (same deadline). The rotation loop remains running here too.
  8. The SVID rotation goroutine and bundle watch goroutine are cancelled now that no live requests or outbound calls remain.
  9. [Binding.Close] releases the listener and any remaining connections. KindBinding transitions from Draining to Unavailable.
  10. Providers close in reverse dependency order: bundle fetcher, policy, identity, rate limiter, secrets, context service client, outbound transport, service registry, service registrar, drainer.
  11. Health and telemetry providers close last so they can observe the full shutdown sequence.

Close is idempotent; subsequent calls return nil.

func (*Server) EnforceInbound

func (s *Server) EnforceInbound(ctx context.Context, req *InboundRequest) (context.Context, error)

EnforceInbound runs req through the enforcement pipeline for trust-boundary services initialised with InitTrustBoundary. It is the entry point for services that accept external traffic and manage their own inbound port rather than using a Ligand-managed mTLS Binding.

The pipeline is identical to [Enforce] — rate limiting then policy evaluation, fail-closed on errors. evalChain is req.Chain + req.Peer, matching the invariant established by [Enforce]: the context always carries the full principal chain including the immediate caller. The caller sets [InboundRequest.Chain] with upstream hops (empty for a first-hop request) and [InboundRequest.Peer] with the immediate trust-boundary caller's identity (e.g. Link{User: accountID} after authentication, or Link{ExternalParty: "browser/unauthenticated"} before it).

On success EnforceInbound returns a new context with the chain stored via WithInboundChain. Callers must use the returned context for all subsequent work including outbound calls — discarding it silently breaks chain propagation to downstream services.

SubjectToken and TransactionToken are not applicable at a trust-boundary entry point — the service is responsible for authenticating callers before building the chain. Both fields are zeroed on a local copy of req so that the shared pipeline in [Enforce] skips the token exchange and verify steps.

Example

ExampleServer_EnforceInbound shows how a trust-boundary handler calls EnforceInbound to run an external request through rate limiting and policy evaluation. The returned context carries the verified chain. Discarding it silently breaks chain propagation to downstream services — always use the returned context for subsequent outbound work.

package main

import (
	"context"
	"fmt"

	"codeberg.org/arichard/ligand"
	"codeberg.org/arichard/ligand/testkit"
)

func main() {
	identity, err := testkit.NewFakeWorkloadIdentity("spiffe://example.org/service/gateway")
	if err != nil {
		fmt.Println("identity setup failed:", err)
		return
	}

	srv, err := ligand.InitTrustBoundary(context.Background(), &ligand.Config{
		Identity: identity,
		Policy:   testkit.NewFakePolicyEngine(),
	})
	if err != nil {
		fmt.Println("init failed:", err)
		return
	}
	defer srv.Close() //nolint:errcheck // example brevity

	// Inside an external-facing HTTP handler:
	req := &ligand.InboundRequest{
		Peer:     ligand.Link{ExternalParty: "browser/unauthenticated"},
		Resource: ligand.Resource{Kind: "http.path", Value: "/login"},
		Action:   ligand.Action{Name: "GET"},
	}

	ctx, err := srv.EnforceInbound(context.Background(), req)
	if err != nil {
		fmt.Println("denied:", err)
		return
	}
	_ = ctx // pass to downstream calls for chain propagation
	fmt.Println("allowed")
}
Output:
allowed

func (*Server) GetSecret

func (s *Server) GetSecret(ctx context.Context, name string) ([]byte, error)

GetSecret returns the current value of the named secret. name is the full backend path (e.g. "secret/data/myapp/db-password"). The framework serves the value from its cache when a valid non-expired entry exists, and fetches from the backend otherwise.

Returns ErrNotSupported when no Secrets is configured. Returns an error when the cache entry has expired and the backend is unreachable.

func (*Server) Monitor

func (s *Server) Monitor() *Monitor

Monitor returns the health monitor for this server. Callers can use it to observe provider state for health endpoints, degradation logic, and tests.

func (*Server) SPIFFEID

func (s *Server) SPIFFEID() string

SPIFFEID returns the SPIFFE ID of this workload's current X.509 SVID. The value is updated atomically each time the SVID rotates, so callers always see the identity that is currently in use for mTLS connections.

Applications should use this when constructing outbound chain links or logging the service's own identity.

type ServiceRegistrar

type ServiceRegistrar interface {
	// Register writes req.Endpoint to the registry backend as a healthy
	// endpoint. Idempotent: a second call replaces the prior registration.
	Register(ctx context.Context, req *RegisterRequest) error

	// ReportHealth marks the endpoint as healthy or unhealthy. When Healthy
	// is false the endpoint is removed from [ServiceRegistry.Resolve] results
	// but the backend lease is kept alive, enabling fast recovery when Healthy
	// is set back to true without a full Register call. The framework calls
	// ReportHealth with Healthy false during graceful shutdown before Deregister.
	ReportHealth(ctx context.Context, req *HealthReport) error

	// Deregister removes the endpoint from the registry backend and releases
	// any associated backend resources (e.g. a lease). Called once during
	// graceful shutdown after ReportHealth.
	Deregister(ctx context.Context, req *DeregisterRequest) error

	io.Closer
}

ServiceRegistrar registers and deregisters a service endpoint in the registry backend. It is separate from ServiceRegistry because registration is a write operation that not all backends support. Backends that support both implement both interfaces in the same concrete type.

The framework calls [ServiceRegistrar.Register] once at startup after the SVID is available, and [ServiceRegistrar.ReportHealth] followed by [ServiceRegistrar.Deregister] during graceful shutdown. Application code must not call these methods directly.

Implementations must keep the registration alive for the process lifetime. For backends that use TTL leases the implementation is responsible for renewing the lease in the background and signalling the framework via [ServiceRegistrarArgs.OnLeaseExpiry] if renewal fails unrecoverably after exhausting the configured recovery grace period.

type ServiceRegistrarArgs

type ServiceRegistrarArgs struct {
	// OutboundTLSConfig is the mTLS config for connections to the registry
	// backend. Updated atomically on SVID rotation. Nil when no outbound TLS
	// is configured.
	OutboundTLSConfig *tls.Config

	// OnLeaseExpiry is called by the registrar if the registration lease
	// expires AND all internal recovery attempts within the configured grace
	// period fail. The framework binds this to a [KindServiceRegistrar]
	// monitor transition to [Unavailable]. Nil-safe; must not block. Not
	// called for transient expiries that self-heal within the grace period.
	OnLeaseExpiry func(err error)
}

ServiceRegistrarArgs holds the ligand-internal values that ServiceRegistrarFactory implementations receive from Init.

type ServiceRegistrarFactory

type ServiceRegistrarFactory func(*ServiceRegistrarArgs) (ServiceRegistrar, error)

ServiceRegistrarFactory is a constructor called by Init after workload identity and TLS config are established. It returns a ServiceRegistrar that the framework uses for the lifetime of the Server.

Init fails if the factory returns an error, satisfying the fail-closed guarantee.

type ServiceRegistration

type ServiceRegistration struct {
	// Address is the host:port that remote callers should connect to.
	// Typically the address the [Binding] is listening on.
	Address string

	// Locality optionally describes the failure domain of this instance.
	// Zero value means no locality preference.
	Locality Locality

	// Metadata is arbitrary key-value data published alongside the endpoint.
	// May be nil.
	Metadata map[string]string
}

ServiceRegistration describes the endpoint this service instance publishes to the registry at startup. Set once in Config; does not change for the lifetime of the Server. The framework derives the registry key from the workload SVID and populates [Endpoint.SPIFFEID] from the current SVID before calling [ServiceRegistrar.Register].

type ServiceRegistry

type ServiceRegistry interface {
	// Resolve returns the current set of healthy endpoints for the workload
	// with the given SPIFFE ID. Returns an empty slice (not an error) when
	// no healthy endpoints are registered.
	Resolve(ctx context.Context, req *ResolveRequest) (*ResolveResponse, error)

	// WatchEndpoints returns a channel that delivers an [EndpointUpdate]
	// whenever the endpoint set for the given SPIFFE ID changes. The channel
	// follows the [LatestRecv]/[LatestSend] pattern: only the most recent
	// update matters and intermediate values may be discarded. The channel is
	// closed when ctx is cancelled or the registry shuts down.
	WatchEndpoints(ctx context.Context, req *WatchEndpointsRequest) LatestRecv[EndpointUpdate]

	io.Closer
}

ServiceRegistry resolves SPIFFE IDs to sets of healthy endpoints. The framework uses it to populate the load balancer with candidate endpoints for each outbound call.

Security note: the registry is an address directory, not an identity authority. The outbound transport holds the expected SPIFFE ID independently (via [OutboundTransport.Transport]) and verifies it against both the registry record and the peer TLS certificate. Write access to the registry backend (e.g. etcd) causes connection failures (wrong address), not silent traffic redirection through a forged identity. Operators must still treat the registry backend as security-critical and restrict write access accordingly.

type ServiceRegistryArgs

type ServiceRegistryArgs struct {
	// OutboundTLSConfig is a *tls.Config for outbound mTLS connections from
	// this workload to the service registry backend. It presents the current
	// X.509 SVID via GetClientCertificate (updated atomically on each SVID
	// rotation) and verifies the server certificate against the SPIFFE trust
	// bundle. Nil when the framework has no outbound TLS configured.
	OutboundTLSConfig *tls.Config
}

ServiceRegistryArgs holds the ligand-internal values that ServiceRegistryFactory implementations receive from Init. Only values that are contractually guaranteed to be present when Init calls the factory are included; adding a field to this struct is a considered API decision because it constrains the construction order inside Init.

type ServiceRegistryFactory

type ServiceRegistryFactory func(*ServiceRegistryArgs) (ServiceRegistry, error)

ServiceRegistryFactory is a constructor called by Init after workload identity and TLS config are established. It returns a ServiceRegistry that the framework uses for the lifetime of the Server.

The ServiceRegistryArgs carries exactly the ligand-internal values the factory needs; no partially-initialized Server is exposed.

Providers expose both a Factory function (for use in Config) and a New constructor (for tests and advanced use).

type SessionStore

type SessionStore interface {
	io.Closer
}

SessionStore persists and retrieves session state across requests. It is used by the framework when the application requires session continuity beyond the lifetime of a single mTLS connection.

type State

type State uint8

State describes the health of a provider or the overall service.

const (
	// Initializing indicates the component has not yet completed startup.
	// The service denies all requests while any required provider is in
	// this state.
	Initializing State = iota

	// Ready indicates the component is operating normally.
	Ready

	// Degraded indicates one or more providers are operating with reduced
	// capability — either an optional provider is unavailable, or a required
	// provider is within its configured grace period after a transient error.
	// The service continues accepting traffic.
	Degraded

	// Draining indicates the service has received a shutdown signal and is
	// completing in-flight requests before closing. New requests are rejected.
	// This is a transitional state between Ready and Unavailable during
	// graceful shutdown.
	Draining

	// Unavailable indicates the component has failed or lost its backing
	// resource. The service denies all requests while any required provider
	// is in this state.
	Unavailable
)

func (State) String

func (s State) String() string

String returns a human-readable name for the state.

type Telemetry

type Telemetry interface {
	// RecordDecision records an enforcement decision for every request that
	// reaches policy evaluation, whether allowed or denied. It forms the
	// tamper-evident decision audit log required by Section 7.
	//
	// RecordDecision must not block the enforcement pipeline. Implementations
	// that perform I/O should buffer writes and return promptly.
	RecordDecision(ctx context.Context, event *DecisionEvent) error

	// RecordRateLimit records the outcome of every rate limit check, whether
	// the request was allowed or denied. Consumers that only want rejection
	// events may filter on [RateLimitEvent.Allowed]. The event carries
	// utilisation fields ([RateLimitEvent.CurrentRPS], [RateLimitEvent.LimitRPS])
	// when the implementation reports them; both are zero otherwise.
	//
	// RecordRateLimit must not block the enforcement pipeline.
	RecordRateLimit(ctx context.Context, event *RateLimitEvent) error

	// RecordOutboundCall records the outcome of every outbound service call
	// made via the [OutboundTransport]. Aggregated fleet-wide, these records
	// form the service call graph required by Section 7 (Structured Telemetry).
	//
	// RecordOutboundCall must not block the outbound call path.
	RecordOutboundCall(ctx context.Context, event *OutboundCallEvent) error

	// RecordHealthTransition is called whenever a provider's health state
	// changes. It is optional: embed [UnimplementedTelemetry] to
	// receive a stub that returns [ErrNotSupported].
	RecordHealthTransition(ctx context.Context, event *HealthTransitionEvent) error

	// RecordEjection is called whenever an endpoint is ejected from or
	// reinstated into the load-balancing pool by the [OutlierDetector].
	// It is optional: embed [UnimplementedTelemetry] to receive a
	// stub that returns [ErrNotSupported].
	RecordEjection(ctx context.Context, event *EjectionEvent) error

	// RecordCircuitState is called whenever a [CircuitBreaker] transitions
	// between states (Closed ↔ Open ↔ HalfOpen).
	// It is optional: embed [UnimplementedTelemetry] to receive a
	// stub that returns [ErrNotSupported].
	RecordCircuitState(ctx context.Context, event *CircuitStateEvent) error

	// RecordBundleFetch is called after each bundle fetch attempt — both the
	// initial fetch during [Init] and each update delivered by the watch loop.
	// It is optional: embed [UnimplementedTelemetry] to receive a
	// stub that returns [ErrNotSupported].
	RecordBundleFetch(ctx context.Context, event *BundleFetchEvent) error

	// RecordMetrics is called periodically by the framework at
	// [Config.MetricsInterval] to deliver a snapshot of aggregated framework
	// health metrics. It is optional: embed [UnimplementedTelemetry] to
	// receive a stub that returns [ErrNotSupported], which causes the framework
	// to stop the metrics loop for this provider.
	//
	// Implementations must not block; the framework calls RecordMetrics from
	// its own background goroutine.
	RecordMetrics(ctx context.Context, snapshot *MetricsSnapshot) error

	io.Closer
}

Telemetry receives structured events emitted by the framework. Implementations may write to stdout, export to OpenTelemetry, or forward to any observability backend.

Required methods: [Telemetry.RecordDecision], [Telemetry.RecordRateLimit], [Telemetry.RecordOutboundCall], and io.Closer.

Optional methods: [Telemetry.RecordHealthTransition], [Telemetry.RecordEjection], [Telemetry.RecordCircuitState], [Telemetry.RecordBundleFetch], [Telemetry.RecordMetrics]. Providers that do not need these events should embed UnimplementedTelemetry, which returns ErrNotSupported.

type TokenExchangeRequest

type TokenExchangeRequest struct {
	// SubjectToken is the incoming credential to exchange.
	SubjectToken string

	// SubjectTokenType is the token type URI per RFC 8693
	// (e.g. "urn:ietf:params:oauth:token-type:access_token").
	SubjectTokenType string

	// Resource is the downstream service URI the resulting Transaction Token
	// will be presented to, per RFC 8693 §2.1. Intended to scope the token's
	// aud claim per draft-ietf-oauth-transaction-tokens. Currently passed
	// through by the framework but not yet consumed by the inline provider,
	// which omits aud from minted TTs pending resolution of the multi-hop
	// propagation design (see inline/inline.go TODO).
	Resource string
}

TokenExchangeRequest is the argument to [ContextServiceClient.Exchange].

type TransactionToken

type TransactionToken struct {
	// Token is the signed JWT string.
	Token string

	// ExpiresAt is the token's expiry time. Callers must not cache or reuse
	// tokens past this time.
	ExpiresAt time.Time
}

TransactionToken is a signed JWT issued by the Context Service or the inline provider. It carries verified caller identity and scope and is propagated across all subsequent internal hops per draft-ietf-oauth-transaction-tokens.

type TransactionTokenClaims

type TransactionTokenClaims struct {
	// CallerID is the identity of the original external caller (the IdP subject
	// claim from the exchanged access token).
	CallerID string

	// Scope is the OAuth scope granted to the original caller.
	Scope string

	// IssuedAt and ExpiresAt are the token's validity window.
	IssuedAt  time.Time
	ExpiresAt time.Time
}

TransactionTokenClaims carries the verified claims extracted from a Transaction Token by [ContextServiceClient.VerifyTransactionToken].

type TrustBoundaryConfig

type TrustBoundaryConfig struct {
	// Paths is the set of route prefixes that are external entry points.
	// The framework calls [ContextServiceClient.Exchange] only on requests
	// whose resource value has a matching prefix. Empty means no paths are
	// configured as trust-boundary entry points.
	Paths []string

	// IdPJWKSURL is the JWKS endpoint of the IdP whose tokens arrive at this
	// boundary. Required by the inline Context Service implementation for
	// subject token validation.
	IDPJWKSURL string
}

TrustBoundaryConfig declares the entry points at which the framework calls [ContextServiceClient.Exchange] to convert incoming OAuth access tokens into Transaction Tokens.

type UnimplementedBinding

type UnimplementedBinding struct{}

UnimplementedBinding is embedded in concrete Binding implementations to satisfy optional Binding methods with safe defaults.

func (UnimplementedBinding) Addr

Addr returns an empty string. Override in bindings that establish a real listener and need to expose their bound address to the service registry.

func (UnimplementedBinding) Listening

func (UnimplementedBinding) Listening() <-chan struct{}

Listening returns a pre-closed channel, causing the framework to mark KindBinding Ready synchronously at the end of Init. This is the correct default for test fakes and other bindings that do not establish a deferred TCP listener. Override this method in bindings (such as the HTTP provider) that only become ready after [Binding.Serve] completes its Listen call.

type UnimplementedBundleFetcher

type UnimplementedBundleFetcher struct{}

UnimplementedBundleFetcher is embedded in concrete BundleFetcher implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

Required methods ([BundleFetcher.Fetch], [BundleFetcher.WatchBundles], and io.Closer) are not stubbed — failing to implement them is a compile error.

type UnimplementedCircuitBreaker

type UnimplementedCircuitBreaker struct{}

UnimplementedCircuitBreaker is embedded in concrete CircuitBreaker implementations to provide no-op stubs for optional methods and forward compatibility when new optional methods are added.

func (UnimplementedCircuitBreaker) Allow

Allow always returns true, nil (all calls permitted).

func (UnimplementedCircuitBreaker) Close

Close is a no-op stub.

func (UnimplementedCircuitBreaker) RecordOutcome

RecordOutcome is a no-op stub.

func (UnimplementedCircuitBreaker) State

State always returns CircuitStateClosed.

type UnimplementedContextServiceClient

type UnimplementedContextServiceClient struct{}

UnimplementedContextServiceClient is embedded in concrete ContextServiceClient implementations to provide stub implementations of optional methods. Required methods — [ContextServiceClient.Exchange], [ContextServiceClient.VerifyTransactionToken], and io.Closer — are never stubbed; failing to implement them remains a compile error.

There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedEndpointSelector

type UnimplementedEndpointSelector struct{}

UnimplementedEndpointSelector is embedded in concrete EndpointSelector implementations for forward compatibility when new optional methods are added.

func (UnimplementedEndpointSelector) Close

Close is a no-op stub.

type UnimplementedGracefulDrainer

type UnimplementedGracefulDrainer struct{}

UnimplementedGracefulDrainer is embedded in concrete GracefulDrainer implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedHealthChecker

type UnimplementedHealthChecker struct{}

UnimplementedHealthChecker is embedded in concrete HealthChecker implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedHealthSignalExposer

type UnimplementedHealthSignalExposer struct{}

UnimplementedHealthSignalExposer is embedded in concrete HealthSignalExposer implementations to remain forward-compatible when optional methods are added to HealthSignalExposer in the future.

Embedding is recommended. Providers that choose not to embed it accept responsibility for tracking new optional methods when they are added.

func (UnimplementedHealthSignalExposer) Addr

Addr returns "" because this implementation does not bind a network address.

func (UnimplementedHealthSignalExposer) Listening

func (UnimplementedHealthSignalExposer) Listening() <-chan struct{}

Listening returns a pre-closed channel, signalling that the address is immediately known (or that no address is exposed).

type UnimplementedLoadBalancer

type UnimplementedLoadBalancer struct{}

UnimplementedLoadBalancer is embedded in concrete LoadBalancer implementations to provide no-op stubs of notification-style optional methods. [LoadBalancer.Select] and io.Closer are required and never stubbed.

func (UnimplementedLoadBalancer) RecordOutcome

RecordOutcome is a no-op. Embed UnimplementedLoadBalancer in load balancer implementations that do not track per-endpoint call outcomes.

type UnimplementedOutboundTransport

type UnimplementedOutboundTransport struct{}

UnimplementedOutboundTransport is embedded in concrete OutboundTransport implementations for forward compatibility when new optional methods are added. [OutboundTransport.Shutdown] and io.Closer are required and never stubbed here — omitting them is a compile error.

type UnimplementedOutlierDetector

type UnimplementedOutlierDetector struct{}

UnimplementedOutlierDetector is embedded in concrete OutlierDetector implementations to provide no-op stubs for optional methods and forward compatibility when new optional methods are added.

func (UnimplementedOutlierDetector) Close

Close is a no-op stub.

func (UnimplementedOutlierDetector) EjectedEndpoints

func (UnimplementedOutlierDetector) EjectedEndpoints(_ context.Context) []Endpoint

EjectedEndpoints always returns nil (no endpoints ejected).

func (UnimplementedOutlierDetector) IsEjected

IsEjected always returns false (no endpoints ejected).

func (UnimplementedOutlierDetector) RecordOutcome

RecordOutcome is a no-op stub.

type UnimplementedPolicyEngine

type UnimplementedPolicyEngine struct{}

UnimplementedPolicyEngine is embedded in concrete PolicyEngine implementations to provide stub implementations of optional methods. Required methods ([PolicyEngine.Evaluate] and io.Closer) are not stubbed — failing to implement them remains a compile error.

Embedding this struct is recommended. Providers that choose not to embed it accept responsibility for tracking new optional methods when they are added to the interface.

func (UnimplementedPolicyEngine) Reload

Reload returns ErrNotSupported. The framework skips hot reload and relies on TTL-based bundle expiry instead. Note: configuring a BundleFetcher alongside a PolicyEngine that returns ErrNotSupported from Reload is a misconfiguration; Init will return an error in that case.

func (UnimplementedPolicyEngine) WatchRevocations

WatchRevocations returns a closed EventStream with no events. TTL-based credential expiry is always active and requires no sentinel to activate.

type UnimplementedPoolSelector

type UnimplementedPoolSelector struct{}

UnimplementedPoolSelector is embedded in concrete PoolSelector implementations for forward compatibility when new optional methods are added.

func (UnimplementedPoolSelector) Close

Close is a no-op stub.

func (UnimplementedPoolSelector) Release

Release is a no-op stub.

type UnimplementedRateLimiter

type UnimplementedRateLimiter struct{}

UnimplementedRateLimiter is embedded in concrete RateLimiter implementations to remain forward-compatible when optional methods are added to RateLimiter in the future. Currently RateLimiter has no optional methods. Embedding the struct now ensures that when optional methods are added, existing implementations continue to satisfy the interface without any code changes.

Providers that choose not to embed it accept responsibility for tracking new optional methods when they are added.

type UnimplementedRetryBudget

type UnimplementedRetryBudget struct{}

UnimplementedRetryBudget is embedded in concrete RetryBudget implementations for forward compatibility when new optional methods are added.

func (UnimplementedRetryBudget) Allow

Allow always returns true (retries permitted).

func (UnimplementedRetryBudget) Close

Close is a no-op stub.

func (UnimplementedRetryBudget) RecordRetry

RecordRetry is a no-op stub.

type UnimplementedRetryPolicyProvider

type UnimplementedRetryPolicyProvider struct{}

UnimplementedRetryPolicyProvider is embedded in concrete RetryPolicyProvider implementations for forward compatibility when new optional methods are added. Both current methods have safe no-op defaults, so this struct fully satisfies the interface and the compile-time check is present.

func (UnimplementedRetryPolicyProvider) Close

Close is a no-op.

func (UnimplementedRetryPolicyProvider) RetryPolicy

RetryPolicy returns a zero RetryConfig and false (no retry for any SPIFFE ID).

type UnimplementedSecrets

type UnimplementedSecrets struct{}

UnimplementedSecrets is embedded in concrete Secrets implementations to provide stub implementations of optional methods. Embedding this struct ensures forward compatibility when new optional methods are added.

Required methods ([Secrets.Get] and io.Closer) are not stubbed — failing to implement them is a compile error.

func (UnimplementedSecrets) WatchRevocations

WatchRevocations returns a closed EventStream carrying a single SecretRevocationUpdate with ErrNotSupported. The revocation loop treats this sentinel as "provider does not support push revocation" and stops watching without retrying, falling back to TTL-based expiry only.

type UnimplementedServiceRegistrar

type UnimplementedServiceRegistrar struct{}

UnimplementedServiceRegistrar is embedded in concrete ServiceRegistrar implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedServiceRegistry

type UnimplementedServiceRegistry struct{}

UnimplementedServiceRegistry is embedded in concrete ServiceRegistry implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedSessionStore

type UnimplementedSessionStore struct{}

UnimplementedSessionStore is embedded in concrete SessionStore implementations to provide stub implementations of future optional methods. There are no optional methods today; embedding this struct ensures forward compatibility when they are added.

type UnimplementedTelemetry

type UnimplementedTelemetry struct{}

UnimplementedTelemetry is embedded in concrete Telemetry implementations to provide stub implementations of optional methods. Required methods — [Telemetry.RecordDecision], [Telemetry.RecordRateLimit], and io.Closer — are never stubbed; failing to implement them remains a compile error.

Embedding this struct is recommended. Providers that choose not to embed it accept responsibility for tracking new optional methods when they are added.

func (UnimplementedTelemetry) RecordBundleFetch

func (UnimplementedTelemetry) RecordBundleFetch(_ context.Context, _ *BundleFetchEvent) error

RecordBundleFetch returns ErrNotSupported.

func (UnimplementedTelemetry) RecordCircuitState

func (UnimplementedTelemetry) RecordCircuitState(_ context.Context, _ *CircuitStateEvent) error

RecordCircuitState returns ErrNotSupported.

func (UnimplementedTelemetry) RecordEjection

RecordEjection returns ErrNotSupported.

func (UnimplementedTelemetry) RecordHealthTransition

func (UnimplementedTelemetry) RecordHealthTransition(_ context.Context, _ *HealthTransitionEvent) error

RecordHealthTransition returns ErrNotSupported. The framework logs health transitions through its structured logger when this method returns ErrNotSupported.

func (UnimplementedTelemetry) RecordMetrics

RecordMetrics returns ErrNotSupported. The framework stops the metrics loop for this provider when this method returns ErrNotSupported.

type UnimplementedWorkloadIdentity

type UnimplementedWorkloadIdentity struct{}

UnimplementedWorkloadIdentity is embedded in concrete WorkloadIdentity implementations to provide stub implementations of optional methods. Required methods ([WorkloadIdentity.FetchX509Context] and io.Closer) are not stubbed — failing to implement them remains a compile error.

Embedding this struct is recommended. Providers that choose not to embed it accept responsibility for tracking new optional methods when they are added to the interface.

func (UnimplementedWorkloadIdentity) FetchWIT

FetchWIT returns ErrNotSupported. WIT issuance requires the Identity Server to implement draft-ietf-wimse-workload-creds (tracked in SPIFFE issue #315). Until then, providers embed this stub and WIT/WPT headers are omitted from outbound requests; the inbound WPT path is fail-closed.

func (UnimplementedWorkloadIdentity) WatchX509Context

WatchX509Context returns a closed LatestRecv containing a single X509ContextUpdate with ErrNotSupported. The framework falls back to polling [WorkloadIdentity.FetchX509Context] for SVID rotation.

type WPTSigner

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

WPTSigner builds Workload Identity Token (WIT) and Workload Proof Token (WPT) JWTs for outbound WIMSE authentication. It reads the current SVID key and SPIFFE ID atomically on each call so SVID rotations propagate to new tokens without restart.

WIT issuance is delegated to the Identity Server via identity.FetchWIT per draft-ietf-wimse-workload-creds-00. The Identity Server signs the WIT and binds the workload's public key (cnf.jwk) to its identity; the workload retains the private key for WPT signing. If identity.FetchWIT returns ErrNotSupported, [WPTSigner.BuildWIT] returns that error and the caller should omit WIMSE headers rather than attaching unverifiable tokens.

WPT replay protection is per-instance: JTI values are cached in the inbound binding's in-process JTI cache, so the same WPT replayed against a different instance behind the same load balancer is not detected. This is an accepted tradeoff per draft-ietf-wimse-wpt-00 §5, which notes that preventing cross-instance replay requires shared replay state (e.g. a distributed cache). Do not rely solely on JTI uniqueness for replay protection in deployments with multiple instances of the same service.

WPT: draft-ietf-wimse-wpt-00

func NewWPTSigner

func NewWPTSigner(getSnapshot func() SVIDSnapshot, identity WorkloadIdentity, clock quartz.Clock) *WPTSigner

NewWPTSigner creates a WPTSigner. getSnapshot is called once per Build* invocation; it must read atomically from the Server's tlsState so rotations take effect without requiring a new WPTSigner. All fields used within a single Build* call are read from the same snapshot to avoid rotation skew. identity is used to issue WITs via [WorkloadIdentity.FetchWIT].

func (*WPTSigner) BuildWIMSETokens

func (s *WPTSigner) BuildWIMSETokens(ctx context.Context, audience, txnToken string) (wit, wpt string, err error)

BuildWIMSETokens issues a WIT and WPT for the given audience in a single atomic snapshot. The WIT is obtained from the Identity Server and binds the workload's current SVID public key as cnf.jwk; the WPT is signed with the same key so that recipients can verify the WPT against the WIT's cnf.jwk. txnToken is an optional Transaction Token to bind in the WPT via the oth claim; pass an empty string when there is no Transaction Token to forward.

Returns ErrWPTKeyNotECDSA if the current SVID key is not ECDSA. Returns ErrNotSupported if the Identity Server does not yet implement WIT issuance (SPIFFE issue #315); callers should omit WIMSE headers in that case.

func (*WPTSigner) BuildWPT

func (s *WPTSigner) BuildWPT(audience, wit, txnToken string, key *ecdsa.PrivateKey) (string, error)

BuildWPT creates a WPT JWT per draft-ietf-wimse-wpt-00 signed with an explicitly supplied key. Use WPTSigner.BuildWIMSETokens for normal outbound request signing — it issues the WIT and WPT atomically with the same key and is the correct call for all production paths.

BuildWPT exists solely for tests that construct WIT strings out-of-band (e.g. with crafted expiry timestamps) and need to sign a WPT against them. Do not use it in production code: callers that supply a key unrelated to the WIT's cnf.jwk will produce tokens that fail verification at the peer.

wit is the raw WIT JWT string. txnToken is an optional Transaction Token to bind via the oth claim; pass an empty string when there is none.

type WatchEndpointsRequest

type WatchEndpointsRequest struct {
	// SPIFFEID is the SPIFFE ID of the service to watch.
	SPIFFEID string
}

WatchEndpointsRequest is the argument to [ServiceRegistry.WatchEndpoints].

type WorkloadIdentity

type WorkloadIdentity interface {
	// FetchX509Context returns the current X.509-SVID and trust bundle for
	// this workload. The result is used to configure mTLS listeners and
	// dialers. Callers that need automatic rotation should use
	// WatchX509Context instead of polling FetchX509Context.
	FetchX509Context(ctx context.Context, req *FetchX509ContextRequest) (*FetchX509ContextResponse, error)

	// WatchX509Context returns a [LatestRecv] that delivers an
	// [X509ContextUpdate] each time the SVID rotates or the trust bundle
	// changes. The first update is delivered before any request is served.
	// The channel is closed when ctx is cancelled. Only the most recent
	// update matters; use [Latest] to consume.
	//
	// Optional: embed [UnimplementedWorkloadIdentity] if not
	// supported. The framework falls back to polling
	// [WorkloadIdentity.FetchX509Context] on [ErrNotSupported].
	WatchX509Context(ctx context.Context) LatestRecv[X509ContextUpdate]

	// FetchWIT requests a Workload Identity Token (WIT) per
	// draft-ietf-wimse-workload-creds from the Identity Server. The returned
	// JWT is signed by the Identity Server's JWT authority key (carried in
	// [FetchX509ContextResponse.JWTAuthorities]) and binds
	// req.CNFPublicKey to the workload's identity in the cnf.jwk claim.
	//
	// Optional: embed [UnimplementedWorkloadIdentity] to return
	// [ErrNotSupported]. When [ErrNotSupported] is returned, [WPTSigner]
	// will not attach WIT/WPT headers on outbound requests, and the inbound
	// WPT verification path is fail-closed (no JWT authorities → rejected).
	// Full support requires the Identity Server to implement SPIFFE issue #315.
	FetchWIT(ctx context.Context, req *FetchWITRequest) (*FetchWITResponse, error)

	io.Closer
}

WorkloadIdentity supplies the service's cryptographic workload identity and keeps it current through automatic rotation. The framework uses it to establish mTLS connections for all inbound and outbound traffic.

Required methods: [WorkloadIdentity.FetchX509Context] and io.Closer. These must be implemented; failing to do so is a compile error.

Optional methods: [WorkloadIdentity.WatchX509Context] and [WorkloadIdentity.FetchWIT]. Providers that do not support these should embed UnimplementedWorkloadIdentity.

type X509ContextUpdate

type X509ContextUpdate struct {
	Response *FetchX509ContextResponse
	Err      error
}

X509ContextUpdate is delivered on the LatestRecv returned by [WorkloadIdentity.WatchX509Context]. Err is non-nil if the watch encountered an error; in that case Response is nil.

type X509SVID

type X509SVID struct {
	// SPIFFEID is the SPIFFE ID encoded in this SVID, e.g.
	// "spiffe://trust-domain/service/name".
	SPIFFEID string

	// Certificates is the certificate chain for this SVID, leaf first.
	// The leaf carries the SPIFFE ID in its URI SAN; intermediates follow.
	Certificates []*x509.Certificate

	// PrivateKey is the private key corresponding to the leaf certificate.
	// It is held in memory only and never serialised by the framework.
	// Implementations must support at least ECDSA P-256; RSA and Ed25519
	// are also accepted.
	PrivateKey crypto.PrivateKey
}

X509SVID is a single X.509 SVID (SPIFFE Verifiable Identity Document) held by this workload.

Directories

Path Synopsis
analysis module
bundleserver module
provider
binding/http module
outbound/http module
Package testkit provides fake implementations of every Ligand interface for use in application unit tests.
Package testkit provides fake implementations of every Ligand interface for use in application unit tests.

Jump to

Keyboard shortcuts

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