outbox

package
v0.1.0-develop.2026060... Latest Latest
Warning

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

Go to latest
Published: Jun 8, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package outbox defines interfaces for the transactional outbox pattern: Writer (insert within a transaction), Emitter (write-or-direct abstraction), Relay (poll-and-publish), Publisher (fire-and-forget), and Subscriber (consume).

Scope: L2 (OutboxFact) — local transaction + outbox publish; and L3 (WorkflowEventual) — cross-cell eventual consistency via event fanout.

L4 (DeviceLatent) command queue semantics live in kernel/command/; outbox/ is scoped to L2 (OutboxFact) and L3 (WorkflowEventual) event fanout.

Implementations live in adapters/ (e.g., adapters/postgres, adapters/rabbitmq).

ref: ThreeDotsLabs/watermill message/ -- Message unified model, Publisher/Subscriber interfaces

Package outbox defines interfaces for the transactional outbox pattern. Implementations live in adapters/ (e.g., adapters/postgres).

ref: ThreeDotsLabs/watermill message/ -- Message unified model, Publisher/Subscriber interfaces

Index

Constants

View Source
const (
	// ConsumerRejectReasonHandlerReject indicates the business handler
	// explicitly returned DispositionReject (permanent failure / DLX).
	ConsumerRejectReasonHandlerReject = "handler_reject"

	// ConsumerRejectReasonRetryExhausted indicates the retry budget was
	// exhausted without the handler returning Ack or Reject. ConsumerBase
	// converts to DispositionReject so the broker routes to DLX.
	ConsumerRejectReasonRetryExhausted = "retry_exhausted"
)

ConsumerRejectReason* are the closed-set values passed to ConsumerObserver.ObserveReject. Adding a new reason requires touching every observer implementation (cross-PR conformance) — keep the set small and well-defined.

View Source
const EntryIDPrefix = "evt-"

EntryIDPrefix is the prefix for all outbox entry IDs — distinguishes entry IDs from other UUID-based IDs (audit entries, sessions, etc.) in logs.

View Source
const EnvelopeSchemaV1 = "v1"

EnvelopeSchemaV1 is the canonical schema version for outbox wire envelopes.

View Source
const MaxObservabilityTotalSize = 4 * idutil.MaxMetadataIDLen

MaxObservabilityTotalSize is a sizing-ceiling reference for downstream adapters that allocate buffers proportional to the worst-case total length of all observability fields combined. With four ID-shaped fields each capped at idutil.MaxMetadataIDLen (256B), the worst-case is 1024B; adapters/postgres uses 4× this value for the JSONB column allocation.

Validate() does NOT enforce this aggregate bound: TraceParent is constrained to a fixed 55B W3C string and the three ID fields are each per-field-capped at MaxMetadataIDLen, so the reachable maximum is 3×256 + 55 = 823 < 1024. Per-field limits already cover the worst case at write time; the aggregate cap is unreachable and intentionally not enforced.

View Source
const (
	// MaxPayloadBytes caps Entry.Payload length. A bug or malicious producer
	// that emits multi-MB JSON would otherwise inflate relay batch memory
	// (BatchSize × payload bytes) and PG TOAST / replication overhead. 1 MiB
	// matches Apache Kafka's default `message.max.bytes` and gives audit
	// snapshots / large business events comfortable headroom while keeping a
	// single relay batch under tens of MB at the default BatchSize=100.
	// ref: Apache Kafka message.max.bytes default 1 MiB.
	MaxPayloadBytes = 1 << 20
)
View Source
const MaxPrincipalTotalSize = 4 * idutil.MaxMetadataIDLen

MaxPrincipalTotalSize is a sizing-ceiling reference for downstream adapters that allocate buffers proportional to the worst-case total length of all principal fields combined. With four ID-shaped fields each capped at idutil.MaxMetadataIDLen (256B), the worst-case is 1024B; adapters/postgres uses a multiple of this value for the JSONB column scan cap. Mirror of MaxObservabilityTotalSize — the two families are sized identically (4 SafeID fields each). Validate() enforces per-field bounds, not this aggregate.

View Source
const ProcessReasonRetryExhausted = "retry_exhausted"

ProcessReasonRetryExhausted marks a DeliveryOutcome whose Reject was produced by ConsumerBase exhausting its retry budget (as opposed to a handler's explicit Reject). It is the sole value ConsumerBase injects into DeliveryOutcome.ProcessReason. Subscriber settle loops classify the final SettlementResult from it via RejectSettlementResult — see that helper.

View Source
const WarnDirectPublishFailOpen = "outbox: direct publish failed (fail-open) — event dropped"

WarnDirectPublishFailOpen is the slog.Warn message emitted when a DirectEmitter in DirectPublishFailOpen mode swallows a publisher failure. Tests assert on this constant to lock the observable signal — AI-robust Medium (single-source literal, typed reference). Counter equivalent: outbox_emit_failopen_dropped_total (registered in NewDirectEmitter).

Variables

View Source
var DefaultRelayBatchBuckets = []float64{1, 5, 10, 25, 50, 100, 200, 500}

DefaultRelayBatchBuckets are histogram buckets for batch size (1–500).

View Source
var DefaultRelayPollBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}

DefaultRelayPollBuckets are histogram buckets for poll phase duration (5ms–10s). Matches the range the original adapters/prometheus impl used before migration; preserved here so Grafana dashboards continue to look natural.

ErrDegraded is the canonical sentinel returned by DirectEmitter.Probes to signal "operational but degraded" — the emitter is still serving requests but the fail-open drop ratio has exceeded the configured threshold, meaning events are silently lost at an elevated rate.

The /readyz aggregator detects ErrDegraded via errors.Is and maps it to HTTP 200 + body status="degraded" rather than 503, so K8s readinessProbe does not evict the pod for soft-failure signals.

Callers should reach this sentinel directly via outbox.ErrDegraded. The kernel/cell alias was removed in PR #615 (G-10 kernel/cell decompose) so that the cell package no longer imports kernel/outbox in production code.

ref: envoyproxy/envoy admin /ready — DEGRADED returns 200, distinguishing "soft failure, do not evict" from "hard failure, drain traffic".

View Source
var ErrObserverAlreadyAttached = errcode.New(errcode.KindInvalid, errcode.ErrValidationFailed,
	"outbox: ConsumerObserver already attached; AttachObserver may only be called once per ConsumerBase")

ErrObserverAlreadyAttached is returned by ConsumerBase.AttachObserver when an observer has already been attached. Re-attaching would silently swap the in-use observer and lose previous emissions; the wiring-fail-fast pattern (runtime-api.md §Option 范式分层) requires it surface as an error.

View Source
var ErrUnknownEnvelopeVersion = errcode.New(errcode.KindInvalid, errcode.ErrEnvelopeSchema,
	"outbox: unknown envelope schema version")

ErrUnknownEnvelopeVersion is returned when a wire message carries an unrecognized or absent schemaVersion field.

View Source
var ReservedMetadataKeys = []string{
	"trace_id",
	"traceparent",
	"trace_state",
	"tracestate",
	"span_id",
	"request_id",
	"correlation_id",

	"actor_id",
	"subject_id",
	"tenant_id",
	"session_id",
}

ReservedMetadataKeys lists keys that the kernel observability and principal bridges own exclusively. Producers writing these into Entry.Metadata is a programming error: the typed Entry.Observability / Entry.Principal fields are the canonical home for trace/request/correlation and actor/subject/tenant/ session identity, and Entry.Validate rejects entries that try to forge them via the producer-owned Metadata namespace.

The list is exhaustive — these are the keys the kernel's observability/trace and principal bridges reserve. Note that not every reserved key round-trips through ctx: span_id / trace_state / tracestate have no ObservabilityMetadata field today, but are reserved for namespace hygiene as members of the W3C trace-context family the bridge owns. Adding a new bridge key requires extending this list (frozen by archtest OUTBOX-RESERVED-METADATA-KEYS-FROZEN-01 against an independent want-set; each key's rejection is exercised by TestEntry_Validate_RejectsReservedMetadataKeys).

occurred_at is deliberately NOT reserved: it belongs to neither bridge family. It is a domain event-time scalar carried by its own dedicated sealed Entry field (occurredAt), so a producer's Metadata["occurred_at"] is inert against the real field — reserving it would be a category error, not defense. See ADR 202605281200-1042 §5.

Functions

func CheckNotNoop

func CheckNotNoop(mode DurabilityMode, cellID string, deps ...any) error

CheckNotNoop returns an error if mode is invalid, or if any dep implements Nooper and mode is DurabilityDurable. In DurabilityDemo mode, all deps are accepted. nil deps are silently skipped (nil checks belong in the caller).

func DemoCellTxManager

func DemoCellTxManager() persistence.CellTxManager

DemoCellTxManager returns a sealed persistence.CellTxManager backed by DemoTxRunner. Cell.Init uses this when the composition root has not provided a real CellTxManager (publisher-only demo assemblies).

The returned value still implements Nooper (via the wrapper's transparent Noop pass-through), so outbox.CheckNotNoop rejects it under DurabilityDurable — demo fallbacks can never silently slip into a durable assembly.

This factory is the kernel-internal demo entry point; it pairs with composition-root wraps (persistence.WrapForCell) for production wiring. The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.

func Emit

func Emit[T any](ctx context.Context, clk clock.Clock, emitter Emitter, topic string, payload T) error

Emit marshals payload to JSON and constructs + emits an Entry for the given topic via NewEntry (the sole sealed constructor) and emitter.Emit.

Replaces the hand-written "json.Marshal → Entry{} → Emit" pattern at producer call sites: one line instead of four, and the signature mechanically rules out silent marshal-error drops (_, _ := json.Marshal). clk is the mandatory positional clock dependency threaded into NewEntry (which stamps createdAt/occurredAt and injects observability + principal from ctx). The helper is transport-only — callers remain responsible for any surrounding transaction or atomicity scope their persistence path requires.

Error contract: Emit does not log on failure. Callers MUST either return the error (so the surrounding HTTP handler / consumer / runPersist path logs it at the correct correlation-ID scope) or log it themselves before swallowing. Silently discarding the returned error defeats the S41 guard this helper exists to enforce.

ref: ThreeDotsLabs/watermill components/cqrs/marshaler.go — typed struct at publisher, reflection-based marshal at call site. GoCell keeps the helper minimal (no CQRS command/event taxonomy, no header shim); callers that need AggregateID / Metadata / FailurePolicy call NewEntry directly with the corresponding EntryOption and then emitter.Emit.

func ExponentialDelay

func ExponentialDelay(base, maxDelay time.Duration, attempt int) time.Duration

ExponentialDelay computes base * 2^attempt with overflow protection, capped at maxDelay. Used by both claimWithRetry and retryLoop.

This is the single source of truth for exponential-backoff delay computation; adapters (e.g., rabbitmq) should call this function instead of maintaining their own copies.

func MarshalEnvelope

func MarshalEnvelope(entry Entry) ([]byte, error)

MarshalEnvelope serializes an Entry into the canonical v1 wire envelope.

Callers are responsible for setting entry.CreatedAt before calling MarshalEnvelope; this function is pure (no clock interaction). The PG outbox writer pre-fills CreatedAt from now() at INSERT time, the relay reads it back from the row, and DirectEmitter sets it from its injected clock.Clock — all paths populate CreatedAt before reaching this function.

Producer-side fail-fast: every ID-shaped field is parsed through idutil.ParseSafeID so an unsafe in-memory Entry surfaces an error at write time rather than poisoning downstream consumers (defense in depth against accidental Entry{ID: rawUnsafe} construction outside the trusted MustNewEntryID path).

func MustNewEntryID

func MustNewEntryID() string

MustNewEntryID is the panic-on-error variant of NewEntryID. The underlying entropy read only fails on broken OS entropy, which is non-recoverable, so most call sites use this Must variant; outbox writers that want to surface the error to upstream tx callers should use NewEntryID directly.

func NewEntryID

func NewEntryID() (string, error)

NewEntryID returns a new outbox.Entry.ID value in the canonical format "evt-<hex32>" (RFC 4122 v4-style UUID, serialized as 32 hex chars without dashes). Centralized so the prefix stays consistent across all slices that write to the outbox.

Uses crypto/rand directly (not github.com/google/uuid) because kernel/ may only depend on the Go standard library per CLAUDE.md's layering rule.

Returns the wrapped entropy read error if the OS entropy source is broken; callers that prefer fail-fast wiring can use MustNewEntryID.

func NotifySettlement

func NotifySettlement(
	ctx context.Context, outcome DeliveryOutcome, entry Entry,
	disposition Disposition, settlement SettlementResult, err error,
)

NotifySettlement emits a settlement observation to every observer attached to the outcome. It is a no-op when no observer is present.

func ReportDurable

func ReportDurable(e Emitter) bool

ReportDurable returns the durability status of an Emitter, defaulting to false when the implementation does not expose DurabilityReporter. Intended for Cell boundaries that accept a directly-injected Emitter via WithEmitter but still need to decide L2/L0 slice upgrades based on durability.

func Transition

func Transition(from, to State) error

Transition validates a state transition from → to and returns an error if it is not allowed. Pure validation; it does NOT mutate any state.

Usage in the relay settlement loop

The relay calls Transition at its three settlement decision points as a Mark↔target cross-check: a function that calls MarkPublished must also call Transition(_, StatePublished), and similarly for MarkDead/StateDead and MarkRetry/StatePending. This pairing is enforced statically by OUTBOX-STATE-TRANSITION-GUARD-01. Because the arguments are constants, these calls cannot fail at runtime against the current transition table — their purpose is machine-readable pairing documentation, not runtime correctness. The relay still propagates the returned error as a drift safety net: if a legal edge is later removed from stateTransitions, the mismatch surfaces loudly (relay returns the error) instead of silently diverging.

Store transitions without Transition calls

ClaimPending (pending→claiming) and ReclaimStale (claiming→pending/dead) have no Mark* pairing to cross-check. Their correctness is enforced by SQL CAS predicates, conformance behavior tests, and LITERAL-BAN.

ref: kernel/saga/status.go::Transition (same pattern and name).

func ValidateMode

func ValidateMode(mode DurabilityMode) error

ValidateMode returns an error if mode is not a known DurabilityMode. Use at assembly-start boundaries to reject misconfiguration early.

func WriteBatchFallback

func WriteBatchFallback(ctx context.Context, w Writer, entries []Entry) error

WriteBatchFallback writes entries using the Writer interface, falling back to sequential Write calls if the writer does not implement BatchWriter.

Validation scope: WriteBatchFallback runs Entry.Validate() on all entries upfront (topic + payload checks). Writer-specific validation (e.g. UUID format, transaction presence) is the responsibility of the Writer/BatchWriter implementation and may run independently.

The caller MUST ensure ctx carries an active transaction. Atomicity depends on the transaction scope: if all writes happen within the same transaction, a failure rolls back everything. WriteBatchFallback itself does not manage transactions.

An empty entries slice is a no-op and returns nil.

Types

type BatchWriter

type BatchWriter interface {
	Writer
	// WriteBatch persists multiple outbox entries atomically within the
	// caller's transaction scope. Implementations MUST validate entries
	// independently (defense-in-depth) even when called via
	// WriteBatchFallback, which only runs Entry.Validate(). If validation
	// fails for any entry, no entries are written.
	//
	// An empty entries slice is a no-op and returns nil.
	//
	// Implementations SHOULD use a single batch INSERT for efficiency.
	// The context MUST carry the caller's transaction (same requirement
	// as Writer.Write). All-or-nothing: either all entries are written
	// or none are (transaction rollback on failure).
	WriteBatch(ctx context.Context, entries []Entry) error
}

BatchWriter extends Writer with batch write support. Implementations that support batch operations SHOULD implement this interface for atomic multi-entry writes within a single transaction.

If an implementation does not support BatchWriter, callers can use WriteBatchFallback which auto-detects batch support and falls back to sequential Write calls.

ref: ThreeDotsLabs/watermill message/pubsub.go -- Publish(topic, ...msgs) variadic pattern. GoCell uses a separate interface instead to preserve the existing Writer contract and enable optimized multi-row INSERT.

type CellEmitter

type CellEmitter interface {
	Emitter
	DurabilityReporter
	healthz.ProbeSet
	// contains filtered or unexported methods
}

CellEmitter is the only Emitter-shaped type that cells/<x> public With* Options (Cell.WithEmitter + every slice WithEmitter) may accept. The unexported sealedCellEmitter() method makes it unimplementable outside this package — kernel/outbox is the sole entry point via WrapEmitterForCell, which composition roots and the kernel-internal ResolveCellEmitter funnel call.

Unlike CellPublisher / CellWriter, CellEmitter additionally embeds DurabilityReporter and healthz.ProbeSet. Embedding the bare Emitter interface in internalCellEmitter promotes only Emit, so the inner *WriterEmitter.Durable() and *DirectEmitter.Probes() would be hidden behind the wrapper. Listing both in the CellEmitter contract makes the forwarding methods on internalCellEmitter COMPILE-MANDATORY (AI-HARD): omit Durable() or Probes() and internalCellEmitter no longer satisfies CellEmitter, so WrapEmitterForCell fails to compile. Without this a missing forward would silently break L2 durability detection (ResolveCellEmitter) and emitter health-probe registration (cell.RegisterEmitterHealthProbes).

AI-robust 评级:

  • 字段/赋值层 + Durable()/Probes() forwarding:Hard(sealed marker 使外部 不可表达 internalCellEmitter 字面量;interface 嵌入使内部不可省略 forwarding)
  • 公开 With* Option 签名层:Medium(CELL-RAW-INFRA-PUBLIC-OPTION-PARAM-01)
  • Noop() 透传:Hard(SEALED-MARKER-NOOP-TRANSPARENCY-01 typed auto-discovery)

CellEmitter embeds Emitter so cells pass the wrapped value directly to slice service constructors / Emit call sites — those accept Emitter and the embedded interface satisfies them transparently, so internal API does not change.

ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1

func DemoCellEmitter

func DemoCellEmitter() CellEmitter

DemoCellEmitter returns a sealed CellEmitter backed by NewNoopEmitter (a NoopWriter-backed WriterEmitter). It is the emitter-leg analog of DemoCellTxManager: composition roots and test builders that previously passed a raw outbox.NewNoopEmitter() to a cell/slice WithEmitter now pass DemoCellEmitter() so the public Option accepts the sealed CellEmitter.

The wrapped emitter is non-durable (Durable()==false via NoopWriter), so a durable assembly that forgets a real emitter still surfaces the error at ResolveCellEmitter rather than silently losing L2 atomicity.

The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.

func NewDirectCellEmitter

func NewDirectCellEmitter(
	p Publisher, mode DirectPublishFailureMode, mp metrics.Provider, clk clock.Clock, cellID string, opts ...DirectEmitterOption,
) (CellEmitter, error)

NewDirectCellEmitter returns a sealed CellEmitter backed by a DirectEmitter, for cells that publish directly with no transactional outbox by design — e.g. L4 DeviceLatent cells (KG-07: no outbox writer / txRunner). It composes NewDirectEmitter + WrapEmitterForCell so a public WithEmitter Option receives a sealed CellEmitter.

Use this instead of ResolveCellEmitter for L4 direct-publish: unlike ResolveCellEmitter it carries NO cellvocab.L2 non-durable atomicity Warn. For L4 direct publish the absence of an outbox writer/txRunner is the intended production mode, not a demo degradation, so the L2 warning would be misleading. This is the "typed function choice" sibling of ResolveCellEmitter (mode-driven L1/L2/L3 resolution): the direct-publish-by-design semantic is expressed by the API name, not by a runtime suppression flag.

The durable-publisher guard (outbox.CheckNotNoop) is the caller's responsibility before this path; this constructor does not re-derive durability mode. The wrapped DirectEmitter is non-durable (Durable()==false) and forwards its fail-open Probes() through the sealed wrapper.

The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.

func ResolveCellEmitter

func ResolveCellEmitter(clk clock.Clock, in CellEmitterInputs) (CellEmitter, error)

ResolveCellEmitter is the Cell-side wrapper around ResolveEmitter that enforces the contract shared by accesscore/auditcore/configcore:

  1. PreResolved (WithEmitter) and raw deps (WithOutboxDeps) are mutually exclusive — both set returns ErrCellInvalidConfig.
  2. PreResolved + durable mode requires a durable emitter; otherwise returns ErrCellMissingOutbox.
  3. Otherwise delegate to ResolveEmitter.
  4. When the resolved emitter is non-durable and the Cell's consistency level is cellvocab.L2 or higher, emit a Warn explaining the degraded atomicity guarantee. The log carries cell, consistency_level, durability_mode.

clk is the mandatory clock passed through to ResolveEmitter for DirectEmitter CreatedAt stamping. Pass clock.Real() in production and clockmock.New(...) in tests. When PreResolved is set, clk is still validated via MustHaveClock to catch wiring mistakes early; it is not consumed in that path.

Returns a sealed CellEmitter: the PreResolved emitter is already sealed, and the WithOutboxDeps path wraps the kernel-built emitter via WrapEmitterForCell so cells store CellEmitter uniformly. Callers read the resolved durability via the returned emitter's Durable() for any composition-root decision.

ref: outbox.ResolveEmitter — the primitive this wraps.

func WrapEmitterForCell

func WrapEmitterForCell(e Emitter) CellEmitter

WrapEmitterForCell is the sole authorized path for handing an Emitter to a cell's With* Option. Returns nil when e is bare-nil OR a typed-nil interface (e.g. `var e *DirectEmitter`) so caller-side typed-nil detection keeps working in accumulative builder options — mirror of WrapPublisherForCell.

Allowed callers (enforced by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01):

  • cmd/* composition roots
  • examples/<demo>/main.go, examples/<demo>/app.go, examples/<demo>/run.go
  • *_test.go in any layer
  • kernel/outbox/cell_marker.go (this file)
  • kernel/outbox/mode_resolver.go (ResolveCellEmitter resolution funnel, which wraps the kernel-built DirectEmitter/WriterEmitter so cells hold a sealed CellEmitter uniformly)

type CellEmitterInputs

type CellEmitterInputs struct {
	EmitterConfig
	// PreResolved is the sealed emitter set directly via Cell.WithEmitter(e)
	// (wrapped at the composition root via WrapEmitterForCell). When non-nil,
	// ResolveCellEmitter skips ResolveEmitter and validates that durable mode
	// requires a durable PreResolved (PreResolved.Durable()==true).
	PreResolved CellEmitter
	// ConsistencyLevel is the owning Cell's consistency level; used to decide
	// whether the cellvocab.L2 non-durable Warn log fires.
	ConsistencyLevel cellvocab.Level
}

CellEmitterInputs bundles the Cell-side inputs for ResolveCellEmitter. Embeds EmitterConfig and adds the two knobs shared by every Cell's Init-time emitter resolution: the pre-resolved emitter (WithEmitter) and the Cell's consistency level (for the cellvocab.L2 non-durable Warn).

type CellPublisher

type CellPublisher interface {
	Publisher
	// contains filtered or unexported methods
}

CellPublisher is the only Publisher-shaped type that cells/<x>/cell.go public With* Options may accept. The unexported sealedCellPublisher() method makes CellPublisher unimplementable outside this package — kernel/outbox is the sole entry point via WrapPublisherForCell, which composition roots must call.

AI-robust 评级:

  • 字段/赋值层:Hard(sealed marker,外部不可表达 internalCellPublisher 字面量)
  • 公开 With* Option 签名层:Medium(CELL-RAW-INFRA-PUBLIC-OPTION-PARAM-01 archtest type-aware 守)

双重防线,参见 ADR 202605101900-adr-cell-raw-infra-sealed-marker §D2。

CellPublisher embeds Publisher so cells can pass the wrapped value directly to outbox.NewDirectEmitter etc. — those constructors accept Publisher and the embedded interface satisfies them transparently, so internal kernel/outbox API does not change.

ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1

func WrapPublisherForCell

func WrapPublisherForCell(p Publisher) CellPublisher

WrapPublisherForCell is the sole authorized path for handing a Publisher to a cell's With* Option. Returns nil when p is bare-nil OR a typed-nil interface (e.g. `var p *amqpPublisher`) so caller-side typed-nil detection keeps working in accumulative builder options. Without IsNilInterface the wrapper would emit a non-nil sealed value hiding the inner nil, silently bypassing Init() fail-fast guards and panicking on the first Publish call.

Allowed callers (enforced by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01):

  • cmd/* composition roots
  • examples/<demo>/main.go, examples/<demo>/app.go, and examples/<demo>/run.go composition roots (run.go is the hand-written half of the K#10 main+run split)
  • *_test.go in any layer
  • kernel/outbox/cell_marker.go (this file)

type CellWriter

type CellWriter interface {
	Writer
	// contains filtered or unexported methods
}

CellWriter mirrors CellPublisher for the outbox.Writer side: cells/<x> public With* Options accept CellWriter; raw Writer is sealed off behind WrapWriterForCell, callable only from composition roots.

ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1

func WrapWriterForCell

func WrapWriterForCell(w Writer) CellWriter

WrapWriterForCell mirrors WrapPublisherForCell for the Writer side. Bare-nil and typed-nil are both rejected via validation.IsNilInterface.

type ClaimPolicy

type ClaimPolicy uint8

ClaimPolicy controls ConsumerBase behavior when Claimer.Claim() fails. The zero value (ClaimPolicyFailClosed) is the safe default.

const (
	// ClaimPolicyFailClosed (default zero-value): retry Claim with exponential
	// backoff. Safe from duplicates, but consumption stops until the idempotency
	// backend recovers.
	ClaimPolicyFailClosed ClaimPolicy = iota

	// ClaimPolicyFailOpen: single Claim attempt; on error, proceed without
	// idempotency receipt. Avoids total consumer stall, but risks duplicate
	// processing during outage.
	ClaimPolicyFailOpen
)

func (ClaimPolicy) String

func (p ClaimPolicy) String() string

String returns the lowercase kebab-case name of the ClaimPolicy. Unknown values render as "unknown(N)". The zero value (ClaimPolicyFailClosed) is the safe-by-default Go convention: any unset ClaimPolicy field automatically uses the stricter fail-closed path.

func (ClaimPolicy) Valid

func (p ClaimPolicy) Valid() bool

Valid returns true if the ClaimPolicy is a recognized enum value.

type ConsumerBase

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

ConsumerBase wraps an EntryHandler with two-phase idempotency (Claim/Commit/Release) and exponential backoff retry. DLQ routing is handled by the broker via DLX (DispositionReject triggers Nack requeue=false).

Settlement flows to the Subscriber delivery loop via the second return value of SubscriberHandler: ConsumerBase.Wrap returns (DeliveryOutcome, Settlement) so that Commit/Release can be called after broker Ack/Nack without leaking idempotency types into business code. Business handlers only return the slim HandleResult; ConsumerBase lifts it to DeliveryOutcome, injecting ProcessReason (e.g. "retry_exhausted") as needed.

Lives in kernel/outbox rather than adapters/rabbitmq because the logic is broker-agnostic — it only depends on kernel/idempotency + outbox types — and is wired by runtime/bootstrap alongside any transport that speaks the Subscriber interface. Adapters (rabbitmq, nats, kafka) reuse this middleware unchanged.

Consumer: cg-{ConsumerGroup}-{topic} Idempotency key: {ConsumerGroup}:{event-id}, TTL 24h ACK timing: after business logic returns DispositionAck Retry: transient errors -> retry+backoff / permanent errors -> DispositionReject → DLX

ref: ThreeDotsLabs/watermill message/router.go — router-level retry/poison/dedup ref: MassTransit UseMessageRetry — pipeline middleware at receive endpoint ref: NATS JetStream consumer_config AckWait+MaxDeliver+BackOff — subscriber config

func NewConsumerBase

func NewConsumerBase(claimer idempotency.Claimer, config ConsumerBaseConfig, clk clock.Clock) (*ConsumerBase, error)

NewConsumerBase creates a ConsumerBase using the two-phase Claimer interface. Returns an error if ConsumerBaseConfig contains invalid values (e.g., unknown ClaimPolicy). The returned Receipt is threaded through HandleResult so that the Subscriber can Commit/Release after broker Ack/Nack.

clk is the time source for backoff sleeps. It must be non-nil; pass clock.Real() in production and clockmock.New() in tests.

ref: nats-go Connect() (*Conn, error), watermill-amqp NewSubscriber() (*Subscriber, error) — constructors return error, never panic.

func (*ConsumerBase) AttachObserver

func (cb *ConsumerBase) AttachObserver(o ConsumerObserver) error

AttachObserver wires a ConsumerObserver that receives notifications on every terminal Reject disposition (handler-explicit reject or retry-budget exhaustion). AttachObserver may be called at most once per ConsumerBase; repeat calls return ErrObserverAlreadyAttached (wiring fail-fast per runtime-api.md §Option 范式分层). Bare-nil and typed-nil observers are rejected with ErrValidationFailed.

AttachObserver is the single public injection path for ConsumerObserver. The internal observer field is private and is not exposed via ConsumerBaseConfig — ConsumerBase is typically constructed at composition root (cmd/corebundle) before the metrics provider is wired in bootstrap phase 5, so constructor injection is not viable. bootstrap.autoWireOutboxRejectCollector calls AttachObserver in phase 6 (before subscriptions start consuming); business code typically does not call this directly.

ref: Temporal MetricsHandler observer-injected pattern

func (*ConsumerBase) IsConstructed

func (cb *ConsumerBase) IsConstructed() bool

IsConstructed reports whether the ConsumerBase came from NewConsumerBase (true) rather than from a zero-value struct literal (false). Production wiring uses this to fail fast when a literal `&ConsumerBase{}` is fed into runtime/bootstrap.WithConsumerBase, preventing a silent retryLoop=0 path.

func (*ConsumerBase) Wrap

Wrap returns a SubscriberHandler that wraps the given business handler with two-phase Claim/Commit/Release idempotency and retry with exponential backoff.

The idempotency key is constructed as "{sub.ConsumerGroup}:{entry.id}", ensuring cross-cell fanout correctness: each cell's ConsumerGroup forms a separate namespace so ClaimDone in one cell does not silence another.

The Receipt is threaded through DeliveryOutcome -- ConsumerBase never calls Commit/Release itself; that is the delivery loop's job after broker Ack/Nack.

Fail-open (ClaimPolicyFailOpen): single Claim attempt; on error, proceed without idempotency -- avoids total consumer stall, but risks duplicate processing during outage.

Fail-closed (ClaimPolicyFailClosed, default zero-value): all Claim attempts go through claimWithRetry (including the first), so every failure is followed by exponential backoff + jitter. Safe from duplicates, but all consumption stops until the idempotency backend recovers.

Rules:

  • handler returns DispositionAck -> pass through as Ack
  • handler returns DispositionRequeue -> pass through as Requeue
  • handler returns DispositionReject -> pass through as Reject
  • handler returns error with non-Ack disposition -> retry with backoff
  • DispositionReject (handler-explicit) -> Reject (broker routes to DLX)
  • retry budget exhausted -> Reject with ProcessReason="retry_exhausted"
  • ctx canceled / shutdown -> Requeue

Wrap lifts a business EntryHandler into a SubscriberHandler that includes idempotency claim/release and retry logic. The returned SubscriberHandler is passed to Subscriber.Subscribe (not EntryHandler) so Settlement can be delivered to the Subscriber without leaking idempotency types into business code.

Settlement is nil when ConsumerBase has no idempotency state: fail-open claim error, ClaimDone (already processed), or ClaimBusy (in progress). Subscribers MUST nil-check Settlement before calling Commit/Release.

type ConsumerBaseConfig

type ConsumerBaseConfig struct {
	// RetryCount is the maximum number of retries for transient errors.
	// Default: 3.
	RetryCount int

	// RetryBaseDelay is the initial delay for exponential backoff retries.
	// Default: 1s.
	RetryBaseDelay time.Duration

	// IdempotencyTTL is the TTL for idempotency keys (done-key TTL for Claimer).
	// Default: 24h (idempotency.DefaultTTL).
	IdempotencyTTL time.Duration

	// LeaseTTL is the processing-lease TTL for the Claimer backend.
	// If a consumer crashes mid-processing, the lease expires after this
	// duration, allowing another consumer to re-claim.
	// Default: 5m (idempotency.DefaultLeaseTTL). Only used with Claimer.
	LeaseTTL time.Duration

	// ClaimPolicy controls behavior when Claimer.Claim() fails due to
	// infrastructure errors (e.g., Redis down). See ClaimPolicyFailClosed
	// (default zero-value) and ClaimPolicyFailOpen for details.
	ClaimPolicy ClaimPolicy

	// ClaimRetryCount is the max number of Claim() attempts on the fail-closed
	// path before returning DispositionRequeue to the broker.
	// Default: falls back to RetryCount (3).
	ClaimRetryCount int

	// ClaimRetryBaseDelay is the initial backoff delay between Claim() retries.
	// Default: falls back to RetryBaseDelay (1s).
	ClaimRetryBaseDelay time.Duration

	// MaxRetryDelay caps the exponential backoff delay for both claimWithRetry
	// and retryLoop, preventing unbounded growth with large retry counts.
	// Default: 30s.
	MaxRetryDelay time.Duration

	// LeaseRenewalInterval is how often the lease renewal goroutine calls
	// receipt.Extend while the handler is running. Zero falls back to
	// LeaseTTL/3 so the lease is renewed well before it expires.
	// Set to a negative value to disable lease renewal entirely.
	LeaseRenewalInterval time.Duration
}

ConsumerBaseConfig configures ConsumerBase behavior.

func (*ConsumerBaseConfig) SetDefaults

func (c *ConsumerBaseConfig) SetDefaults()

SetDefaults populates zero-valued fields with safe defaults. Called automatically by NewConsumerBase; exported so callers constructing the config outside of NewConsumerBase (e.g., test harnesses verifying default values) can invoke it directly.

type ConsumerObserver

type ConsumerObserver interface {
	ObserveReject(ctx context.Context, cellID, topic, consumerGroup, reason string)
}

ConsumerObserver receives notifications from ConsumerBase when a delivery reaches a terminal Reject disposition. Implementations are provided by runtime/observability/metrics (OutboxRejectCollector) so kernel/outbox stays free of metric backend imports.

ObserveReject is called exactly once per terminal Reject (handler-explicit reject OR retry-budget exhaustion); cellID / topic / consumerGroup are the observability dimensions, reason is a closed-set string drawn from ConsumerRejectReason* constants.

Implementers should NOT include consumerGroup in metric label sets — it is provided here for slog/tracing dimensions but adding it as a Prometheus label expands cardinality multiplicatively (every consumer group × topic × cell × reason combination becomes a unique time-series). See runtime/observability/metrics.OutboxRejectCollector for the sanctioned label set {cell, topic, reason}.

Note: PendingDepthObserver is a SEPARATE interface defined in runtime/outbox (not here) covering the Relay path's pending-depth Gauge. The two interfaces serve different subsystems and are wired independently.

ref: kernel/outbox/relay_collector.go — RelayCollector is the analogous interface for the Relay path; same kernel-defined-runtime-implemented pattern keeps adapter-backend imports out of kernel.

type DeliveryOutcome

type DeliveryOutcome struct {
	Disposition Disposition
	Err         error // optional: logged/observed; nil for success

	// ProcessReason is a low-cardinality kernel/process classification, such
	// as "retry_exhausted". Injected by ConsumerBase, never set by business
	// handlers.
	ProcessReason string

	// SettlementObservers are notified by subscribers after final broker
	// settlement. Appended by subscriber-layer middleware (e.g.
	// WrapConfigEventSubscriber) after ConsumerBase has resolved
	// retry/lease decisions.
	SettlementObservers []SettlementObserver
}

DeliveryOutcome is the subscriber-layer carrier produced by ConsumerBase.Wrap. It extends the slim HandleResult with kernel-injected ProcessReason and SettlementObservers so the subscriber (rabbitmq / eventbus / mqtt) can perform post-settlement notification without any of those concerns leaking into business handler code.

Business handlers return HandleResult. ConsumerBase.Wrap lifts HandleResult → DeliveryOutcome, injecting ProcessReason (e.g. "retry_exhausted") before returning SubscriberHandler to the subscriber layer.

type DemoTxRunner

type DemoTxRunner struct{}

DemoTxRunner is the cell-boundary pass-through TxRunner installed at Cell Init() when the composition root has not provided a real persistence.TxRunner (publisher-only demo assemblies). It implements Nooper, so CheckNotNoop rejects it under DurabilityDurable mode — demo callers that forget to wire a real TxRunner surface an error at Init() time instead of silently losing cellvocab.L2 atomicity guarantees.

func (DemoTxRunner) Noop

func (DemoTxRunner) Noop() bool

Noop reports DemoTxRunner as a no-op runner for CheckNotNoop guards.

func (DemoTxRunner) RunInTx

func (DemoTxRunner) RunInTx(ctx context.Context, fn func(context.Context) error) error

RunInTx executes fn directly without a real transaction wrapper. nil fn is treated as a no-op for safety.

type DirectEmitter

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

DirectEmitter emits by wrapping entries in the v1 wire envelope and calling Publisher.Publish directly.

func NewDirectEmitter

func NewDirectEmitter(
	p Publisher, mode DirectPublishFailureMode, mp metrics.Provider, clk clock.Clock, cellID string, opts ...DirectEmitterOption,
) (*DirectEmitter, error)

NewDirectEmitter adapts a Publisher into an Emitter that publishes v1 wire envelopes. cellID is the owning Cell's ID and is used as the "cell" label on the fail-open dropped counter; it must be non-empty (empty string returns errcode.ErrValidationFailed). mp is a required metrics.Provider used to register the fail-open dropped counter (fqName after Namespace injection: gocell_outbox_emit_failopen_dropped_total); pass metrics.NopProvider{} in tests or demos where no backend is wired. A nil mp returns an errcode error. clk is the mandatory positional clock dependency (CLOCK-POSITIONAL-INJECTION-01); it is validated via MustHaveClock at construction. DirectEmitter no longer stamps timestamps — NewEntry is the single source of createdAt/occurredAt — but the dependency is retained so every emitter is constructed clock-aware. Pass clock.Real() in production and clockmock.New(...) in tests.

Use WithLogger to override the default slog.Default() logger. Use WithFailOpenRateThreshold to set the drop-ratio threshold for the Probes checker (default 5%; 0 disables).

func (*DirectEmitter) Durable

func (*DirectEmitter) Durable() bool

Durable always returns false for DirectEmitter: direct publishing bypasses the transactional outbox and therefore carries no durability guarantee by design.

func (*DirectEmitter) Emit

func (e *DirectEmitter) Emit(ctx context.Context, entry Entry) error

Emit validates the entry, marshals the v1 wire envelope, and publishes synchronously. The entry's createdAt/occurredAt stamps and observability + principal identity were already set by NewEntry at construction (the single injection trust boundary); Emit re-runs Validate as defense in depth before touching the wire. When publish fails, the per-entry FailurePolicy (or the construction-time default) decides between fail-closed (return the wrapped error) and fail-open (log + increment the gocell_outbox_emit_failopen_dropped_total counter and return nil so the caller's request path is not blocked on broker availability).

func (*DirectEmitter) Probes

func (e *DirectEmitter) Probes() []healthz.Probe

Probes returns the fail-open rate probe for this emitter.

The probe name "outbox_failopen_rate_<cellID>" is snake_case per the PROBENAME-SEALED-FUNNEL-01 convention (a framework rate probe, so no "_ready" suffix). The composed name flows through the typed healthz.EmitterFailOpenProbeName(cellID) constructor at NewDirectEmitter time; a hyphen or other illegal character in cellID fails construction (no runtime drift possible — the value-shape check is funneled into the same NewProbeName validator used at every other construction site).

type DirectEmitterOption

type DirectEmitterOption func(*directEmitterOptions)

DirectEmitterOption configures NewDirectEmitter.

func WithFailOpenRateThreshold

func WithFailOpenRateThreshold(ratio float64) DirectEmitterOption

WithFailOpenRateThreshold sets the drop ratio threshold above which the emitter's Probes checker reports outbox.ErrDegraded.

Default is 0.05 (5%) — the tracker is enabled by default because fail-open drop monitoring is framework infrastructure responsibility, not a per-cell opt-in (CLAUDE.md "生产配置禁止静默降级"). Pass WithFailOpenRateThreshold(0) to explicitly disable.

The implicit time window is the interval between two /readyz probes (typically 10-30s under K8s readinessProbe).

ref: kernel/outbox/failopen_tracker.go for ratio semantics.

func WithLogger

func WithLogger(l *slog.Logger) DirectEmitterOption

WithLogger overrides slog.Default() for this DirectEmitter's structured logs.

type DirectPublishFailureMode

type DirectPublishFailureMode int

DirectPublishFailureMode controls direct publisher error handling.

const (
	DirectPublishFailClosed DirectPublishFailureMode = iota + 1
	DirectPublishFailOpen
)

type DiscardPublisher

type DiscardPublisher struct {
	// Logger is used for discard warnings. If nil, slog.Default() is used.
	Logger *slog.Logger
	// contains filtered or unexported fields
}

DiscardPublisher is an explicit publisher sink for tests and demos. Unlike NoopWriter, it affects direct-publish flows rather than durable outbox writes. It is an explicit opt-in sink, not a default runtime fallback.

Use DiscardPublisher when:

  • Unit testing Cells that require an outbox.Publisher dependency
  • Running demo/example code without a message broker

Publish logs a structured warning and discards the payload. The warning ensures discard behavior is visible in logs.

ref: go-logr zero-value safe (if sink == nil), slog DiscardHandler

func (*DiscardPublisher) Close

func (d *DiscardPublisher) Close(_ context.Context) error

Close implements Publisher. DiscardPublisher holds no resources; always nil.

func (*DiscardPublisher) DiscardCount

func (d *DiscardPublisher) DiscardCount() uint64

DiscardCount returns the total number of messages discarded. Returns 0 on a nil receiver.

func (*DiscardPublisher) Noop

func (*DiscardPublisher) Noop() bool

Noop implements cell.Nooper. CheckNotNoop rejects DiscardPublisher in durable mode.

func (*DiscardPublisher) Publish

func (d *DiscardPublisher) Publish(_ context.Context, topic string, _ []byte) error

Publish logs a discard warning, increments the counter, and returns nil. Safe to call on a nil receiver (typed nil defense).

type Disposition

type Disposition uint8

Disposition describes the broker-level action for a consumed message.

  • DispositionAck: message processed successfully (or duplicate); broker may discard.
  • DispositionRequeue: temporary failure / shutdown; broker should redeliver.
  • DispositionReject: permanent failure; broker routes to dead-letter exchange.
const (
	// DispositionAck indicates the message was processed successfully (or is a
	// safe-to-skip duplicate); broker may discard.
	//
	// IMPORTANT: iota+1 ensures the zero value (0) is NOT a valid Disposition.
	// A forgotten/uninitialised HandleResult.Disposition will NOT silently ACK.
	DispositionAck     Disposition = iota + 1 // = 1
	DispositionRequeue                        // NACK+requeue -- transient / shutdown
	DispositionReject                         // NACK+no-requeue -- permanent failure -> DLX
)

func (Disposition) String

func (d Disposition) String() string

String returns a human-readable label for the Disposition. The zero value returns "invalid" to surface forgotten/uninitialised fields.

func (Disposition) Valid

func (d Disposition) Valid() bool

Valid reports whether d is a recognized Disposition value. The zero value is deliberately invalid to catch forgotten/uninitialised fields.

type DurabilityMode

type DurabilityMode int

DurabilityMode declares whether an assembly runs in demo or durable mode. Cells use this to reject noop/test implementations at Init() time, preventing "pseudo-success" assemblies in production.

The zero value is intentionally invalid (unset), forcing callers to explicitly choose DurabilityDemo or DurabilityDurable. ref: Vault StoredKeysInvalid=0, gRPC InvalidSecurityLevel=0, net/http SameSite iota+1

const (
	// DurabilityDemo allows noop implementations (NoopWriter, NoopTxRunner,
	// DiscardPublisher). Used by examples/ and unit tests.
	DurabilityDemo DurabilityMode = iota + 1

	// DurabilityDurable rejects noop implementations at Init() time.
	// Used by production assemblies (e.g., cmd/corebundle).
	DurabilityDurable
)

func (DurabilityMode) String

func (m DurabilityMode) String() string

String returns "demo", "durable", or "unset".

type DurabilityReporter

type DurabilityReporter interface {
	Durable() bool
}

DurabilityReporter is an optional interface Emitter implementations may expose so callers (typically Cell boundaries) can query whether this emitter is backed by durable (transactional outbox) sinks. Emitters that do not implement DurabilityReporter are treated as non-durable by callers — the safe default for direct-publish and noop paths.

ref: kernel/cell.EmitterOutcome.Durable — the primary consumer; Cells use the reported value to decide whether optional slices (e.g. rbacassign) upgrade from L0 to L2. ref: github.com/ThreeDotsLabs/watermill message/router.go — `disabledPublisher` pattern; an explicit typed indicator lets callers branch on capability without runtime type switches.

type Emitter

type Emitter interface {
	Emit(ctx context.Context, entry Entry) error
}

Emitter emits an outbox entry either by writing it to a durable outbox or by directly publishing its canonical wire envelope.

Implementations may optionally satisfy DurabilityReporter to expose whether their sink is backed by durable (transactional outbox) storage. Callers that need to decide L2/L0 slice upgrades should use ReportDurable, which returns false for any Emitter that does not implement the optional interface.

func NewNoopEmitter

func NewNoopEmitter() Emitter

NewNoopEmitter returns an Emitter backed by NoopWriter.

type EmitterConfig

type EmitterConfig struct {
	CellID            string
	Mode              DurabilityMode
	Publisher         Publisher
	OutboxWriter      Writer
	TxRunner          persistence.TxRunner
	Logger            *slog.Logger
	DirectPublishMode DirectPublishFailureMode
	// MetricsProvider is REQUIRED when DurabilityDemo mode resolves to a DirectEmitter.
	MetricsProvider metrics.Provider
}

EmitterConfig bundles the inputs needed to resolve an outbox.Emitter for a Cell according to DurabilityMode rules.

ResolveEmitter accepts nil TxRunner and treats it as absent (no pairing with a real writer is possible). Callers in Durable mode must inject a real TxRunner explicitly; Demo mode tolerates nil and any Nooper implementation.

MetricsProvider is required for DirectEmitter resolution paths. Pass metrics.NopProvider{} explicitly in tests.

The clock is NOT a field here — it is a mandatory positional parameter to ResolveEmitter (see ADR 202605270000-adr-clock-positional-injection-funnel).

ref: outbox.CheckNotNoop — sibling durability guard.

type EmitterOutcome

type EmitterOutcome struct {
	Emitter Emitter
	Durable bool
}

EmitterOutcome reports the resolved emitter and whether it is durable (backed by a real writer+txRunner). Cells use Durable to decide whether optional slices (e.g., rbacassign) can activate L2 outbox publication.

func ResolveEmitter

func ResolveEmitter(clk clock.Clock, cfg EmitterConfig) (EmitterOutcome, error)

ResolveEmitter picks the right outbox.Emitter based on durability mode, publisher, writer, and txRunner. Semantics mirror the pre-existing per-cell resolveOutboxDeps+resolveDemoEmitter pair that accesscore/ configcore/auditcore each carried.

clk is the mandatory clock injected into DirectEmitter for CreatedAt stamping. Pass clock.Real() in production and clockmock.New(...) in tests.

Durable mode: requires real writer+txRunner (non-noop); nil/noop → error.

Demo mode: prefers DirectEmitter when publisher is present and writer is absent-or-noop. Falls back to WriterEmitter when writer is present (paired with txRunner — both together form a valid demo sink). Both absent → error.

ref: outbox.CheckNotNoop — sibling durability guard. ref: github.com/ThreeDotsLabs/watermill message/router.go — disabledPublisher pattern.

type Entry

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

Entry represents a single outbox record to be published.

Entry is sealed construction (issue #1229): all fields are unexported and the only ways to obtain an Entry are NewEntry (the producer constructor), UnmarshalEnvelope (the wire-decode funnel), and EntryScan.ToEntry (the storage-adapter reconstruction funnel) — all three in package kernel/outbox. A composite literal `outbox.Entry{...}` outside this package is a compile error (type-system Hard), which is the point: OccurredAt is always clock-derived, Observability and Principal are always ctx-injected at the trust boundary, and no producer can forge or omit them. Consumers read state through the value-receiver getters below.

ref: OpenTelemetry SpanContext / OIDC Principal — typed carriers of trace and principal identity, kept distinct from producer-owned business Metadata.

func NewEntry

func NewEntry(clk clock.Clock, ctx context.Context, eventType string, payload []byte, opts ...EntryOption) (Entry, error)

NewEntry is the sole producer constructor for an outbox Entry. It stamps createdAt = occurredAt = clk.Now().UTC() (overridable via WithCreatedAt / WithOccurredAt), generates a fresh ID (overridable via WithID), applies the options, injects observability + principal identity from ctx (the single injection trust boundary — there is no producer-facing inject API), and validates. clk is the mandatory positional clock dependency (CLOCK-POSITIONAL-INJECTION-01); a typed-nil clk panics via MustHaveClock (programmer error).

Because Entry fields are unexported, NewEntry / UnmarshalEnvelope / EntryScan.ToEntry are the only ways to obtain an Entry — a composite literal outside kernel/outbox does not compile (OUTBOX-ENTRY-SEALED-CONSTRUCTION-01).

func UnmarshalEnvelope

func UnmarshalEnvelope(topic string, raw []byte) (Entry, error)

UnmarshalEnvelope decodes a v1 wire envelope into an Entry. Unsafe ID-shaped fields (newline, length overrun, etc.) are rejected during json.Unmarshal via idutil.SafeID.UnmarshalJSON — wrapped here as ErrEnvelopeSchema for consistent error classification across the schema-version / missing-field / unsafe-id rejection paths.

func (Entry) AggregateID

func (e Entry) AggregateID() string

AggregateID returns the owning domain-entity identifier (may be empty).

func (Entry) AggregateType

func (e Entry) AggregateType() string

AggregateType returns the owning domain-entity type (may be empty).

func (Entry) CreatedAt

func (e Entry) CreatedAt() time.Time

CreatedAt returns the store/seal time stamped at construction.

func (Entry) EventID

func (e Entry) EventID() string

EventID is the stream-unique identifier (cellvocab.ProjectionEvent). It is the entry id — the polymorphic carrier name for outbox.Entry.ID.

func (Entry) EventType

func (e Entry) EventType() string

EventType returns the event type tag.

func (Entry) FailurePolicy

func (e Entry) FailurePolicy() FailurePolicy

FailurePolicy returns the per-entry publisher failure policy (in-process control plane; not on the wire).

func (Entry) ID

func (e Entry) ID() string

ID returns the canonical idempotency identifier.

func (Entry) Metadata

func (e Entry) Metadata() map[string]string

Metadata returns a defensive copy of the business metadata map so external mutation cannot reach the sealed entry state. Returns nil when no metadata is set.

func (Entry) Observability

func (e Entry) Observability() ObservabilityMetadata

Observability returns the cross-async tracing identity carried by the entry.

func (Entry) OccurredAt

func (e Entry) OccurredAt() time.Time

OccurredAt returns the producer-domain event time.

func (Entry) Payload

func (e Entry) Payload() []byte

Payload returns the event payload. The returned slice aliases the entry's backing array (no copy, consumer hot path) — callers MUST NOT mutate it.

func (Entry) Principal

func (e Entry) Principal() PrincipalMetadata

Principal returns the cross-async OAuth/OIDC principal identity carried by the entry. The returned value is itself immutable-by-construction (PrincipalMetadata exposes no setters).

func (Entry) RestoreContext

func (e Entry) RestoreContext(ctx context.Context) context.Context

RestoreContext installs the entry's observability + principal identity into ctx (cellvocab.ProjectionEvent). It is the single source of the "restore both envelope identity families into ctx" sequence used by the projection rebuild path and the live consumer delivery path (SubscriberWithMiddleware). Both RestoreToContext calls are idempotent and preserve any ambient transaction already on ctx.

func (Entry) RoutingTopic

func (e Entry) RoutingTopic() string

RoutingTopic returns the broker routing key for the entry. If topic is set, it is returned; otherwise eventType is used as fallback.

func (Entry) Stream

func (e Entry) Stream() string

Stream is the routing/topic equivalent (cellvocab.ProjectionEvent): the projection harness compares it against the subscribed spec topic. Identical to RoutingTopic; the distinct name reads correctly for a non-outbox carrier.

func (Entry) Topic

func (e Entry) Topic() string

Topic returns the explicit broker routing key (may be empty; use RoutingTopic for the EventType fallback).

func (Entry) Validate

func (e Entry) Validate() error

Validate checks that required fields (id, topic or eventType, payload, and a non-zero occurredAt) are present, that metadata is within size limits and free of reserved keys, and that observability + principal fields are well-formed. NewEntry calls Validate at construction; the wire-decode and reconstruction funnels (UnmarshalEnvelope, EntryScan.ToEntry) re-run it. (F-OB-03, META-SIZE-01, PR246-FU1 reserved-key invariant, issue #1229).

type EntryHandler

type EntryHandler func(context.Context, Entry) HandleResult

EntryHandler is the business handler signature. Business handlers return a HandleResult that declares the intended broker disposition. Settlement is not visible to business handlers — it is delivered via SubscriberHandler, which is the Subscriber-layer interface (not business layer).

type EntryOption

type EntryOption func(*Entry)

EntryOption customizes an Entry under construction. Options may set the aggregate identity, explicit topic, business metadata, failure policy, and (for domain-time / reconstruction needs) the createdAt / occurredAt stamps.

There is deliberately no WithPrincipal / WithObservability option: those two families are injected from the construction context by NewEntry alone, so a producer cannot forge or override them — the trust boundary is the ctx, not the call site.

func WithAggregateID

func WithAggregateID(id string) EntryOption

WithAggregateID sets the owning domain-entity identifier.

func WithAggregateType

func WithAggregateType(t string) EntryOption

WithAggregateType sets the owning domain-entity type.

func WithCreatedAt

func WithCreatedAt(t time.Time) EntryOption

WithCreatedAt overrides the store/seal time. Rare — reserved for callers that must pin createdAt to a domain timestamp. The value is normalized to UTC.

func WithFailurePolicy

func WithFailurePolicy(p FailurePolicy) EntryOption

WithFailurePolicy sets the per-entry publisher failure policy (DirectEmitter paths only; ignored by the transactional WriterEmitter).

func WithID

func WithID(id string) EntryOption

WithID overrides the auto-generated entry ID. Reserved for reconstruction and tests that need a deterministic ID; producers should let NewEntry generate one.

func WithMetadata

func WithMetadata(m map[string]string) EntryOption

WithMetadata sets business metadata. The map is cloned so later caller-side mutation cannot reach the sealed entry state. Reserved keys (ReservedMetadataKeys) are rejected by Validate. Note: "occurred_at" is NOT reserved, but writing it here is inert against the domain event time — use WithOccurredAt to override Entry.OccurredAt.

func WithOccurredAt

func WithOccurredAt(t time.Time) EntryOption

WithOccurredAt overrides the producer-domain event time. By default NewEntry stamps occurredAt = createdAt = clock.Now(); pass this when the domain event time differs from the seal time (e.g. an order created earlier in the same request). The value is normalized to UTC.

func WithTopic

func WithTopic(t string) EntryOption

WithTopic sets an explicit broker routing key. When unset, RoutingTopic falls back to the eventType.

type EntryScan

type EntryScan struct {
	ID            string
	AggregateID   string
	AggregateType string
	EventType     string
	Topic         string
	Payload       []byte
	CreatedAt     time.Time
	OccurredAt    time.Time
	Metadata      map[string]string
	Observability ObservabilityMetadata
	Principal     PrincipalMetadata
	FailurePolicy FailurePolicy
}

EntryScan is the sanctioned reconstruction funnel for storage adapters that rebuild an Entry from persisted columns (the relay claim path scans DB rows into it, then calls ToEntry). It exists because Entry fields are unexported (sealed construction, issue #1229): an adapter in another package cannot scan `rows.Scan(&entry.id, ...)` nor build a composite literal, so the kernel exposes this typed, validated rebuild path instead.

EntryScan deliberately carries exported fields — it is a scan target, not a producer surface. It has NO wire role (wireMessage remains the only json.Unmarshal funnel) and NO clock/ctx injection (the persisted row already carries createdAt/occurredAt/observability/principal). ToEntry runs the full Entry.Validate, so a corrupt row cannot reconstruct an invalid Entry.

EntryScan is asserted to be the only exported Entry-reconstruction mirror by archtest OUTBOX-ENTRY-SEALED-CONSTRUCTION-01; any second exported struct that reconstitutes an Entry would defeat the seal.

func (EntryScan) ToEntry

func (s EntryScan) ToEntry() (Entry, error)

ToEntry assembles a sealed Entry from the scanned columns and validates it. Storage adapters MUST route persisted-row reconstruction through this method rather than attempting to construct an Entry directly.

type FailurePolicy

type FailurePolicy int

FailurePolicy expresses how an Emitter handles publisher-side failures for a particular Entry. Cells default their DirectEmitter to DirectPublishFailClosed (k8s apiserver audit model); individual entries opt into FailOpen for observational / non-critical sinks.

Reserved for direct-publish paths: WriterEmitter (transactional outbox) surfaces errors through the surrounding transaction and does not consult FailurePolicy — the field is ignored there by design.

const (
	// FailurePolicyDefault falls through to Emitter ctor default. Zero value.
	FailurePolicyDefault FailurePolicy = iota
	// FailurePolicyFailOpen drops on publisher failure (log + counter), returns nil.
	FailurePolicyFailOpen
	// FailurePolicyFailClosed surfaces publisher failure to caller.
	FailurePolicyFailClosed
)

func (FailurePolicy) Resolve

Resolve returns the effective DirectPublishFailureMode, preferring the per-entry policy when set and falling through to the Emitter construction- time default when FailurePolicyDefault.

type HandleResult

type HandleResult struct {
	Disposition Disposition
	Err         error // optional: logged/observed; nil for success
}

HandleResult carries the business handler's processing outcome. Business handlers return HandleResult via EntryHandler — it contains only what the handler author decides: the disposition and an optional error.

ProcessReason and SettlementObservers are subscriber-layer concerns and live in DeliveryOutcome (the type ConsumerBase.Wrap produces). Business handlers never need to set those fields; ConsumerBase injects ProcessReason (e.g. "retry_exhausted") and the subscriber layer appends SettlementObservers.

func Ack

func Ack() HandleResult

Ack returns a HandleResult declaring successful processing. ConsumerBase will Ack the broker delivery and Commit the idempotency Receipt.

ref: cloudevents/sdk-go/v2/protocol Receipt + ResultACK -- factory pattern for handler outcome types.

func Reject

func Reject(err error) HandleResult

Reject returns a HandleResult declaring permanent failure. ConsumerBase will Nack(requeue=false) so the broker routes to DLX. Wrap err with outbox.NewPermanentError to tag it for logging/metrics; the disposition itself is what the broker observes — PermanentError is purely diagnostic.

Passing a nil err is accepted but reduces DLX observability — the downstream operator has no diagnostic to attach. Production handlers SHOULD pass a non-nil err (typically wrapped with NewPermanentError).

func Requeue

func Requeue(err error) HandleResult

Requeue returns a HandleResult declaring transient failure. ConsumerBase will return the message to the broker for backoff retry; once the retry budget is exhausted the consumer escalates to Reject so the broker can route to DLX. err is optional — pass nil if the caller has no diagnostic to attach.

type NoopRelayCollector

type NoopRelayCollector struct{}

NoopRelayCollector is a no-op implementation of RelayCollector. Used when metrics collection is disabled (nil Metrics in RelayConfig).

func (NoopRelayCollector) RecordBatchSize

func (NoopRelayCollector) RecordBatchSize(_ context.Context, _ int)

func (NoopRelayCollector) RecordCleanup

func (NoopRelayCollector) RecordCleanup(_ context.Context, _, _ int64)

func (NoopRelayCollector) RecordPollCycle

func (NoopRelayCollector) RecordPollCycle(_ context.Context, _ PollCycleResult)

func (NoopRelayCollector) RecordReclaim

func (NoopRelayCollector) RecordReclaim(_ context.Context, _ int64)

type NoopWriter

type NoopWriter struct{}

NoopWriter is an explicit outbox writer sink for tests and demos. It validates entries like a real writer, then discards them instead of persisting anything. It is not a production durability mechanism.

Use NoopWriter when:

  • Unit testing Cells that require an outbox.Writer dependency
  • Running demo/example code without a database

NoopWriter still validates entries via Entry.Validate(), unlike a zero-value or nil writer. This catches schema errors during development.

func (NoopWriter) Noop

func (NoopWriter) Noop() bool

Noop implements cell.Nooper. CheckNotNoop rejects NoopWriter in durable mode.

func (NoopWriter) Write

func (NoopWriter) Write(_ context.Context, entry Entry) error

Write validates the entry, discards it, and returns nil.

func (NoopWriter) WriteBatch

func (NoopWriter) WriteBatch(_ context.Context, entries []Entry) error

WriteBatch validates all entries, discards them, and returns nil.

type Nooper

type Nooper interface {
	Noop() bool
}

Nooper is a marker interface for test/demo-only implementations. Types that implement Nooper are rejected by CheckNotNoop when the assembly runs in DurabilityDurable mode.

Kernel noop types (outbox.NoopWriter, outbox.DiscardPublisher) implement this interface; cells in Demo mode may register their own Nooper TxRunners.

type NopConsumerObserver

type NopConsumerObserver struct{}

NopConsumerObserver is the default ConsumerObserver used when no metrics provider is wired. ObserveReject is a no-op.

func (NopConsumerObserver) ObserveReject

func (NopConsumerObserver) ObserveReject(_ context.Context, _, _, _, _ string)

ObserveReject is a no-op: NopConsumerObserver is the default observer when no metrics collector is wired. Discarding the observation is deliberate — the no-op exists so ConsumerBase can always call ObserveReject unconditionally without a nil guard.

type ObservabilityMetadata

type ObservabilityMetadata struct {
	TraceID       idutil.SafeID `json:"traceId,omitempty"`
	TraceParent   string        `json:"traceParent,omitempty"`
	RequestID     idutil.SafeID `json:"requestId,omitempty"`
	CorrelationID idutil.SafeID `json:"correlationId,omitempty"`
}

ObservabilityMetadata carries cross-async tracing context that the gocell observability bridge owns. Producers MUST NOT populate these fields directly — the writer bridge (InjectObservabilityFromContext) fills them from context at persistence time. Consumer middleware (RestoreToContext) reads them back into handler context.

The typed field replaces the pre-PR246-FU1 string-key metadata bridge (Merge*/IsReserved*) which allowed producers to forge reserved keys via entry.Metadata["trace_id"] = "evil" before the observability layer got a chance to overwrite them. With a typed field the forgery surface is removed by construction — the observability system and the business metadata map no longer share a key namespace.

ref: OpenTelemetry SpanContext -- typed carrier of trace identity across transport boundaries, kept distinct from application attributes. Adopted: separate struct for system-owned fields vs. producer-owned Metadata map. Deviated: only 4 fields (no sampled flag, no traceFlags struct) to keep the async boundary narrow; TraceParent is the W3C canonical form.

TraceID/RequestID/CorrelationID use idutil.SafeID — UnmarshalJSON fail-closes on unsafe characters at wire boundary (CWE-117). TraceParent keeps `string`: its 55-byte W3C format has its own validator (validTraceParent) and is not in the IsSafeID character set.

func ContextObservability

func ContextObservability(ctx context.Context) ObservabilityMetadata

ContextObservability reads reserved observability values from ctx and returns a populated ObservabilityMetadata. Missing keys stay empty. Falls back to a synthesized W3C traceparent from trace_id+span_id when ctx has no explicit traceparent (preserves pre-FU1 semantics).

func (ObservabilityMetadata) IsZero

func (o ObservabilityMetadata) IsZero() bool

IsZero reports whether all fields are empty.

func (ObservabilityMetadata) RestoreToContext

func (o ObservabilityMetadata) RestoreToContext(ctx context.Context) context.Context

RestoreToContext returns a new context populated with the non-empty fields of o. Existing non-empty ctx values WIN (idempotent restore — the consumer ctx may already carry trace identity from an inbound header on a synchronous spawn path; we do not stomp it). Values that fail safety validation (overlong, unsafe chars, invalid traceparent) are silently dropped.

Producer/consumer asymmetry — by design:

  • InjectObservabilityFromContext OVERWRITES e.Observability with the producer-side context's identity (the writer is the source of truth for what the entry carries).
  • RestoreToContext does NOT overwrite existing ctx values (the consumer ctx may legitimately have its own trace propagated by an outer middleware; the entry's identity is a fallback, not a mandate).

Restoration also does not synthesize a TraceParent from a TraceID-only metadata: synthesis is a producer-side fallback (ContextObservability builds it from ctx trace_id+span_id when no traceparent is set), but on the consumer side the wire-captured TraceParent is the canonical truth. If it's empty, no synthesis can recover the original parent-id.

func (ObservabilityMetadata) Validate

func (o ObservabilityMetadata) Validate() error

Validate enforces per-field size + charset bounds. Each non-empty ID field (TraceID/RequestID/CorrelationID) must satisfy idutil.IsSafeID and len ≤ idutil.MaxMetadataIDLen via SafeID.Validate; TraceParent must be a valid W3C traceparent (fixed 55-byte format, checked via validTraceParent).

No aggregate size check: per-field caps already cover the worst case (see MaxObservabilityTotalSize doc).

Producers MUST call Validate (via Entry.Validate, which is called by every Writer.Write impl) so size violations surface at write time rather than as silent broker rejections or downstream OOMs.

type PermanentError

type PermanentError struct {
	Err error
}

PermanentError wraps an error to indicate it should not be retried and should be routed to the dead-letter queue. This is a domain concept alongside Disposition and HandleResult.

ref: Temporal SDK temporal.ApplicationError (NonRetryable flag in SDK core); Watermill delegates error classification to middleware -- GoCell makes it explicit at the kernel level so WrapLegacyHandler and InMemoryEventBus can detect it without depending on adapter-layer types.

func NewPermanentError

func NewPermanentError(err error) *PermanentError

NewPermanentError wraps an error as a PermanentError.

func (*PermanentError) Error

func (e *PermanentError) Error() string

func (*PermanentError) Unwrap

func (e *PermanentError) Unwrap() error

type PollCycleResult

type PollCycleResult struct {
	// Published / Retried / Dead are the canonical outcomes from the publish
	// and writeback phases. Skipped covers `MarkPublished updated=false`
	// (the entry was reclaimed mid-flight before MarkPublished could win).
	// Lost covers the same condition for failure writebacks (Mark{Retry,Dead}
	// updated=false): the lease lost mid-flight while the publisher was
	// reporting an error, so the failure must NOT be counted as retried/dead
	// — the new lease owner will report the canonical outcome.
	Published, Retried, Dead, Skipped, Lost int
	ClaimDur, PublishDur, WriteBackDur      time.Duration
}

PollCycleResult captures the outcome of a single relay poll cycle. Used by RelayCollector.RecordPollCycle to avoid a long parameter list and to support future extensions without breaking the interface.

type PrincipalMetadata

type PrincipalMetadata struct {
	ActorID   idutil.SafeID `json:"actorId,omitempty"`
	SubjectID idutil.SafeID `json:"subjectId,omitempty"`
	TenantID  idutil.SafeID `json:"tenantId,omitempty"`
	SessionID idutil.SafeID `json:"sessionId,omitempty"`
}

PrincipalMetadata carries cross-async principal identity that the gocell observability bridge owns. Producers MUST NOT populate these fields directly — NewEntry fills them from the construction context (the single injection trust boundary). Consumer middleware (RestoreToContext) reads them back into handler context. It mirrors ObservabilityMetadata field-for -field (sealed-construction sibling, issue #1229).

OAuth/OIDC semantics:

  • ActorID = impersonator — the principal actually triggering the action (token "act.sub" claim). In non-impersonation flows equals SubjectID.
  • SubjectID = subject-of-record — the user the action is performed on behalf of (token "sub" claim).
  • TenantID = tenant boundary identifier (multi-tenant deployments).
  • SessionID = session identifier (server-side session binding).

The typed field prevents producers from forging principal IDs via entry.Metadata["actor_id"] = "evil" — the two namespaces are physically separate, and Entry.Validate rejects ReservedMetadataKeys to keep producers honest at write time. Mirror of ObservabilityMetadata's rationale for trace identity.

ref: OpenID Connect Core 1.0 §5.1 ("sub"), RFC 8693 §4.1 ("act") — typed carrier of principal identity, kept distinct from observability (trace) and business (metadata) namespaces.

All four fields use idutil.SafeID — UnmarshalJSON fail-closes on unsafe characters at wire boundary (CWE-117 defense).

The field set, JSON tags, and method set are frozen by archtest PRINCIPAL-SEALED-FIELD-FROZEN-01.

func ContextPrincipal

func ContextPrincipal(ctx context.Context) PrincipalMetadata

ContextPrincipal reads reserved principal values from ctx and returns a populated PrincipalMetadata. Missing keys stay empty. Mirror of ContextObservability — ctx values were already validated upstream (runtime/auth middleware writes ctxkeys at the request trust boundary); cast to SafeID without re-checking; producer-side Validate (invoked by Entry.Validate at NewEntry) catches any drift before reaching wire.

TenantID provenance (scope fallback, #1618 F1): pre-auth emits that run inside a tenant.WithScope but carry no authenticated-principal ctxkeys.TenantID — login's event.session.created.v1 and setup's event.user.created.v1, both emitted inside accesscore scopedtx — would otherwise land with an empty principal tenant and be written by the audit appender into the tenant_id=” system chain, which migration 055's `OR tenant_id=”` RLS USING clause makes readable by EVERY tenant (a cross-tenant audit leak). The scope is the real resolved tenant for those operations, so we fall back to it when ctxkeys is absent. This mirrors the GUC source precedence in adapters/postgres tenantScopeForTx (scope → ctxkeys); the scope value's write provenance is itself Hard-funnel-locked (TENANT-TXSCOPE-WRITE-CALLER-01), so principal-tenant inherits that trust. Post-auth emits set ctxkeys.TenantID (auth middleware), so the ctxkeys branch wins and behavior there is unchanged.

func (PrincipalMetadata) IsZero

func (p PrincipalMetadata) IsZero() bool

IsZero reports whether all fields are empty.

func (PrincipalMetadata) RestoreToContext

func (p PrincipalMetadata) RestoreToContext(ctx context.Context) context.Context

RestoreToContext returns a new context populated with the non-empty fields of p. Existing non-empty ctx values WIN (idempotent restore — the consumer ctx may already carry principal identity from an outbound header on a synchronous spawn path; we do not stomp it). Values that fail safety validation (overlong, unsafe chars) are silently dropped.

Producer/consumer asymmetry — by design (mirror of ObservabilityMetadata.RestoreToContext):

  • NewEntry OVERWRITES e.principal with the construction context's identity (the producer is the source of truth for what the entry carries).
  • RestoreToContext does NOT overwrite existing ctx values (the consumer ctx may legitimately carry its own principal propagated by an outer middleware; the entry's identity is a fallback).

The no-overwrite / idempotent contract above is for the generic / spawn path. The async consume path (SubscriberWithMiddleware.SubscribeEntry) pre-clears the ambient principal via clearAmbientPrincipal before calling RestoreContext, so the entry's wire principal (actor/subject/tenant/session) is authoritative and the no-overwrite guard never blocks it. This mirrors the projection rebuild detach boundary (kernel/projection.clearAmbientPrincipal).

func (PrincipalMetadata) Validate

func (p PrincipalMetadata) Validate() error

Validate enforces per-field size + charset bounds. Each non-empty field must satisfy idutil.IsSafeID and len ≤ idutil.MaxMetadataIDLen via SafeID.Validate. Zero-value PrincipalMetadata returns nil (matches ObservabilityMetadata optional model).

Producers MUST call Validate (via Entry.Validate, which is called by every Writer.Write impl) so size violations surface at write time rather than as silent broker rejections or downstream OOMs.

type ProviderRelayCollectorConfig

type ProviderRelayCollectorConfig struct {
	// PollBuckets overrides DefaultRelayPollBuckets; zero value uses defaults.
	PollBuckets []float64
	// BatchBuckets overrides DefaultRelayBatchBuckets; zero value uses defaults.
	BatchBuckets []float64
}

ProviderRelayCollectorConfig customizes metric naming / bucketing. Zero value is acceptable and produces defaults.

type Publisher

type Publisher interface {
	Publish(ctx context.Context, topic string, payload []byte) error
	// Close terminates the publisher and releases resources. The ctx parameter
	// allows callers to share a shutdown budget (e.g., bootstrap shutCtx).
	Close(ctx context.Context) error
}

Publisher sends events to the message broker.

ref: uber-go/fx app.go Lifecycle.Append OnStop(ctx) — ContextCloser pattern adopted so bootstrap teardown can share a unified shutCtx budget across all managed publishers.

type Relay

type Relay interface {
	Start(ctx context.Context) error
	Stop(ctx context.Context) error
}

Relay polls unpublished outbox entries and publishes them.

type RelayCollector

type RelayCollector interface {
	// RecordPollCycle records a completed poll cycle with outcome counts and
	// per-phase durations. Called once per pollOnce invocation after writeBack.
	RecordPollCycle(ctx context.Context, r PollCycleResult)

	// RecordBatchSize records the number of entries claimed in a poll cycle.
	// Called even when the batch is empty (size=0) to capture idle cycles.
	RecordBatchSize(ctx context.Context, size int)

	// RecordReclaim records the number of stale entries reclaimed back to
	// pending (or dead-lettered). Called once per reclaimStale tick **only
	// when count > 0** — idle reclaim sweeps are not observed (the metric
	// is a recovery counter, not a tick frequency gauge). This contrasts
	// with RecordBatchSize which is also called on size==0 cycles so
	// dashboards can detect a totally idle relay.
	RecordReclaim(ctx context.Context, count int64)

	// RecordCleanup records the number of entries removed during periodic
	// cleanup, split by original status (published vs dead-lettered).
	RecordCleanup(ctx context.Context, publishedDeleted, deadDeleted int64)
}

RelayCollector records outbox relay operational metrics. Implementations must be safe for concurrent use. Zero counts are valid inputs; implementations should handle them gracefully (e.g. skip counter increments for zero values).

The interface is intentionally in kernel/outbox (not runtime/) so that adapters/postgres can depend on it without pulling in runtime/ packages.

ref: Temporal client.Options{MetricsHandler} — inject-at-construction pattern ref: Watermill components/metrics — publish_time_seconds, subscriber_messages_received_total ref: Debezium JMX — MilliSecondsBehindSource, max.batch.size, DLQ count

func NewProviderRelayCollector

func NewProviderRelayCollector(p metrics.Provider, cellID string, opts ...ProviderRelayCollectorConfig) (RelayCollector, error)

NewProviderRelayCollector registers outbox relay metrics on p and returns a RelayCollector that records through them. Returns error when cellID is empty or when the Provider reports registration failure (typically duplicate metric names).

type SerialInOrderGuarantor

type SerialInOrderGuarantor interface {
	GuaranteesSerialInOrderDelivery() bool
}

SerialInOrderGuarantor is an optional Subscriber-implementer extension contract. Business handlers must never reference this interface.

A Subscriber that implements it and returns true ASSERTS that it delivers a single subscription's stream strictly serially and in order: at most one delivery is dispatched at a time and the next is handed off only after the previous handler returns. This is the precondition L3 CQRS projection subscriptions REQUIRE — projection exactly-once rests on a monotonic checkpoint (applyOne skips any event whose stream position ≤ the stored checkpoint), which is only SOUND under serial in-order delivery. Under concurrent delivery (e.g. the AMQP subscriber dispatching one goroutine per delivery with prefetch>1) a higher position can commit the checkpoint before a lower position is applied, silently dropping the lower event's distinct apply (a projection gap). See kernel/projection/doc.go "Ordering precondition" and ADR docs/architecture/202605261620-adr-cqrs-projection-lifecycle-harness.md §6 threat row 4.

Scope of the guarantee: it holds for a SINGLE subscriber on a given (consumerGroup, topic). A projection's consumer group is derived as "<cellID>-<projectionID>" and the bootstrap drain registers exactly one subscription for it, so the single-subscriber precondition is structurally satisfied. The guarantee does NOT extend to multiple competing subscribers sharing one consumer group (e.g. the in-memory bus round-robins across them on independent goroutines).

fail-closed by absence: a Subscriber that does NOT implement this interface is treated as NOT serial. The projection drain rejects wiring a projection onto such a transport. A concurrent transport therefore opts OUT simply by not implementing the method — and any future transport that forgets to implement it is auto-rejected for projections rather than silently unsafe.

Guarded by archtest PROJECTION-SERIAL-DELIVERY-ENFORCEMENT-01, which freezes this method set and pins the implementer set to exactly the qualifying transports. A new transport that implements this marker MUST also be added to that archtest's implementer golden (serialGuarantorSoleImpl), or sub-rule B fails CI — by design, so adding a serial transport is a deliberate update.

type Settlement

type Settlement interface {
	Commit(ctx context.Context) error
	Release(ctx context.Context) error
}

Settlement is the broker-side commit/release handle that ConsumerBase delivers alongside HandleResult via SubscriberHandler. Subscriber implementations (adapters/rabbitmq, runtime/eventbus) call Commit before broker Ack and Release after broker Nack to finalize idempotency state.

Business handlers MUST NOT see Settlement — it is part of SubscriberHandler (framework Subscriber-side hand-off), not EntryHandler (business return signature). The compile-time separation supersedes the prior HANDLER-RECEIPT-WRITE-01 archtest gate, which guarded a HandleResult.Receipt field that no longer exists (see 029 #12 K#12 PR-V1-OUTBOX-RECEIPT-EXTRACT).

kernel/idempotency.Receipt implicitly satisfies Settlement: its Commit and Release methods match this interface, and its Extend method is hidden in the Settlement view (renewal stays inside ConsumerBase).

ref: nats-io/nats.go jetstream/message.go Msg interface — settle ops

(Ack/Nak/Term/InProgress) abstracted into a single interface

ref: IBM/sarama consumer_group.go ConsumeClaim(session ConsumerGroupSession,

claim ConsumerGroupClaim) — settle handle as explicit method parameter
rather than a ctx side-channel

type SettlementObservation

type SettlementObservation struct {
	Entry         Entry
	Disposition   Disposition
	Result        SettlementResult
	ProcessReason string
	Err           error
}

SettlementObservation is emitted by subscribers after the final delivery settlement action is known.

type SettlementObserver

type SettlementObserver interface {
	ObserveSettlement(context.Context, SettlementObservation)
}

SettlementObserver records a post-settlement observation. Implementations must be non-blocking or bounded; subscriber delivery loops call observers on the hot path after broker settlement.

type SettlementObserverFunc

type SettlementObserverFunc func(context.Context, SettlementObservation)

SettlementObserverFunc adapts a function into a SettlementObserver.

func (SettlementObserverFunc) ObserveSettlement

func (f SettlementObserverFunc) ObserveSettlement(ctx context.Context, obs SettlementObservation)

type SettlementResult

type SettlementResult string

SettlementResult describes whether the subscriber completed the final broker settlement action. It intentionally observes the boundary after Commit, Ack/Nack, and receipt release decisions, not the handler's process result.

const (
	SettlementResultSuccess        SettlementResult = "success"
	SettlementResultRetryExhausted SettlementResult = "retry_exhausted"
	SettlementResultCommitFailed   SettlementResult = "commit_failed"
	SettlementResultAckFailed      SettlementResult = "ack_failed"
	SettlementResultNackFailed     SettlementResult = "nack_failed"
)

func RejectSettlementResult

func RejectSettlementResult(outcome DeliveryOutcome) SettlementResult

RejectSettlementResult classifies the SettlementResult of a Reject disposition from the DeliveryOutcome's ProcessReason. It is the single source every subscriber settle loop (rabbitmq / mqtt / eventbus) routes its Reject branch through, so retry-budget exhaustion is reported as SettlementResultRetryExhausted on every transport instead of drifting per adapter. A handler's explicit Reject (empty ProcessReason) stays SettlementResultSuccess — the broker settlement action itself succeeded; only the process outcome was a rejection.

type State

type State uint8

State is the publication state of a transactional-outbox entry.

Single-source role

State is the single Go-side source of the wire/DB status strings: String() produces the exact value bound into the `status` column, so adapters/postgres and runtime/outbox must use the enum rather than bare string literals (enforced by the OUTBOX-STATE-LITERAL-BAN-01 archtest).

Transition table

stateTransitions documents the complete legal state graph and is validated by the OUTBOX-STATE-TRANSITION-COMPLETENESS-01 archtest (every State const must be a key). It is NOT the runtime enforcement source for SQL operations:

  • relay settlement (writeBackOne / handleFailedEntry): three paths where the relay explicitly decides claiming→published, claiming→dead, or claiming→pending. Each calls Transition as a Mark↔target cross-check: a function that calls MarkPublished must also Transition to StatePublished, catching copy-paste mismatches at CI time (OUTBOX-STATE-TRANSITION-GUARD-01). These calls are not correctness guards — the constant arguments mean they can never fail at runtime — but they serve as machine-readable pairing assertions.

  • store ClaimPending (pending→claiming) and ReclaimStale (claiming→pending/dead): no Mark* pairing exists for these transitions. Their correctness is enforced by SQL CAS predicates (WHERE status='claiming'/'pending'), conformance behavior tests, and LITERAL-BAN. Duplicating Transition calls there would add no pairing-check value and would be misleading.

  • SQL-text ↔ table-edge machine binding: SQL args are ...any, so the wire string cannot be type-constrained at the binding site. This is a true Medium ceiling, explicitly accepted; no fragile static SQL↔edge parser is attempted.

ref: kernel/saga/status.go (transition-table + Transition pattern).

const (
	// StatePending: awaiting publish (initial state and the retry back-off
	// state).
	//
	// IMPORTANT: iota+1 ensures the zero value (0) is NOT a valid State, so a
	// forgotten/uninitialised State will not silently appear as pending.
	StatePending   State = iota + 1 // = 1
	StateClaiming                   // locked by a relay instance, publish in progress
	StatePublished                  // delivered to broker (terminal)
	StateDead                       // exceeded MaxAttempts, needs manual intervention (terminal)
)

func ParseState

func ParseState(s string) (State, error)

ParseState parses a wire/DB status string into a State. Returns errcode.ErrValidationFailed for unrecognized input.

func (State) CanTransitionTo

func (s State) CanTransitionTo(target State) bool

CanTransitionTo reports whether s can transition to target.

func (State) IsTerminal

func (s State) IsTerminal() bool

IsTerminal reports whether s is a terminal (final) state: published or dead.

func (State) String

func (s State) String() string

String returns the canonical wire/DB status string. This is the sole sanctioned producer of the status literal; see OUTBOX-STATE-LITERAL-BAN-01.

func (State) Valid

func (s State) Valid() bool

Valid reports whether s is a recognized State value.

func (State) ValidTransitions

func (s State) ValidTransitions() []State

ValidTransitions returns the states reachable from s, as a defensive copy. Returns nil for terminal states.

type Subscriber

type Subscriber interface {
	// Setup pre-declares broker topology (exchanges, queues, bindings) for the
	// given subscription before Subscribe is called. Callers SHOULD await Ready
	// before publishing to ensure messages are queued deterministically.
	// In-memory implementations MUST return nil immediately.
	Setup(ctx context.Context, sub Subscription) error

	// Ready returns a channel that is closed when the subscription is ready to
	// consume. In-memory implementations SHOULD return an already-closed channel.
	Ready(sub Subscription) <-chan struct{}

	// Subscribe registers a handler for the given subscription and blocks until
	// ctx is canceled or an unrecoverable error occurs.
	//
	// Subscription.ConsumerGroup identifies the logical consumer group.
	// Subscribers sharing the same group compete for messages (load-balanced);
	// different groups each receive a full copy (fanout).
	//
	// handler is SubscriberHandler so the Subscriber can receive Settlement
	// alongside DeliveryOutcome without idempotency types leaking into business
	// code. Callers that hold an EntryHandler and want the full business
	// pipeline (middleware chain + ConsumerBase idempotency) should use
	// SubscriberWithMiddleware.SubscribeEntry instead of lifting manually.
	Subscribe(ctx context.Context, sub Subscription, handler SubscriberHandler) error

	// Close terminates all active subscriptions and releases resources.
	// The ctx parameter allows callers to share a shutdown budget.
	//
	// ref: uber-go/fx app.go Lifecycle.Append OnStop(ctx context.Context) error
	// ref: ThreeDotsLabs/watermill message/pubsub.go Subscriber.Close()
	Close(ctx context.Context) error
}

Subscriber consumes events from a topic.

ref: ThreeDotsLabs/watermill message/pubsub.go Subscriber interface Adopted: Close() for clean shutdown; topic-based subscription model. Deviated: callback-based EntryHandler instead of channel-based (<-chan *Message) to align with GoCell's ConsumerBase pattern and simplify consumer lifecycle. Extended: Setup/Ready split mirrors Watermill Router's setup-before-run pattern, eliminating the 500ms startup-timeout heuristic in eventrouter (Commit 3).

ref: Kafka sarama ConsumerGroup -- consumerGroup isolates consumption; same group competes, different groups each get a full copy (fanout). ref: go-micro broker.SubscribeOptions.Queue -- same concept, different name.

type SubscriberHandler

type SubscriberHandler func(ctx context.Context, entry Entry) (DeliveryOutcome, Settlement)

SubscriberHandler is the Subscriber-layer handler type that ConsumerBase.Wrap returns and Subscriber.Subscribe accepts. It extends EntryHandler with a Settlement return value so Subscriber implementations can call Settlement.Commit before broker Ack and Settlement.Release after broker Nack without any idempotency types leaking into business code.

The return type is DeliveryOutcome (not HandleResult) because ConsumerBase.Wrap injects kernel-level ProcessReason ("retry_exhausted") and subscriber-layer SettlementObservers that business handlers must not set. Business handlers return slim HandleResult; ConsumerBase lifts it to DeliveryOutcome.

Settlement may be nil when ConsumerBase has no idempotency state (fail-open claim error, ClaimDone, ClaimBusy short-circuit). Subscribers MUST nil-check before calling Commit/Release.

Business handlers use EntryHandler — they never see Settlement. The type separation provides compile-time enforcement without archtest gates.

ref: IBM/sarama consumer_group.go ConsumeClaim(session ConsumerGroupSession,

claim ConsumerGroupClaim) — settle handle as explicit method parameter

ref: nats-io/nats.go jetstream/message.go Msg interface (Ack/Nak/Term)

type SubscriberIntakeStopper

type SubscriberIntakeStopper interface {
	StopIntake(ctx context.Context) error
}

SubscriberIntakeStopper is a Subscriber-implementer extension contract. Business handlers should never reference this interface.

Subscribers that implement it can stop accepting new deliveries while still processing in-flight ones, enabling a two-phase drain during shutdown (stop intake → wait in-flight handlers). Router.Close calls StopIntake on subscribers implementing this interface before canceling the run context.

Subscribers that do not implement this interface will fall back to the legacy single-phase close behavior (cancel context only).

StopIntake MUST be idempotent. It SHOULD be safe to call concurrently with in-flight Subscribe calls.

Errors are best-effort: callers (e.g., eventrouter.Router.Close) log a warning and proceed to context cancellation regardless. Return an error only for caller-observable failures that operators would want to see in logs.

ref: ThreeDotsLabs/watermill message/router.go (closingInProgressCh two-phase barrier) ref: uber-go/fx app.go shutdown semantics (run ctx vs stop ctx separation)

type SubscriberWithMiddleware

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

SubscriberWithMiddleware wraps a Subscriber with a business middleware chain and a required ConsumerBase for idempotency/retry.

The SubscribeEntry method is the primary entry point for callers that hold an EntryHandler (e.g., eventrouter.Router). It orchestrates:

  1. Business middleware chain (EntryHandler → EntryHandler): applied via slices.Backward(s.Middleware) — [0] is outermost (first to see each delivery, last to return), [len-1] is innermost (adjacent to ConsumerBase). This is the opposite of chi/Kratos forward composition where [0] is applied last.
  2. ConsumerBase.Wrap (EntryHandler → SubscriberHandler): injects idempotency Claim/Commit/Release and retry logic. SubscribeEntry fails fast when ConsumerBase is nil.
  3. Observability restore (entry.Observability → ctx): built-in OUTERMOST wrapper applied inside Inner.Subscribe so every layer sees a ctx populated with trace_id/request_id/correlation_id.
  4. Inner.Subscribe: the actual broker subscription.

Observability restoration is a paired invariant with Entry.InjectObservability FromContext on the producer side — there is no kill-switch. Raw delivery without restore (integration testing only) should invoke Inner.Subscribe directly.

SubscriberWithMiddleware does NOT implement the Subscriber interface: it exposes only SubscribeEntry (EntryHandler) as the single public entry point. This prevents the lift→discard footgun where callers could assign *SubscriberWithMiddleware to outbox.Subscriber and bypass the business middleware chain. Adapter-layer tests that need raw SubscriberHandler delivery must call the inner subscriber directly.

Construction is funneled through NewSubscriberWithMiddleware: fields are unexported so struct literals cannot create an invalid (nil-inner / nil-ConsumerBase) instance. This mirrors the OUTBOX-SERVICE-01 ctor fail-fast pattern that 12 outbox-bound services already follow.

ref: ThreeDotsLabs/watermill message/router.go — handleMessage applies middleware then calls handler; Ack/Nack monopoly stays in router. ref: go-kratos/kratos — middleware chain on business Request/Reply; transport/grpc monopolizes gRPC status. ref: IBM/sarama consumer_group.go — ConsumeClaim owns MarkMessage; business handler in ConsumeClaim body never calls MarkMessage directly.

func NewSubscriberWithMiddleware

func NewSubscriberWithMiddleware(inner Subscriber, cb *ConsumerBase, mw ...SubscriptionMiddleware) (*SubscriberWithMiddleware, error)

NewSubscriberWithMiddleware constructs a SubscriberWithMiddleware. inner and cb are required (nil → error); mw is optional. The constructor is the only path to a valid instance — fields are unexported so a struct literal cannot bypass the nil-guards. Methods (Setup / Ready / SubscribeEntry / Close / StopIntake) therefore delegate without runtime nil checks; the boundary is closed at construction time.

func (*SubscriberWithMiddleware) Close

Close delegates to the inner subscriber, forwarding the ctx unchanged so the inner implementation can honor the shutdown budget.

func (*SubscriberWithMiddleware) Ready

func (s *SubscriberWithMiddleware) Ready(sub Subscription) <-chan struct{}

Ready delegates to the inner subscriber.

func (*SubscriberWithMiddleware) Setup

Setup delegates topology pre-declaration to the inner subscriber.

func (*SubscriberWithMiddleware) StopIntake

func (s *SubscriberWithMiddleware) StopIntake(ctx context.Context) error

StopIntake forwards to the inner subscriber if it implements SubscriberIntakeStopper. Returns nil if it does not (graceful degradation). Safe to call multiple times (idempotent, assuming inner is).

func (*SubscriberWithMiddleware) SubscribeEntry

func (s *SubscriberWithMiddleware) SubscribeEntry(ctx context.Context, sub Subscription, h EntryHandler) error

SubscribeEntry is the entry point for callers that hold an EntryHandler (e.g. eventrouter.Router). It applies the full pipeline: business middleware chain → ConsumerBase.Wrap → observability/principal restore → inner Subscribe.

The business middleware chain is applied via slices.Backward(s.middleware): middleware[0] is the outermost layer (first to wrap, last to return) and middleware[len-1] is the innermost layer (adjacent to ConsumerBase). This is the opposite of chi/Kratos forward composition where index 0 is applied last. Example with middleware = [A, B, C]: execution order is A → B → C → handler.

This method is intentionally not part of the Subscriber interface: it accepts the business-layer EntryHandler rather than the framework-layer SubscriberHandler, enforcing the boundary at the type level. eventrouter.Router calls SubscribeEntry directly; Subscriber adapters (rabbitmq, eventbus) implement Subscribe directly.

type Subscription

type Subscription struct {
	// Topic is the broker routing key. Required.
	Topic string

	// ConsumerGroup is the logical consumer group identity. Required.
	// It forms the idempotency key namespace: "{ConsumerGroup}:{entry.ID}".
	// Subscribers sharing the same ConsumerGroup compete for messages;
	// different groups each receive a full copy (fanout).
	ConsumerGroup string

	// CellID is the observability owner label. Required.
	//
	// CellID is the single source of truth for the cell owning this
	// subscription — metrics, slog fields, and trace span attributes use it
	// to route subscriber telemetry to the right owner. The value is set by
	// codegen (contractgen NewSubscription / cellgen cell.tmpl) from the
	// cell's metadata.ID at compile time; there is no runtime fallback to
	// ConsumerGroup. ConsumerGroup is a broker partition key + idempotency
	// namespace, conceptually orthogonal to ownership.
	//
	// ref: ADR docs/architecture/202605111000-adr-subscription-cellid-mandatory.md
	CellID string

	// SliceID is an optional observability owner label. Codegen fills it
	// from slice.yaml contractUsages[role=subscribe] via cellgen when a cell
	// wants per-slice consumer metrics.
	SliceID string

	// ContractID/Kind/Transport identify the contract bound to this
	// subscription. They are intentionally primitive strings rather than a
	// contractspec.ContractSpec to keep kernel/outbox independent of
	// kernel/wrapper (wrapper already imports outbox for EntryHandler).
	//
	// Runtime eventrouter fills these fields for AddContractHandler
	// registrations so subscription middleware can install the contract span
	// outside ConsumerBase while still after observability metadata restore.
	ContractID        string
	ContractKind      string
	ContractTransport string
}

Subscription describes the full identity of a single subscription intent. It is the first-class object passed through the middleware chain, ensuring every layer (idempotency, observability, retry) sees Topic, ConsumerGroup, CellID, and SliceID without information loss at middleware boundaries.

ref: ThreeDotsLabs/watermill message/router.go handler context injection ref: MassTransit ConsumeContext — full identity traverses the entire pipeline

func (Subscription) IdempotencyNamespace

func (s Subscription) IdempotencyNamespace() string

IdempotencyNamespace returns the namespace prefix for idempotency keys. Keys are constructed as "{IdempotencyNamespace}:{entry.ID}".

func (Subscription) ObservabilityID

func (s Subscription) ObservabilityID() string

ObservabilityID returns the human-readable identifier used in logs and metrics.

Returns CellID unconditionally — Validate enforces CellID is non-empty, so the empty string only surfaces when the caller bypassed Validate (e.g. constructing a literal in a test fixture). There is no fallback to ConsumerGroup: substituting ConsumerGroup would mask a codegen defect (the cell metadata → reg.Subscribe positional parameter chain failed to populate the field), so the empty value is intentionally surfaced.

ref: ADR docs/architecture/202605111000-adr-subscription-cellid-mandatory.md

func (Subscription) Validate

func (s Subscription) Validate() error

Validate returns an error when required fields are missing.

Subscription.Validate is the SINGLE source of truth for subscription-shape invariants. Subscribe-time decorators (e.g. ContractTracingSubscriber) call it once at the entry point instead of duplicating the checks downstream (N8 (c) — collapsed the previous parallel check inside MustWrapSubscriber).

type SubscriptionMiddleware

type SubscriptionMiddleware func(sub Subscription, next EntryHandler) EntryHandler

SubscriptionMiddleware is the event-consumer middleware type. It carries the full Subscription identity (Topic + ConsumerGroup + CellID), so middleware can route, log, and observe per-subscription rather than per-topic.

Apply in order: [0] is outermost, [len-1] is innermost.

The next handler is EntryHandler (business signature). Middleware authors work entirely in the business domain and do not see Settlement — it is an internal concern of the Subscriber layer (adapters/rabbitmq, runtime/eventbus). ConsumerBase is field-injected into SubscriberWithMiddleware and applied as an explicit EntryHandler→SubscriberHandler conversion boundary after the business middleware chain, not as a SubscriptionMiddleware entry.

ref: ThreeDotsLabs/watermill message/router.go — HandlerMiddleware operates on Message→Message; Ack/Nack is the router's exclusive responsibility. ref: go-kratos/kratos middleware — middleware operates on Request→Reply; gRPC status (settle equivalent) is the transport's exclusive responsibility.

type Writer

type Writer interface {
	// Write persists an outbox entry atomically with the caller's business
	// state. Write MUST be invoked from within an active transaction; the
	// implementation extracts the tx from ctx via TxFromContext(ctx). Calling
	// Write outside of a persistence.TxRunner.RunInTx scope is a programming
	// error — implementations return an errcode error with KindInternal
	// (e.g. adapters/postgres returns ErrAdapterPGNoTx) rather than silently
	// writing without transactional guarantees.
	//
	// ref: nikolayk812/pgx-outbox writer.go -- explicit tx parameter +
	// ErrTxNil guard for the same MUST-have-tx contract.
	Write(ctx context.Context, entry Entry) error
}

Writer writes outbox entries within a transaction. The implementation MUST ensure the outbox write is atomic with the business state write (same DB transaction).

type WriterEmitter

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

WriterEmitter emits by writing entries to the transactional outbox.

func NewWriterEmitter

func NewWriterEmitter(w Writer) (*WriterEmitter, error)

NewWriterEmitter adapts an outbox Writer into an Emitter.

func (*WriterEmitter) Durable

func (e *WriterEmitter) Durable() bool

Durable reports whether this WriterEmitter is backed by a real (non-noop) outbox.Writer. NoopWriter and any writer that advertises Noop()==true are considered non-durable; anything else is durable.

func (*WriterEmitter) Emit

func (e *WriterEmitter) Emit(ctx context.Context, entry Entry) error

Emit writes the entry to the underlying transactional outbox. The relay then publishes it asynchronously, so durability is decoupled from the caller's success path. Returns ErrCellMissingOutbox when the writer is nil — a programmer error that should surface at construction time.

Directories

Path Synopsis
Package outboxtest provides a reusable conformance test suite for outbox.Publisher and outbox.Subscriber implementations.
Package outboxtest provides a reusable conformance test suite for outbox.Publisher and outbox.Subscriber implementations.

Jump to

Keyboard shortcuts

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