Documentation
¶
Overview ¶
Package port defines the interfaces the agent loop consumes — the ports of the hexagonal architecture. Adapters implement these and depend inward on the domain; the loop targets only these interfaces. Each port has a fake in internal/adapter so the loop is unit-testable with no network and no disk.
Allowed imports (ARCHITECTURE.md §3): the standard library (context, io, time, iter, encoding/json) and the domain packages (session, tool, prompt, governance). Nothing else — no adapter, agent, api, os, or third-party import.
Note (cycle resolution): FileSystem, Workspace, and Environment are intentionally NOT defined here. They live in engine/tool, the context that owns them, because port already imports tool (LLMRequest.Tools is []tool.ToolSpec) and Tool.Execute takes an Environment — defining it here would create a port↔tool cycle.
Package port — cycle note: LLMRequest references tool.ToolSpec and prompt.Layered, so port imports tool and prompt (and session, governance). FileSystem/Workspace/Environment deliberately live in engine/tool, not here, to avoid a port↔tool import cycle (Tool.Execute takes an Environment).
Index ¶
- Constants
- Variables
- func CompareSessionMetadataOrder(a, b SessionDiscoveryMeta) int
- func DecodeCursor(cur Cursor, id session.SessionID, currentGeneration string) (position string, err error)
- func ObserveAttempt(ctx context.Context, observation session.NetworkAttemptPayload)
- func RouteToolResultParts(tr session.ToolResult, caps ProviderCapabilities) []session.Content
- func RunSerialFromContext(ctx context.Context) (int64, bool)
- func SessionDiscoveryMetaEqual(a, b SessionDiscoveryMeta) bool
- func SessionIDFromContext(ctx context.Context) (session.SessionID, bool)
- func SetAttemptTurnIndex(ctx context.Context, index int) bool
- func TurnIndexFromContext(ctx context.Context) (int, bool)
- func ValidateSessionLineageQuery(query SessionLineageQuery) error
- func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context
- func WithRunAttemptContext(ctx context.Context, id session.SessionID, serial int64) context.Context
- func WithRunSerial(ctx context.Context, serial int64) context.Context
- func WithSessionID(ctx context.Context, id session.SessionID) context.Context
- func WithTurnIndex(ctx context.Context, index int) context.Context
- type AttemptObserver
- type AuthorityDecision
- type AuthorityEvaluator
- type AuthorityOwnerRequirement
- type AuthorityPrincipal
- type AuthorityRequest
- type AuthorityResource
- type AuthorityResourceKind
- type Chunk
- type ChunkKind
- type Clock
- type ConditionalPrunableStore
- type Cursor
- type CursorEventLog
- type DeliveryNote
- type DeliveryQueue
- type Diagnostics
- type EventLog
- type EventSink
- type HookApprovalLearner
- type HookRunner
- type LLMProvider
- type LLMRequest
- type Lease
- type Level
- type LogRecord
- type LogRecordKind
- type MetaLister
- type MisfirePolicy
- type NopDeliveryQueue
- type NopDiagnostics
- type PermanentError
- type PermissionPolicy
- type PermissionStore
- type ProviderCapabilities
- type ProviderErrorMetadataError
- type PrunableStore
- type ReadOptions
- type RetryDisposition
- type RetryDispositionError
- type Schedule
- type ScheduleCreator
- type ScheduleFire
- type ScheduleManager
- type ScheduleOneShotReArmer
- type ScheduleProviderSelector
- type ScheduleSpec
- type ScheduleState
- type ScheduleStore
- type SessionCreator
- type SessionDeleteSupport
- type SessionDiscoveryMeta
- type SessionLease
- type SessionLineageQuery
- type SessionLineageReader
- type SessionLineageRecord
- type SessionLineageResult
- type SessionLineageState
- type SessionLiveness
- type SessionMeta
- type SessionMetadataCursor
- type SessionMetadataPage
- type SessionMetadataPageRequest
- type SessionMetadataPager
- type SessionMigrationError
- type SessionMigrationFamily
- type SessionMigrationInspection
- type SessionMigrationJob
- type SessionMigrationState
- type SessionMigrationStore
- type SessionStorageHealth
- type SessionStorageHealthProvider
- type SessionStore
- type StoredSession
- type StreamProgress
- type StreamProgressError
- type ToolCallRecorder
- type TriggerKind
- type TriggerSpec
Constants ¶
const ( RetryDispositionUnknown = session.RetryDispositionUnknown RetryDispositionRetryable = session.RetryDispositionRetryable RetryDispositionPermanent = session.RetryDispositionPermanent )
RetryDispositionUnknown and its siblings alias the session vocabulary.
const ( StreamProgressUnknown = session.StreamProgressUnknown StreamProgressPrecommit = session.StreamProgressPrecommit StreamProgressVisible = session.StreamProgressVisible StreamProgressComplete = session.StreamProgressComplete )
StreamProgressUnknown and its siblings alias the session vocabulary.
const MaxSessionLineageRecords = 256
MaxSessionLineageRecords is the largest direct-related result a reader may return.
const PendingFireSessionID session.SessionID = "pending"
PendingFireSessionID is the single-source sentinel Claim stamps on ScheduleState.LastFireSessionID — the placeholder the caller overwrites via RecordFire with the real fire's session id. It is non-empty so the singleton check has a recognisable "in-flight" marker (a Claim has happened but RecordFire has not).
Singleton interaction: the singleton overlap check is a TRIAL lease acquire on LastFireSessionID (the authoritative cross-replica liveness oracle). The pending sentinel is NOT a real session id, so it cannot be probed by the trial lease. The scheduler therefore does NOT perform the overlap check when LastFireSessionID == PendingFireSessionID: it PROCEEDS with the fire. This is deliberate. The alternative — treat pending as "in-flight, so SKIP the fire" — would wedge a schedule FOREVER after a hard crash between Claim and RecordFire: the sentinel would never clear (a skip does not Claim, so it never advances), and the schedule would be skipped on every subsequent tick. Proceeding instead accepts a NARROW double-fire window (a fire genuinely still inside its short Claim→RecordFire window when the slot next becomes due — normally impossible, since Claim advances NextFireAt past `now`) in exchange for crash-recoverability. A real (non-pending) LastFireSessionID IS probed and the overlap check applies.
Promoting it to one port constant (the same single-source discipline as SchedulerLeaderLeaseID / MemberSessionID) means the three store adapters and the scheduler agree on the exact string by importing it, not by independently declaring a byte-for-byte mirror.
const SchedulerLeaderLeaseID session.SessionID = "__scheduler__"
SchedulerLeaderLeaseID is the well-known session id the scheduler acquires a leader lease on (a SessionLease keyed by this id) so that, in a multi-replica deployment, at most one replica ticks the schedule store at a time (decision #10). It is the `id` argument to SessionLease.Acquire — the lease key — NOT the owner (the owner is the per-process identity string composition builds, exactly as the run-entry seam does for session leases).
It is hygiene, NOT correctness: SessionID is an unvalidated string (the aggregate never inspects its value), the k8slease adapter hashes arbitrary ids, and real session ids are hex — so this sentinel cannot collide with a genuine session. A deployment that prefers a distinct leader id passes its own id through composition (a Build field), never by mutating this constant.
Variables ¶
var ( // ErrSessionNotFound is the port-level sentinel a SessionStore.Load wraps // (with %w) when no session is stored under the requested id — distinct // from a genuine infrastructure failure (I/O error, decode failure). It lets // a consumer in a layer that may NOT import the store adapters (e.g. // engine/agent's InspectMemberTool) distinguish "no such session" from "the // store is broken" via errors.Is, without reaching for an adapter's own // not-found sentinel. Every SessionStore adapter MUST wrap this for the // not-found case. ErrSessionNotFound = errors.New("port: session not found") // ErrSessionAlreadyExists is wrapped by SessionCreator.Create when an // authoritative snapshot already exists under the requested session id. ErrSessionAlreadyExists = errors.New("port: session already exists") )
var ErrCursorExpired = errors.New("port: event-log cursor expired")
ErrCursorExpired is returned when a cursor's generation does not match the log's current generation — the log was deleted and recreated, or otherwise had its positional basis replaced.
The contract this exists to enforce is NEVER SILENTLY WRONG. A positional cursor against a rebuilt log does not fail: it happily points at a real position holding a DIFFERENT event, and the consumer resumes from the wrong place with no error to notice. Generation-scoping converts that silent corruption into a loud, recoverable one — the consumer restarts from the beginning or reloads the transcript.
var ErrCursorMalformed = errors.New("port: malformed event-log cursor")
ErrCursorMalformed is returned when a cursor cannot be decoded at all — bad base64, bad JSON, an unknown envelope version, or a position the backend cannot align to a record boundary.
It is deliberately DISTINCT from ErrCursorExpired. Expired means "your position was valid but the log's basis moved underneath you"; malformed means "this is not a cursor I issued". A client can retry an expired cursor by restarting from the beginning; a malformed one indicates a bug or tampering and restarting silently would hide it.
var ErrFireNowOverlap = errors.New("port: fire-now skipped (prior fire still running)")
ErrFireNowOverlap is the port-level sentinel a ScheduleManager's FireNow returns (wrapped with %w) when the schedule's singleton guard found a prior fire still running — the manual fire is REJECTED, not run concurrently with the in-flight one (the create-seam's default-true Singleton holds through every surface: the REST/gRPC FireNow AND the model-facing Schedule tool). A consumer in a layer that may NOT import the scheduler adapter (e.g. engine/agent, a test asserting the tool surfaces the rejection) distinguishes "overlapping fire, try again later" from a genuine failure via errors.Is.
var ErrInvalidSessionLineageQuery = errors.New("port: invalid session lineage query")
ErrInvalidSessionLineageQuery marks an invalid or over-limit lineage query.
var ErrLeaseHeld = errors.New("port: session lease held by another owner")
ErrLeaseHeld is the sentinel a SessionLease returns (wrapped with %w) when the requested session lease is currently held by a DIFFERENT, still-live owner: an Acquire that cannot take over, or a Renew/Release whose caller no longer holds the lease (it expired and was taken, or was released). It is a TRANSIENT condition, NOT sticky — the holder may release or its lease may lapse, after which a later Acquire succeeds. A consumer maps it to a "leased elsewhere" refusal (the run-entry gate) and, on a Renew, to "we lost the lease" (the renewer cancels the run). Distinct from ErrLeaseUnsupported, which says the seam will never work here at all.
var ErrLeaseUnsupported = errors.New("port: session leasing not supported by this backend")
ErrLeaseUnsupported is the sentinel a SessionLease returns (wrapped with %w) when the lease BACKEND cannot lease at all — e.g. a remote driver answering UNIMPLEMENTED. It is the "this seam will never work here" signal, distinct from a transient infrastructure failure (I/O error, timeout) and from ErrLeaseHeld (a live competitor). A consumer that sees it via errors.Is should stop consulting the seam: the composition layer's run-entry gate logs one INFO and stickily disables leasing for the process, degrading to the byte-identical no-lease path. This mirrors ErrPruneUnsupported's sticky-disable contract for the retention seam.
var ErrPruneUnsupported = errors.New("port: store does not support retention pruning")
ErrPruneUnsupported is the port-level sentinel a PrunableStore's List or Delete wraps (with %w) when the store's BACKEND cannot enumerate/delete sessions at all — e.g. a remote driver answering UNIMPLEMENTED. It is the "this seam will never work here" signal, distinct from a transient infrastructure failure (I/O error, timeout): a consumer that sees it via errors.Is should stop consulting the seam (the composition layer's child-session GC logs one INFO and stickily disables further sweeps), whereas any other error is retried on the next sweep.
var ErrScheduleAlreadyExists = errors.New("port: schedule already exists")
ErrScheduleAlreadyExists is the sentinel a ScheduleCreator returns (wrapped with %w) when Create is called against a name that already has a schedule. It is the atomic-create counterpart of the manager's pre-existing check-then-Save duplicate-name error (schedule_manager.go's "a schedule named %q already exists"), distinct from an infrastructure failure.
var ErrScheduleNotFound = errors.New("port: schedule not found")
ErrScheduleNotFound is the port-level sentinel a ScheduleStore wraps (with %w) in Load/Delete/Claim/RecordFire/LoadFire when no schedule (or fire) exists under the requested name/id — distinct from a genuine infrastructure failure (I/O error, decode failure). It mirrors ErrSessionNotFound: a consumer in a layer that may NOT import the store adapters distinguishes "no such schedule" from "the store is broken" via errors.Is. Every ScheduleStore adapter MUST wrap this for the not-found case.
var ErrScheduleUnsupported = errors.New("port: scheduled tasks not supported by this backend")
ErrScheduleUnsupported is the sentinel a ScheduleStore returns (wrapped with %w) when the backend cannot store schedules at all — e.g. a remote driver answering UNIMPLEMENTED, or an in-memory default that opts out. It is the "this seam will never work here" signal, distinct from a transient infrastructure failure (I/O error, timeout): a consumer that sees it via errors.Is should stop consulting the seam (composition logs one INFO and stickily disables scheduling for the process, degrading to the byte-identical no-schedule path). This mirrors ErrLeaseUnsupported / ErrPruneUnsupported's sticky-disable contract.
var ErrSessionLineageUnsupported = errors.New("port: store does not support session lineage")
ErrSessionLineageUnsupported marks a store without durable lineage support.
var ErrSessionMetadataCursorRestart = errors.New("port: session metadata cursor requires restart")
ErrSessionMetadataCursorRestart reports that a metadata cursor no longer identifies the same generation and owner/filter scope. Callers must discard the cursor and restart at page one; adapters never continue across the mismatch.
var ErrSessionMetadataPagingUnsupported = errors.New("port: store does not support session metadata paging")
ErrSessionMetadataPagingUnsupported is returned by a metadata pager whose backend cannot enumerate bounded inventory pages. It is a permanent capability posture, distinct from a transient storage failure.
Functions ¶
func CompareSessionMetadataOrder ¶
func CompareSessionMetadataOrder(a, b SessionDiscoveryMeta) int
CompareSessionMetadataOrder is the shared (ModifiedAt DESC, ID ASC) ordering every SessionMetadataPager/MetaLister implementation sorts session inventory rows by. It returns a negative number when a sorts before b, zero when the two share the same order key, and a positive number when a sorts after b.
func DecodeCursor ¶
func DecodeCursor(cur Cursor, id session.SessionID, currentGeneration string) (position string, err error)
DecodeCursor recovers the position from cur, verifying BOTH that the cursor was issued for this session and that its generation matches the log's current one.
The ZERO cursor decodes to the zero position with no error — "the beginning" is a legitimate request, and forcing every backend to special-case it before calling here would put the same branch in four places.
SESSION SCOPING IS ENFORCED HERE, not left to each backend. A position is only meaningful relative to one log, so a cursor issued for session A applied to session B must fail. Checking it in the port rather than per backend is what makes the guarantee structural: a backend cannot forget it, and a new backend inherits it. It also closes the case a generation check CANNOT: a legacy log predating generations reports the EMPTY generation, so two legacy logs share a basis value and a cross-session cursor would decode cleanly and resolve to a real — but wrong — record. Found by review on #868; the mismatch is ErrCursorMalformed rather than ErrCursorExpired because nothing moved underneath the caller: the cursor was never valid here, which is a bug or tampering, not staleness.
A generation mismatch is ErrCursorExpired. Passing an EMPTY currentGeneration disables THAT check, which is what a backend with no generation basis (a legacy log written before generations existed) needs — such a log's cursors carry an empty generation too, so the comparison holds without a special case. The session check above is never disabled.
func ObserveAttempt ¶
func ObserveAttempt(ctx context.Context, observation session.NetworkAttemptPayload)
ObserveAttempt sends producer-controlled attempt evidence to the observer on ctx, when one is installed. The loop-side observer is responsible for canonical validation before emission.
func RouteToolResultParts ¶
func RouteToolResultParts(tr session.ToolResult, caps ProviderCapabilities) []session.Content
RouteToolResultParts is the composition-driven, capability-gated PROJECTION of a recorded tool result's typed blocks for the model-facing request. It returns the subset of tr.Parts the (provider, model) — described by caps, the SINGLE composition-computed capability intersection (catalog ∩ adapter) — may receive as typed blocks, or nil when no block survives (the caller degrades to the recorded model-facing Content string).
SEAM. This lives in engine/port (not internal/app) for two reasons:
- The provider adapters (provider/openai, provider/anthropic) consume the projection at their RoleTool case; they may import engine/port and engine/session but MUST NOT import internal/app (composition). Composition may import them. Placing the helper here lets BOTH call it without inverting the layering.
- caps is already a port.ProviderCapabilities (the neutral intersection type modelCapability computes), and session.ToolResult is the recorded value object — both live below port, so the function depends only on its own package's existing imports (port already imports session).
READ-ONLY PROJECTION (Risk #1). This MUST NOT mutate the recorded *session.ToolResult. It builds a FRESH slice of the surviving blocks; the input tr (and tr.Parts) is never touched. The recorded history, the client stream, and the model view stay identical (the effective-payload guarantee / gauntlet-#7). The provider computes the projection on the fly when building its request message; nothing is written back into the session.
CAPABILITY GATING. A block survives when the (provider, model) can receive it:
- BlockImage survives iff caps.Image
- BlockAudio survives iff caps.Audio
- BlockText, BlockResourceLink, BlockEmbeddedResource, BlockStructuredContent survive (text-summarised, no modality gate) UNLESS they render to EMPTY text — see the empty-render rule below.
EMPTY-RENDER RULE. A text-summarised block whose rendered text (session.ToolBlockText) is the empty string is DROPPED. Strict providers reject an empty text content block on the wire: Moonshot via OpenRouter (POST /responses) 400s the WHOLE request with "Invalid request: text content is empty", and the Anthropic Messages API rejects "text content blocks must be non-empty". Because the LLM adapters replay full history STATELESSLY, a single empty-text tool-result block (e.g. an MCP fetch past the end of a document that returned one empty text block) poisons EVERY subsequent request and permanently bricks the session. Dropping it here — a REQUEST-TIME projection, never a history rewrite — heals an already-poisoned persisted session on replay while leaving the recorded history untouched (the read-only-projection discipline above). If ALL blocks drop, the len(out)==0 → nil return routes the caller to the single-string Content fallback.
CALLER CONTRACT. When you degrade to tr.Content (this returns nil) and that string is ITSELF empty, you MUST substitute a non-empty deterministic placeholder before putting it on the wire — an empty string on the wire reproduces the exact strict-provider rejection this rule exists to prevent. The in-tree adapters use "(tool returned no output)".
The rule is EXACT-EMPTY (session.ToolBlockText(b) == ""): a whitespace-only block is deliberately NOT dropped. Widening to whitespace-trimming would be a second behavior change on this published surface and waits for evidence a provider actually rejects whitespace-only text.
A legacy media part (BlockKind == "", the user-message media shape that never rides on a tool result) is left to the user-message media path and dropped here — it is not a tool-result block.
AUDIENCE IS NEVER A FILTER (CWE-345). Content.Audience is UNTRUSTED server self-attestation carried for advisory display routing ONLY. It MUST NEVER suppress model-facing content and NEVER gates access control; enforcement lives in the permission layer. This function does not read Audience at all, so a ["user"]-audience block still passes to the model exactly as a [] block does.
func RunSerialFromContext ¶
RunSerialFromContext returns the process-local run serial carried by ctx.
func SessionDiscoveryMetaEqual ¶
func SessionDiscoveryMetaEqual(a, b SessionDiscoveryMeta) bool
SessionDiscoveryMetaEqual reports whether two inventory rows represent the same durable cleanup precondition. Owner identity is compared semantically.
func SessionIDFromContext ¶
SessionIDFromContext returns the session identity carried by ctx.
func SetAttemptTurnIndex ¶
SetAttemptTurnIndex updates the turn on the nearest carrier installed by WithRunAttemptContext and reports whether such a carrier exists. One run-loop goroutine writes at turn boundaries; concurrent reads are race-safe.
func TurnIndexFromContext ¶
TurnIndexFromContext returns the zero-based turn index carried by ctx.
func ValidateSessionLineageQuery ¶
func ValidateSessionLineageQuery(query SessionLineageQuery) error
ValidateSessionLineageQuery applies the shared query bounds.
func WithAttemptObserver ¶
func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context
WithAttemptObserver returns a child context carrying a run-local attempt observer. The observer grants no authority. Its input is producer-controlled; consumers must validate it before constructing an event or durable record.
func WithRunAttemptContext ¶
WithRunAttemptContext returns an independent, run-owned correlation context. Session and run identities are immutable for its lifetime. The engine may update its turn with SetAttemptTurnIndex until that run ends; the context must not be reused for another run. Atomic turn access permits concurrent reads by provider iterator and diagnostics goroutines.
func WithRunSerial ¶
WithRunSerial returns a child context carrying the process-local serial of the run that owns a model call. The value is diagnostic correlation only: it is bounded to one int64, grants no authority, and is not durable across restarts.
func WithSessionID ¶
WithSessionID returns a child context carrying the exact identity of the session that owns model calls made with that context. It grants no authority.
Types ¶
type AttemptObserver ¶
type AttemptObserver func(session.NetworkAttemptPayload)
AttemptObserver receives producer-controlled provider-attempt evidence. It is a run-local bridge: adapters observe, while the agent loop validates the whole payload and remains the sole event producer; the server relay remains the sole durable-log writer.
type AuthorityDecision ¶
AuthorityDecision is an evaluator's authorization result. A denied decision is a successful evaluation; an unavailable evaluator returns an error so the call site can fail closed and report that condition separately.
type AuthorityEvaluator ¶
type AuthorityEvaluator interface {
AuthorizeTool(context.Context, AuthorityRequest) (AuthorityDecision, error)
}
AuthorityEvaluator authorizes one tool execution against a carried capability set. Implementations may only tighten the set's authority; they must not use external policy to grant a tool the set omits.
type AuthorityOwnerRequirement ¶
type AuthorityOwnerRequirement interface {
RequiresOwnerIdentity() bool
}
AuthorityOwnerRequirement is an optional AuthorityEvaluator capability for evaluators whose policy cannot safely authorize an ownerless request. Evaluators that do not implement it must accept an absent owner identity when their other request requirements are met.
type AuthorityPrincipal ¶
type AuthorityPrincipal struct {
Definition string
Instance string
OwnerIssuer string
OwnerSubject string
}
AuthorityPrincipal identifies the authority under which a tool is requested. Definition is the resolved agent-definition identity, Instance identifies this concrete run, and OwnerIssuer and OwnerSubject are the exact verified caller identity pair that owns it. They are distinct from the capability set and intentionally contain no credentials.
type AuthorityRequest ¶
type AuthorityRequest struct {
CapabilitySet governance.CapabilitySet
ToolName string
Action string
DelegationDepth int
Principal AuthorityPrincipal
Resource *AuthorityResource
}
AuthorityRequest is the provider-neutral input to AuthorityEvaluator. The carried capability set is the complete local authority fact; evaluators do not look it up from mutable state. ToolName selects the carried capability while Action identifies the operation the caller requested. They differ for derived capabilities such as a named MCP server's resources. DelegationDepth provides additional action context; Principal carries attribution without credentials or raw tool arguments. Resource is present only when the execution boundary can derive an unambiguous non-secret local target.
type AuthorityResource ¶
type AuthorityResource struct {
Kind AuthorityResourceKind
Path string
Workspace string
}
AuthorityResource is a normalized, non-secret target derived at the execution boundary. Path is a clean absolute local path and Workspace identifies the current session workspace that supplied it; neither is raw tool-call JSON nor an adapter-specific resource identifier.
type AuthorityResourceKind ¶
type AuthorityResourceKind string
AuthorityResourceKind identifies the non-secret target class in an authority request. It is provider-neutral: evaluators cannot depend on a tool's raw argument format to identify a resource.
const ( // AuthorityResourceWorkspaceFile is a normalized local target associated with // the executing session's workspace. AuthorityResourceWorkspaceFile AuthorityResourceKind = "workspace_file" )
type Chunk ¶
type Chunk struct {
// Kind discriminates the payload.
Kind ChunkKind
// Text carries the assistant text on ChunkText, the human-readable reasoning
// summary on ChunkReasoning (display-only), the provider's opaque reasoning
// replay blob on ChunkReasoningItem (e.g. OpenAI encrypted_content or Anthropic
// (thinking,signature); never displayed), and the provider's opaque phase
// marker on ChunkPhase (stored on Message.Phase, replayed verbatim).
Text string
// ReasoningItemID is set on ChunkReasoningItem. It carries the provider's
// opaque reasoning ITEM id (e.g. OpenAI's "rs_…" id on a reasoning output
// item), which the loop stores on Message.ReasoningItemID and the adapter
// stamps back verbatim on subsequent stateless calls: the OpenAI Responses
// reasoning item's id field is api:"required" with no omitzero, so a replay
// without the captured id serialises "id":"" and strict OpenAI-compatible
// gateways reject it (HTTP 400). Like the Text blob it accompanies, it is
// never displayed, interpreted, or validated — the STRUCTURE is neutral
// (one opaque id per reasoning item), the CONTENTS are provider-private.
// Same discipline as ToolCall.ItemID and the ChunkPhase carrier.
ReasoningItemID string
// ToolCall is set on ChunkToolCall.
ToolCall *session.ToolCall
// Usage is set on ChunkUsage.
Usage *session.Usage
// Stop is set on ChunkDone.
Stop session.StopReason
}
Chunk is a single provider-neutral unit of a model stream. The loop assembles a sequence of Chunks into a domain Message. The OpenAI Responses specifics (function_call items, reasoning items, SSE framing, cache accounting) live entirely inside the openai adapter; the loop never sees a provider type.
type ChunkKind ¶
type ChunkKind int
ChunkKind is the kind of a streamed Chunk.
const ( // ChunkText is an assistant text delta. ChunkText ChunkKind = iota // ChunkReasoning is a human-readable reasoning summary delta. It is // DISPLAY-ONLY: clients render it for visibility into the model's thinking; // it is NOT the blob replayed to the provider. (See ChunkReasoningItem.) // // DTO-neutrality note (multi-provider Finding C): the ChunkReasoning (display // summary) vs ChunkReasoningItem (replay blob) split is the PROVIDER-NEUTRAL // seam P1 (the native Anthropic adapter) validates — Anthropic's thinking delta // maps to ChunkReasoning, its (thinking,signature) replay token to // ChunkReasoningItem. Keep the split; do not collapse the two. ChunkReasoning // ChunkReasoningItem carries the provider's opaque reasoning REPLAY blob // (e.g. OpenAI's reasoning-item encrypted_content, or Anthropic's // (thinking,signature) pair), emitted once the reasoning output item is // assembled. The Text field holds the opaque blob, which the loop stores on // Message.Reasoning and the adapter sends back verbatim on subsequent stateless // calls. It is never displayed or interpreted — the contents are provider- // private; only the STRUCTURE (one opaque blob per message) is neutral. The // item's provider id rides ReasoningItemID alongside it (stored on // Message.ReasoningItemID); a provider with no per-item id (Anthropic) leaves // it empty. ChunkReasoningItem // ChunkToolCall is a fully-assembled tool call, emitted once complete. ChunkToolCall // ChunkUsage is the terminal usage/cache accounting. ChunkUsage // ChunkDone is the end of stream; it carries the StopReason. ChunkDone // ChunkPhase carries the provider's opaque PHASE marker on Text (mirrors // ChunkReasoningItem). The OpenAI Responses API tags an assistant output // message as intermediate commentary or the final answer; for store:false // manual-replay apps the phase must be preserved and resent on the assistant // message item, or GPT-5.x models treat preambles as final answers / stop // early. The loop stores it on Message.ProviderPhase and the adapter sends it back // verbatim on subsequent stateless calls. Like ChunkReasoningItem it is never // displayed or interpreted — the STRUCTURE is neutral (one opaque phase string // per message), the CONTENTS are provider-private (the harness never branches // on or validates the value). Placed LAST in the block — chunks are in-process // only, never serialized as ints, so ordinal stability is not a concern. ChunkPhase // ChunkProviderRoute carries the opaque DOWNSTREAM provider display label that // actually served a routed request on Text (issue #480). It is emitted by the // OpenAI adapter ONLY when OpenRouter metadata is explicitly armed, at the // terminal response.completed event. Like ChunkPhase it is DISPLAY-ONLY and // provider-private in CONTENTS: the loop relays it verbatim onto an // EvProviderRoute event for clients, never branches on it, never replays it, // never anchors TTFT or feeds usage. It is absent on a cache hit (OpenRouter // strips openrouter_metadata from cached responses) — the loop simply emits // nothing. The STRUCTURE is neutral (one opaque label per turn); mecatl's // "provider" stays the wire adapter — this is the DOWNSTREAM inference provider // OpenRouter routed to. The label is human-readable and is not a routing slug or // round-trippable identifier. Same discipline as ChunkPhase. ChunkProviderRoute )
type Clock ¶
Clock abstracts the wall clock so the loop and stores are deterministically testable. Adapters provide a real clock and a fake.
type ConditionalPrunableStore ¶
type ConditionalPrunableStore interface {
DeleteSessionIfUnchanged(ctx context.Context, expected SessionDiscoveryMeta) (bool, error)
}
ConditionalPrunableStore is the OPTIONAL atomic cleanup seam. Implementations acquire their session-family mutation exclusion, compare the complete expected metadata row with the current durable row, and keep that exclusion held through sidecar-first/snapshot-last deletion. A mismatch or missing row returns false without mutation; backend failures return an error.
type Cursor ¶
type Cursor string
Cursor is an OPAQUE resume token meaning "you have durably received every record up to and including this position".
Opaque is a promise, not an implementation detail. The encoding is stateless — a server-side cursor registry was rejected in ADR 0250 because it would need eviction and a cloud-inventory row to buy nothing — which does make a cursor inspectable by a determined client. That is exactly why the generation is inside it: a client that decodes one, hand-edits it, and passes it back gets ErrCursorExpired or ErrCursorMalformed rather than silently wrong data. Treat the value as bytes to hand back, nothing more.
The ZERO cursor means THE BEGINNING OF THE LOG. That is a real value, not a missing one: a consumer attaching for the first time has no cursor, and "replay everything, then follow" is the common case rather than an edge.
func EncodeCursor ¶
EncodeCursor builds an opaque cursor from a backend's generation and position.
It lives in port rather than in each adapter so all four backends agree byte-for-byte on the envelope, and so tamper rejection has ONE implementation to test rather than four to keep in sync.
base64url (unpadded) keeps a cursor safe in a URL path, a query parameter, a JSON string, and an HTTP header without escaping — it is carried by all of those before it reaches a backend.
type CursorEventLog ¶
type CursorEventLog interface {
EventLog
// AppendEvent durably records ev and returns the cursor positioned after it.
//
// It carries the SAME durability obligation as EventLog.Append — nil means
// the record is on stable storage — and the same at-most-once, no-retry
// contract. It exists alongside Append rather than replacing it because
// EventLog is a shipped port with consumers that do not want a cursor.
AppendEvent(ctx context.Context, id session.SessionID, ev session.Event) (Cursor, error)
// AppendGap durably records a gap marker at the next position.
//
// This is the best-effort, cross-process tier of ADR 0250's three-tier
// append-gap guarantee: when an append fails, one gap marker is attempted, and
// if it lands then every watcher everywhere learns of the gap deterministically
// rather than silently skipping it. It covers the LIKELY failure — one
// rejected or unencodable record — and not a total backend outage, which by
// construction cannot record its own failure.
AppendGap(ctx context.Context, id session.SessionID, reason string) (Cursor, error)
// ReadAfter yields the log's records STRICTLY AFTER the given cursor.
//
// The zero cursor starts from the beginning. A cursor from a superseded log
// generation yields ErrCursorExpired; one that cannot be decoded or aligned
// yields ErrCursorMalformed. Neither is ever coerced to a position — a cursor
// that cannot be honoured exactly must fail, because resuming from
// approximately the right place is indistinguishable from resuming from the
// right place until data is already lost.
//
// As with EventLog.Read, an error is yielded on a zero-value record and the
// iterator then stops, a miss is an empty sequence rather than an error, and
// resources are released when the consumer breaks out early.
ReadAfter(ctx context.Context, id session.SessionID, after Cursor, opts ReadOptions) iter.Seq2[LogRecord, error]
}
CursorEventLog is EventLog plus durable positions: append returns where the record landed, and reads resume from a position rather than always from the start.
It is ADDITIVE (ADR 0250). EventLog is unchanged and unbroken, and a backend opts in by also implementing this interface. A backend that does not is NOT silently degraded to "replay the whole log every time" — the watch operation reports the feature as unsupported, because a client asking to resume from a position and being handed the entire transcript instead is a correctness problem dressed as a performance one.
CONCURRENCY: the same contract as EventLog — safe for concurrent use across session ids, with per-id append order well-defined for a single session's serialised appends. Additionally, a Follow read must tolerate appends happening concurrently on the same id; that is its whole purpose.
type DeliveryNote ¶
type DeliveryNote struct {
// Seq is the monotonic per-session sequence assigned at Enqueue time. It is
// the exactly-once ledger key: the drain records MarkDelivered(origin, seq)
// so a note is delivered exactly once, and the seq survives the run it was
// queued during (the ledger is session-scoped).
Seq uint64
// SessionID is the ORIGIN session the note is destined for (the queue's key).
SessionID session.SessionID
// Text is the opaque rendered note (the renderer's output, stored verbatim).
Text string
// EnqueuedAt is when the note was enqueued (for diagnostics/backlog
// observability; NOT a ledger key).
EnqueuedAt time.Time
}
DeliveryNote is one pending-delivery entry: the opaque rendered note text plus the monotonic per-session sequence that is the exactly-once ledger key. The text is ALREADY rendered (fenced-untrusted, clamped) by the time it arrives here; the queue stores it verbatim and does not re-render.
type DeliveryQueue ¶
type DeliveryQueue interface {
// Enqueue appends a rendered note for the origin session, assigning the
// next monotonic per-session seq. It returns the assigned note (the caller
// may surface its seq to the fire path). When the pending backlog for the
// origin exceeds the configured cap, the OLDEST pending note is DROPPED
// (with a WARN via the injected Diagnostics) rather than growing
// unboundedly on an overloaded origin; the cap 0 means UNBOUNDED (no drop).
// Enqueue is NOT idempotent: each call mints a fresh seq and a fresh note.
Enqueue(ctx context.Context, origin session.SessionID, text string) (DeliveryNote, error)
// Pending returns the origin's not-yet-delivered notes in ENQUEUE order
// (oldest first). The drain reads this at the origin's next turn boundary
// / run-entry and records each via MarkDelivered. A miss (no notes for the
// origin) returns an empty slice, not an error: absence is data.
Pending(ctx context.Context, origin session.SessionID) ([]DeliveryNote, error)
// MarkDelivered records that seq was delivered to the origin, removing it
// from the pending set. It is IDEMPOTENT: a re-mark of an already-delivered
// (or unknown) seq is a no-op success, never an error. This is the
// exactly-once ledger: a seq marked delivered is never re-delivered.
MarkDelivered(ctx context.Context, origin session.SessionID, seq uint64) error
}
DeliveryQueue is the DURABLE per-session pending-delivery queue for scheduled-task fire results (ADR 0075, fire-result-delivery Scenario 4). It is keyed on the ORIGIN session id — the session that created the schedule — NOT on a per-Run registry (which dies with its run). A note queued during one run is drained on the origin's NEXT run-entry if the current run ends first: the exactly-once ledger is SESSION-scoped.
The queue holds the opaque RENDERED note text (produced by the delivery renderer, task 03) plus a monotonic per-session sequence that is the exactly-once ledger key. It does NOT call the renderer and does NOT know how the text was produced; it is a data structure, not a rendering surface.
DURABILITY: a durable backing (the same durability the session snapshot has, e.g. a JSONL sidecar under the store dir) survives a process restart so a note queued before a restart drains after it. A backing with no persistence (the in-memory default) degrades honestly — in-process it works, across a restart it is empty (byte-identical to a no-delivery path for the restarted process). NopDeliveryQueue is the byte-identical no-delivery default: a deployment with delivery unwired sees nothing.
The loop is storage-agnostic and stays so: the fire path (composition) ENQUEUES to it, and the loop's turn-boundary drain + the run-entry funnel DEQUEUE from it via this port. Implementations live in composition/adapters, never in engine/agent.
CONCURRENCY: implementations MUST be safe for concurrent Enqueue/Pending/ MarkDelivered across session ids — the fire path and the origin's drain may run on different goroutines. Per-id order need only be well-defined for a SINGLE session's enqueues, which the implementation serialises.
type Diagnostics ¶
type Diagnostics interface {
// Log emits one record at level with msg and zero or more alternating
// key/value args (slog-style). The ctx is a trace/baggage carrier only:
// implementers MUST NOT derive cancellation or deadlines from it.
Log(ctx context.Context, level Level, msg string, args ...any)
// With returns a child Diagnostics that carries the supplied key/value args
// bound onto every subsequent record, leaving the receiver unchanged.
With(args ...any) Diagnostics
}
Diagnostics is the general-purpose operational logging seam: low-volume human-readable lines about what the harness is doing (composition decisions, degraded-mode warnings, lifecycle notes). It is DISTINCT from ToolCallRecorder (the per-tool audit seam) and from EventSink (the model's conversation stream). The agent loop and the composition layer write to it; domain packages do not take it (they stay silent).
The contract is deliberately tiny and slog-shaped (a message plus alternating key/value args) so the obvious adapter is a thin wrapper over log/slog, but the port itself imports only context + stdlib so it never drags slog or an adapter inward. Callers that inject no sink get NopDiagnostics (Build defaults to it), so every consumer is nil-safe by construction.
type EventLog ¶
type EventLog interface {
// Append durably records ev under the session id.
//
// DURABILITY OBLIGATION: Append must be durable before it returns nil — the
// record is on stable storage (or committed to the backing service) by the
// time nil is returned. An implementation that CANNOT guarantee that returns
// an error rather than buffering silently. A non-nil error may have happened
// before OR after the record committed (for example, a post-write sync can
// fail), so the commit outcome is indeterminate to the caller. The caller
// therefore MUST NOT retry: retrying could duplicate a committed event. (The
// relay logs a WARN on failure and never aborts the run — a broken log must not
// break the live stream — but it relies on nil meaning durable, so a silent
// in-memory buffer that may lose the record on crash is a contract violation,
// not a valid optimisation.)
//
// AT-MOST-ONCE / NO DEDUP: the caller appends each event AT MOST ONCE and
// never retries, so implementations need NOT deduplicate. The log is keyed by
// APPEND ORDER: session.Event.Seq is monotonic within a run, but the contract
// is raw append order — implementations MUST NOT reorder by Seq (or anything
// else); Read returns events in the exact order Append received them.
Append(ctx context.Context, id session.SessionID, ev session.Event) error
// Read returns the session's recorded events in APPEND order as a lazy
// iterator, matching the LLMProvider.Stream idiom: it is streamable (maps
// 1:1 to a server-streaming Read RPC and avoids a unary size cap on a long
// log) and yields each event with a per-item error.
//
// IMPLEMENTER OBLIGATION: on an infrastructure fault (an undecodable record,
// an unknown format tag, an I/O fault) Read yields (session.Event{}, err) and
// then RETURNS — it yields no further events after an error. A MISS — no log
// recorded for the id — yields an EMPTY sequence, not an error: absence is
// data, exactly like an empty conversation. An implementation MUST release any
// resource it opened (file handle, stream) when the consumer breaks out of the
// range early, exactly like a well-behaved iter.Seq2.
Read(ctx context.Context, id session.SessionID) iter.Seq2[session.Event, error]
}
EventLog is the append-only, per-session DURABLE record of a run's event stream — the rich timeline (live reasoning, ask/verdict pairs, delegation lifecycle) that the gRPC/HTTP relay otherwise emits and discards. It is a SEPARATE port from port.EventSink: a Sink is a synchronous live MIRROR of the loop's emits (telemetry, ACP), whereas an EventLog is durable storage that a later consumer reads back chronologically. The two never share a code path — the loop is storage-agnostic (it only emits), and the persistence happens at the server relay, beside the existing awaiting-ask Persist.
The log stores ALREADY-REDACTED events: the event stream is itself the redaction boundary (Subagent/Parallel payloads are metadata-only, Team previews are capped, surfaced child asks are clamped — gauntlet #7), so the log inherits that discipline and adds no new redaction code. The relay may coalesce same-turn message/reasoning deltas into one event of each kind before calling Append; their text and first-observed kind ordering remain exact, while the live client still receives every original delta unchanged.
Both the local JSONL adapter (3a) and the gRPC driver (3c) implement this one contract; a remote driver maps Read 1:1 onto a server-streaming RPC.
CONCURRENCY: implementations MUST be safe for concurrent Append and Read across session ids — the server shares ONE EventLog across all relay goroutines (every in-flight run appends through it at once). Per-id append order need only be well-defined for a SINGLE session's appends, which the server serialises (one relay loop per run).
type EventSink ¶
type EventSink interface {
// Emit publishes a single Event. The ctx is the run's context: telemetry
// implementers may read a trace span from it (so concurrent runs correlate
// their spans/metrics to the originating request) but MUST NOT retain it past
// the call. Implementations must not block the loop indefinitely.
//
// The ctx is a trace/baggage carrier ONLY: implementers MUST NOT derive
// cancellation or deadlines from it. Terminal-event emits (e.g. the final
// EvResult after Run.Cancel) deliberately pass an already-cancelled ctx, and
// correctness relies on sinks reading only the span context from it — a sink
// that bailed on ctx.Err() would drop those terminal events.
Emit(ctx context.Context, ev session.Event)
}
EventSink receives domain Events from the loop and relays them to the API stream (gRPC server-stream / HTTP SSE).
type HookApprovalLearner ¶
type HookApprovalLearner interface {
// LearnHookApproval records that the human authorized the hook-blocked call
// described by ev (its SessionID/Tool/Input). The consumer decides the waiver
// scope; the engine only reports the verdict. It is called synchronously at the
// verdict site and MUST be cheap and non-blocking.
LearnHookApproval(ctx context.Context, ev governance.HookEvent)
}
HookApprovalLearner is an OPTIONAL capability a HookRunner may ALSO implement to be told when a HUMAN granted a durable "allow & don't ask again" verdict (session.VerdictAllowAlways) on a hook-originated approval ask (ADR 0062). The engine TYPE-ASSERTS this interface on Deps.Hooks and calls LearnHookApproval ONLY at that one verdict site — so a HookRunner that does not implement it is wholly unaffected (no method added to HookRunner: that would be a breaking change). It is called ONLY for session.VerdictAllowAlways — NEVER for allow-once (nothing is remembered) and NEVER for deny — and ONLY on the LIVE verdict path, not the awaiting-resume path (which would double-arm). The payload is the neutral governance.HookEvent the engine already holds (SessionID + Tool + Input args), carrying NO approval/guardrail vocabulary — the engine stays generic; the consumer (the guardrails adapter) interprets it to arm a session-scoped waiver so a later identical block does not re-ask.
type HookRunner ¶
type HookRunner interface {
// Run executes the hook(s) registered for ev.Phase and returns the outcome.
Run(ctx context.Context, ev governance.HookEvent) (governance.HookOutcome, error)
}
HookRunner executes a lifecycle hook for a HookEvent and returns its outcome. The shell-exec adapter maps process exit code 0 to allow and exit code 2 to a blocking outcome.
A PreToolUse outcome MAY set governance.HookOutcome.AskApproval together with Block to REFINE a block into an askable block: an interactive engine surfaces it as a permission ask rather than dead-ending the call (ADR 0062). A HookRunner is free to never set it (the byte-identical pre-feature terminal-block behaviour).
type LLMProvider ¶
type LLMProvider interface {
Stream(ctx context.Context, req LLMRequest) (iter.Seq2[Chunk, error], error)
Capabilities() ProviderCapabilities
}
LLMProvider is the provider-agnostic seam for model calls. Stream yields provider-neutral chunks until ctx is cancelled or the model stops; ctx cancellation is how the API "cancel" verb interrupts an in-flight turn. The returned iter.Seq2 yields (Chunk, error) pairs; a non-nil error terminates the stream. The outer error reports a failure to start the stream.
Capabilities reports which non-text prompt input the provider can consume, so a surface adapter can advertise it and gate unsupported content. A decorator MUST forward the inner provider's Capabilities.
type LLMRequest ¶
type LLMRequest struct {
// System is the layered system prompt.
System prompt.Layered
// Messages is the conversation history to send.
Messages []session.Message
// Tools are the tool schemas the model may call.
Tools []tool.ToolSpec
// Model is the provider model identifier (an opaque string; the adapter maps it
// to the concrete wire model). Provider-neutral — see the struct doc-comment.
Model string
}
LLMRequest is the provider-neutral input to a model call. System is the two-layer system prompt (stable prefix + volatile suffix for cache breakpoints); Tools are the schemas, stable across turns for caching.
DTO-neutrality guardrail (multi-provider Finding B): this struct is deliberately provider-NEUTRAL and must stay so. Model is a BARE opaque string (no provider/endpoint/key rides the request — those are server-side registry concerns). Provider-PRIVATE knobs (OpenAI's store/include flags, an Anthropic thinking-budget, a reasoning-effort) are an ADAPTER CONSTRUCTION concern — a WithThinkingBudget-style Option like the existing openai.WithBaseURL — NOT a new LLMRequest field, because the domain/agent loop never branches on provider. A reflection guard test (llm_neutral_test.go) tripwires any silent field addition.
type Lease ¶
type Lease struct {
// SessionID is the session this lease guards.
SessionID session.SessionID
// Owner is the holding process's owner-identity string (e.g.
// "<hostname>-<pid>-<build-nonce>"). Composition builds it once per Build so
// two Builds in one process get distinct owners.
Owner string
// Token is the monotonic fencing epoch for this session id: it advances only
// on a TAKEOVER Acquire (a free/expired/other-owner lease being granted to a
// new holder) and is STABLE across a successful Renew. A future CAS-Save can
// reject a writer holding a stale token; V1 plumbs the token but does not
// consult it (the enforcement is the lease grant itself).
Token uint64
// Expiry is the wall-clock instant the lease lapses if not renewed before it.
Expiry time.Time
}
Lease is an immutable value object describing a single-writer hold on a session id. It is the unit a SessionLease grants, refreshes, and relinquishes. Implementations never mutate a Lease in place; Acquire/Renew return a fresh value.
type Level ¶
type Level int
Level is the severity of a Diagnostics record. It is a small, provider-neutral set kept in the port package so domain/agent code can name a level without importing log/slog or any adapter. Adapters map it onto their backend's own level type (slogdiag maps it onto slog.Level).
type LogRecord ¶
type LogRecord struct {
// Kind says whether this position holds an event or a gap marker.
Kind LogRecordKind
// Event is the recorded event. It is the zero Event when Kind is
// LogRecordGap.
Event session.Event
// GapReason describes why an append failed, when Kind is LogRecordGap. It is
// operator-facing diagnostic text and may be empty.
GapReason string
// Cursor is the resume token positioned AFTER this record. Handing it back to
// ReadAfter yields the NEXT record, so a consumer that persists it after
// processing each record gets at-least-once delivery across a reconnect.
Cursor Cursor
// Live reports whether this record arrived AFTER the read had drained
// everything present when it started.
//
// It is the replay/live boundary a follower needs in order to tell a caller
// "you are now caught up". Computing it in the backend is free — each one
// already knows where its initial snapshot ended — whereas a consumer cannot
// recover it afterwards without a second round-trip to ask for the tail.
Live bool
}
LogRecord is one durable position in a session's log.
type LogRecordKind ¶
type LogRecordKind string
LogRecordKind distinguishes the two things an append position can hold.
const ( // LogRecordEvent is an ordinary recorded session.Event. LogRecordEvent LogRecordKind = "event" // LogRecordGap marks a position where an append is KNOWN to have failed. // // It is a log-record envelope variant, NOT a session.Event (ADR 0250): a gap // is a fact about DELIVERY, not something that happened in the run, and // making it an event would leak it into the event taxonomy, the proto Event // message, the kind-parity gate, and every consumer that folds events into a // session. It occupies a real append position so cursors advance past it // correctly, and the legacy EventLog.Read SKIPS it, preserving that port's // existing contract of returning only events. LogRecordGap LogRecordKind = "gap" )
type MetaLister ¶
type MetaLister interface {
// MetaList returns every stored session's picker metadata, reading only the
// last snapshot line of each (never the full conversation).
MetaList(ctx context.Context) ([]SessionMeta, error)
}
MetaLister is the OPTIONAL cheap-listing seam a SessionStore adapter may additionally implement to enumerate picker metadata WITHOUT loading the full conversation of every stored session. It is a separate interface — SessionStore itself stays the minimal Save/Load pair — and consumers discover it by type assertion: a store that does not implement it falls back to the Load-per-row path. The metadata is a PROJECTION of the latest snapshot line, so it is the same latest-line-wins source Load trusts, just decoded into a small struct that skips the messages array.
Contract:
- MetaList returns ALL stored sessions' picker metadata (id + state + turns + model id + title + creation + last-modified), in no guaranteed order. A row whose last snapshot line cannot be decoded (truncated/empty/corrupt file) is skipped best-effort rather than failing the whole inventory — the same tolerance List applies.
- It applies NO filtering — which rows to show is the CALLER's business.
type MisfirePolicy ¶
type MisfirePolicy int
MisfirePolicy is what to do when a schedule's NextFireAt is in the past at tick time — i.e. the scheduler wakes up (or a replica takes over) and finds a due slot it missed. It is a per-schedule knob; the default (the zero value) is MisfireFireOnceNow.
const ( // MisfireFireOnceNow fires the schedule a single time immediately for the missed // slot, then resumes the normal cadence. This is the DEFAULT (zero value): a // missed run is not silently dropped — the schedule gets one catch-up fire. It // does NOT cascade (a slot missed by an hour fires once, not sixty times). MisfireFireOnceNow MisfirePolicy = iota // MisfireSkip skips the missed slot entirely and waits for the next due fire. // Use for schedules where a stale fire is worthless (e.g. a heartbeat that must // reflect current state) and a catch-up would be misleading. MisfireSkip )
type NopDeliveryQueue ¶
type NopDeliveryQueue struct{}
NopDeliveryQueue is the byte-identical no-delivery default: Enqueue drops, Pending returns empty, MarkDelivered is a no-op. It is the queue a deployment with delivery unwired sees — the same shape as if the feature did not exist. Its zero value is usable, so it is the safe default when nothing is injected.
func (NopDeliveryQueue) Enqueue ¶
func (NopDeliveryQueue) Enqueue(_ context.Context, origin session.SessionID, _ string) (DeliveryNote, error)
Enqueue drops the note and returns a zero-seq note. It never errors: the fire path treats a nil-error Enqueue as "recorded" and proceeds; a Nop queue simply records nothing (the delivery path is opt-in).
func (NopDeliveryQueue) MarkDelivered ¶
MarkDelivered is a no-op success (idempotent by construction).
func (NopDeliveryQueue) Pending ¶
func (NopDeliveryQueue) Pending(_ context.Context, _ session.SessionID) ([]DeliveryNote, error)
Pending returns an empty slice for every origin (absence is data).
type NopDiagnostics ¶
type NopDiagnostics struct{}
NopDiagnostics is the no-op Diagnostics: it drops every record and returns itself from With. Its zero value is usable, so it is the safe default sink for callers (and child engines) that inject nothing.
func (NopDiagnostics) With ¶
func (n NopDiagnostics) With(...any) Diagnostics
With returns the receiver unchanged (no bound attributes to carry).
type PermanentError ¶
type PermanentError interface {
error
// Permanent returns true when the error is a permanent client-side rejection.
Permanent() bool
}
PermanentError reports whether a provider error is a PERMANENT client-side rejection — replaying the identical request cannot succeed (e.g. a 4xx other than 408/429: invalid_encrypted_content, a policy-blocked model, a malformed request shape baked into the persisted history). It is the neutral counterpart to the retry classifier: the classification rides the error (as an errors.As-reachable interface), NOT a port.LLMRequest field, so the loop, EvResult, and clients can distinguish "transient — retry may work" from "permanent — this request shape is rejected" without any provider-specific type crossing into engine/agent.
Fail-open contract: an error that does NOT implement PermanentError (or a nil target) is treated as NOT permanent — today's behaviour is preserved for unclassifiable errors. Adapters implement it on their terminal provider errors; llmresilience wraps the surfaced non-retryable error.
The request remains provider-neutral. Optional status/code/correlation detail may ride the primitive structural ProviderErrorMetadataError and is consumed only after root-side validation.
type PermissionPolicy ¶
type PermissionPolicy interface {
// Evaluate returns the permission decision for tool call c under mode, scoped
// to sessionID so per-session LEARNED rules (see Learn) are consulted in
// addition to the static rule set. The learned rules only ever ADD allows at
// the lowest scope: a static deny/ask still wins, and plan mode still
// hard-denies mutations BEFORE any learned rule is consulted.
//
// ws is the session's workspace, taken as a READ-ONLY tool.WorkspaceReader
// (Root + Read + Stat) — the policy only ever LOOKS at the workspace, never
// mutates it. It is the discovery root for FILE-BASED permission config
// (issue #13): a RuleResolver re-resolves the project-level
// `.mecatl/settings.yaml` (and Claude-imported rules) against ws.Root() per
// session, so two sessions rooted at different workspaces can resolve the SAME
// tool call differently. ws may be nil (e.g. a child/member engine with no
// resolver wired) — implementations must treat a nil ws as "no project config".
Evaluate(ctx context.Context, sessionID session.SessionID, mode session.PermissionMode, c session.ToolCall, ws tool.WorkspaceReader) governance.PermissionDecision
// Learn records a per-session allow rule derived from tool call c (the model's
// "allow always" verdict). It is a no-op when c is not safely learnable (a
// compound/substituted Bash command, or a call with no targetable pattern —
// see governance.LearnableRule). It NEVER overrides a deny or bypasses plan
// mode: the learned rule is consulted by Evaluate at the lowest scope only.
Learn(sessionID session.SessionID, c session.ToolCall)
}
PermissionPolicy evaluates a tool call under a permission mode, resolving across merged scopes with deny → ask → allow precedence. It is implemented by the permpolicy adapter (a session-aware wrapper over the session-free governance.Evaluator), not by governance itself, which cannot import session.
type PermissionStore ¶
type PermissionStore interface {
// Record stores a learned rule for sessionID. Implementations should dedupe
// identical rules so repeated "allow always" for the same call does not grow
// the set unbounded.
Record(sessionID session.SessionID, rule governance.Rule)
// Rules returns a snapshot (copy) of the rules learned for sessionID, safe for
// the caller to read without holding any lock. An unknown session yields nil.
Rules(sessionID session.SessionID) []governance.Rule
}
PermissionStore holds the per-session LEARNED permission rules an "allow always" verdict records. It is the small mutable seam the otherwise-immutable governance.Evaluator is missing: the Evaluator stays session-free and immutable; this store keys learned rules by session so the policy can merge them in per call. Implementations must be safe for concurrent use.
type ProviderCapabilities ¶
type ProviderCapabilities struct {
// Image reports whether the provider consumes image parts.
Image bool
// Audio reports whether the provider consumes audio parts.
Audio bool
// EmbeddedContext reports whether the provider ADVERTISES embedded-context
// support. Inline-text resources always flatten into the prompt text
// regardless; this gates whether the adapter declares the capability.
EmbeddedContext bool
}
ProviderCapabilities declares which non-text prompt input a provider can consume. The capability seam is the single switch that gates multimodal prompt content: a surface adapter (e.g. ACP) consults it to advertise its promptCapabilities and to loud-reject unsupported content rather than silently dropping it. The zero value is text-only (every field false).
type ProviderErrorMetadataError ¶
type ProviderErrorMetadataError interface {
error
ProviderHTTPStatus() int
ProviderInBandStatus() int
ProviderErrorCode() string
ProviderErrorCorrelationKind() string
ProviderErrorCorrelationID() string
}
ProviderErrorMetadataError exposes optional provider failure metadata through errors.As using only primitive method signatures. That structural shape lets independently versioned provider modules implement it while compiling against an older engine release.
Zero values mean absent. Consumers must validate all populated fields and omit invalid metadata as a whole. CorrelationKind is a closed root-validated string vocabulary: request, response, trace, completion, or message.
type PrunableStore ¶
type PrunableStore interface {
// List returns every stored session's id and last-modified time, in no
// guaranteed order.
List(ctx context.Context) ([]StoredSession, error)
// Delete removes the session stored under id. An unknown id is success
// (idempotent); any returned error is an infrastructure failure.
Delete(ctx context.Context, id session.SessionID) error
}
PrunableStore is the OPTIONAL retention seam a SessionStore adapter may additionally implement. It is a separate interface — SessionStore itself stays the minimal Save/Load pair — and consumers discover it by type assertion: a store that does not implement it is simply never swept (the composition-layer child-session GC degrades to a no-op).
Contract:
- List returns ALL stored session ids (with their last-modified times). It applies NO filtering — retention policy (which ids are prunable, age thresholds, per-family caps) is entirely the CALLER's business.
- Delete removes the snapshot stored under id, plus any sidecar records the adapter keeps for it (e.g. jsonlstore's tool-call log). It is IDEMPOTENT: deleting an unknown id succeeds — callers tolerate List/Delete races by construction.
type ReadOptions ¶
type ReadOptions struct {
// Limit caps the number of records yielded. Zero means unbounded.
//
// This is the bounded paging the legacy Read cannot express: it reads the
// whole log and stops, so a long transcript has no way to arrive in pieces.
//
// Zero bounds the SEQUENCE, not the FETCH. It means "yield records until the
// log ends or the context is cancelled" — it is NOT permission to pull an
// unbounded response out of storage in one round trip. A backend may, and for
// a large log should, page internally at whatever size it likes while
// continuing to yield; that choice is invisible through the iterator, so it
// is a backend decision rather than a contract term. Stated because the
// natural reading of "unbounded" is to pass the caller's zero straight
// through to the storage call, which turns a long transcript into one large
// allocation (raised in review on #868).
//
// When Limit is POSITIVE it is a TOTAL budget for the call, not a per-wake-up
// one: reaching it ends the read even with Follow set. A follower wanting an
// unbounded tail sets Limit to zero — under the per-wake-up reading a total
// cap would be inexpressible, whereas this way both are.
Limit int
// Follow keeps the iterator open at the tail instead of ending there,
// yielding records as they are appended until ctx is done.
//
// A follow that ends because ctx was cancelled returns WITHOUT yielding an
// error: cancellation is how a watch is meant to end, and reporting it as a
// fault would make every clean detach look like a failure in the logs.
//
// A positive Limit still applies and still ends the read: Follow means "do
// not stop at the tail", not "ignore the budget".
Follow bool
}
ReadOptions bounds a ReadAfter.
type RetryDisposition ¶
type RetryDisposition = session.RetryDisposition
RetryDisposition and StreamProgress remain aliases here for source compatibility; the neutral vocabularies live in session so durable domain state does not import this outward port package.
type RetryDispositionError ¶
type RetryDispositionError interface {
error
RetryDisposition() session.RetryDisposition
}
RetryDispositionError exposes a failure's causal retry classification through errors.As without adding provider-specific fields to LLMRequest.
type Schedule ¶
type Schedule struct {
Spec ScheduleSpec
State ScheduleState
}
Schedule is the aggregate value object a ScheduleStore returns from Load/List: the immutable Spec plus the durable State. The two halves are separate so a caller can hold a Spec without firing state (e.g. a create/update payload) and so the store can advance State in place without touching the definition.
type ScheduleCreator ¶
type ScheduleCreator interface {
// Create atomically creates a NEW schedule under s.Spec.Name: if a schedule
// already exists under that name, it returns ErrScheduleAlreadyExists
// (wrapped) and leaves the existing record untouched; otherwise it creates
// the schedule exactly as Save would for a brand-new name (including the
// zero-State-defaults-to-enabled convention). The check-and-create is a
// SINGLE atomic operation from the backend's perspective — two concurrent
// Create calls for the same name must yield exactly one success and one
// ErrScheduleAlreadyExists, never two successes.
Create(ctx context.Context, s Schedule) error
}
ScheduleCreator is the OPTIONAL atomic create-only seam (review finding 5, issue #368): a schedule manager's Create must never silently overwrite an existing schedule of the same name, and the pre-existing check-then-Save path (Load, then Save) has a TOCTOU window across two separate store calls — two concurrent same-name creates can both observe absence and one silently clobbers the other. It is discovered by type assertion on a ScheduleStore exactly like ScheduleOneShotReArmer / PrunableStore / SessionLease: a store that does not implement it is simply never consulted, and the manager's create path degrades to the pre-existing check-then-Save (byte-identical, the documented small-risk TOCTOU noted on that path).
Why an OPTIONAL interface, NOT a method on ScheduleStore: adding a required method is BREAKING for external implementers (a ScheduleStore implementation outside this repo would fail to compile) — the same rationale ScheduleOneShotReArmer's doc gives.
type ScheduleFire ¶
type ScheduleFire struct {
// ID is the fire's unique identifier (caller-assigned at RecordFire time).
ID string
// ScheduleName is the schedule this fire belongs to (the foreign key back to
// ScheduleSpec.Name).
ScheduleName string
// SessionID is the session the fire ran as. The fire's full conversation/state
// is loaded from the SessionStore under this id.
SessionID session.SessionID
// FiredAt is when the fire was Claimed (its start instant). It is the same
// instant recorded as LastFireAt on the schedule.
FiredAt time.Time
// StartedAt is when the fire's run actually began (RecordFireStart). It is
// distinct from FiredAt (the Claim instant): a fire is Claimed BEFORE its run
// starts, so StartedAt >= FiredAt. A fire written by RecordFireStart is
// IN-FLIGHT (Stop empty, StartedAt set); a fire written by RecordFire is
// terminal. Zero on a terminal-only fire (one never observed in-flight by the
// store, e.g. a legacy record). Issue #386.
StartedAt time.Time
// ProgressAt is the last observed progress instant for the fire
// (RecordFireProgress). Zero means "no progress observed". Issue #386.
ProgressAt time.Time
// Deadline is the fire's wall-clock deadline (RecordFireStart): start +
// ScheduleSpec.FireTimeout (or zero when FireTimeout is zero / the deployment
// default applies). Zero means "no explicit deadline". Issue #386.
Deadline time.Time
// Stop is the terminal stop reason of the fire's run (the same
// session.StopReason EvResult carries). Empty if the fire has not yet
// completed.
Stop session.StopReason
// Err is the error string if the fire's run failed (Stop == StopError), empty
// otherwise. It is a flat string (no structured error crosses the store) so a
// consumer can render it without importing the run's error types.
Err string
}
ScheduleFire is one fire record: the outcome of a single Claim→run→RecordFire cycle. It is the pull-only result-delivery channel for v1 — a caller polls LoadFire (or List, future) to discover what a fire produced, rather than the store pushing results. The fire's SESSION (the conversation, usage, tool calls) lives in the SessionStore under SessionID; this record is the schedule-indexed pointer to it plus the terminal stop reason and any error string.
type ScheduleManager ¶
type ScheduleManager interface {
// CreateSchedule validates the spec fail-closed (trigger XOR, cron grammar,
// the Mutating/Mode invariant, the profile-aware workspace check), applies
// the create-seam defaults (Singleton=true), computes the first NextFireAt,
// and saves the schedule. A duplicate name is rejected (create never
// clobbers an existing schedule).
CreateSchedule(ctx context.Context, spec ScheduleSpec) (Schedule, error)
// GetSchedule loads a schedule by name; the not-found case wraps
// ErrScheduleNotFound.
GetSchedule(ctx context.Context, name string) (Schedule, error)
// ListSchedules returns all stored schedules (no guaranteed order).
ListSchedules(ctx context.Context) ([]Schedule, error)
// UpdateSchedule re-validates and overwrites the Spec half while preserving
// the firing State (progress). The not-found case wraps ErrScheduleNotFound.
UpdateSchedule(ctx context.Context, spec ScheduleSpec) (Schedule, error)
// DeleteSchedule removes a schedule by name. It is idempotent (deleting an
// unknown name is success).
DeleteSchedule(ctx context.Context, name string) error
// PauseSchedule disables a schedule (Enabled=false) without deleting it.
PauseSchedule(ctx context.Context, name string) error
// ResumeSchedule re-enables a paused schedule (Enabled=true).
ResumeSchedule(ctx context.Context, name string) error
// FireNow manually fires a schedule by name, SYNCHRONOUSLY-TO-TERMINAL: it
// claims the slot, mints the fire's session (a "sched--"-prefixed id), runs
// it, and returns the fire record (ID == SessionID, plus the terminal Stop
// reason). A paused/done schedule, an exhausted one-shot, or a singleton
// overlap is rejected (the same sentinels the REST FireNow maps).
FireNow(ctx context.Context, name string) (ScheduleFire, error)
// ListFires returns the fire records for a schedule (no guaranteed order).
ListFires(ctx context.Context, scheduleName string) ([]ScheduleFire, error)
}
ScheduleManager is the narrow CONSUMER-LOCAL interface the agent-layer Schedule tool (engine/agent) consumes to manage scheduled tasks. It exposes exactly the verbs the tool needs — create/inspect/list/update/pause/resume/ delete/fire/list-fires — and is satisfied by composition with the existing schedule surface of the server service (internal/adapter/server.Service), whose methods have these EXACT signatures. The injection precedent is the Subagent tool's WithSubagentStore(port.SessionStore): a consumer-defined port satisfied in composition, so engine/agent NEVER imports internal/adapter/server, an adapter, proto, or gRPC. It is NOT port.ScheduleStore (the durable registry port the tick loop polls) — the manager is the validated create-seam + fire-seam SURFACE (CreateSchedule validates fail-closed and computes the first fire; FireNow claims + runs a fire synchronously-to-terminal), which the store alone does not provide.
Error contract: a ScheduleManager MUST wrap the port-level sentinels a consumer distinguishes via errors.Is — port.ErrScheduleNotFound for an unknown schedule/fire name, and the create-seam's argument-rejection class for an invalid spec. A FireNow on a schedule whose prior fire is still running returns the singleton-overlap error the REST FireNow surface returns (a caller may map it to a "try again later" model message); it is NOT a second concurrent fire.
type ScheduleOneShotReArmer ¶
type ScheduleOneShotReArmer interface {
// ReArmOneShot re-enables the named one-shot schedule, sets its NextFireAt to
// nextFire, and increments OneShotRetryCount — atomically. It is the re-arm
// primitive the tick loop calls for a crashed one-shot retry. The not-found
// case wraps ErrScheduleNotFound.
ReArmOneShot(ctx context.Context, name string, nextFire time.Time) error
}
ScheduleOneShotReArmer is the OPTIONAL at-least-once re-arm seam for one-shot schedules (ADR 0059 Phase 2). It is discovered by type assertion on a ScheduleStore exactly like PrunableStore / SessionLease / MetaLister are on a SessionStore: a store that does not implement it is simply never consulted, and the tick loop's one-shot re-arm path degrades to at-most-once (byte-identical to the pre-Phase-2 path) — a one-shot that crashed mid-fire stays lost, the documented pre-Phase-2 trade-off.
Why an OPTIONAL interface, NOT a method on ScheduleStore: adding a method to an existing interface is BREAKING for external implementers (a ScheduleStore implementation outside this repo would fail to compile). The optional-interface type-assertion pattern (PrunableStore / SessionLease / MetaLister) adds the seam without widening the required surface — a store opts in by implementing the method, and the caller type-asserts before calling.
ReArmOneShot atomically: re-enables the schedule (Enabled=true), sets NextFireAt to nextFire, and increments OneShotRetryCount. It is the re-arm primitive the tick loop calls when it observes a one-shot that crashed mid-fire (Claim disabled it; the fire never recorded a successful outcome). The atomicity is the at-most-once fence for the RE-ARM: two concurrent re-arms must not double-increment OneShotRetryCount or double-enable. The not-found case wraps ErrScheduleNotFound. A re-arm past OneShotMaxRetries is the CALLER's gate (the tick loop checks the budget before calling); the store does NOT enforce the budget — it only atomically advances the counter.
It is one-shot-ONLY: a cron schedule never re-arms (a cron self-heals via misfire). The caller never calls ReArmOneShot on a cron schedule.
type ScheduleProviderSelector ¶
type ScheduleProviderSelector struct {
// ProviderID is the opaque provider identifier (the same inert label a session
// stores on ProviderID). "" means the deployment default.
ProviderID string
// ModelID is the opaque model identifier (the same inert label a session stores
// on ModelID). "" means the deployment default for the provider.
ModelID string
}
ScheduleProviderSelector is the provider+model pair a schedule's fires run on. It mirrors port.LLMRequest.Model's opaque-string discipline: the domain stores these two bare strings and never interprets them — the ProviderSelector type and all resolution (live catalog, model alias, capability intersection) live in composition, exactly as they do for a per-session engine. A zero value (both empty) means "use the deployment default" (the server's default provider/model), the same posture as a session created with no selector.
It deliberately omits a reasoning-effort field (the third field on the internal ProviderSelector, ADR 0055). Reasoning-effort is an ADAPTER OPTION (a per-provider construction knob), NOT a port.LLMRequest field (the DTO-neutrality discipline), so it does not cross the port boundary. A v1 schedule runs on the operator's configured default effort for the selected model; a per-schedule effort override is a later-phase composition knob, not a port concept.
type ScheduleSpec ¶
type ScheduleSpec struct {
Name string
Prompt string
Parts []session.Content
Trigger TriggerSpec
Selector ScheduleProviderSelector
Profile string
Workspace string
Mode session.PermissionMode
Limits session.Limits
Mutating bool
MaxFires int
Misfire MisfirePolicy
Singleton bool
// Timezone is the IANA timezone name (e.g. "America/New_York") the cron
// expression fires in. Empty means UTC (the recommended default for infra
// schedules — avoids the 1–3am DST danger zone). The store stores it
// verbatim (it never interprets it); composition's cronparse call loads
// it and passes it to NextFire. A one-shot trigger ignores it (a
// one-shot is an absolute wall-clock instant, already tz-aware via
// time.Time).
Timezone string
CreatedAt time.Time
// OneShotRetry is the opt-in at-least-once retry for a one-shot (see the
// field-by-field contract above). Default false (at-most-once).
OneShotRetry bool
// OneShotMaxRetries bounds the re-arm budget when OneShotRetry is true. 0
// means off (the create-seam applies a default of 3 when OneShotRetry is
// true and this is 0). One-shot-only; ignored for cron.
OneShotMaxRetries int
// CarryContext renders the prior fire's conversation as a fenced untrusted
// preamble (NOT seeded history — carried context is untrusted). See the
// field-by-field contract above.
CarryContext bool
// OriginSessionID is the session whose terminal result delivery should
// receive the fire's outcome. Empty means no delivery — the fire's result
// is discoverable only through the pull-only GetFire/ListFires channel
// (the v1 pre-delivery posture). A non-empty value names the session the
// fire's terminal EvResult is delivered to (per ADR 0075, fire-result-
// delivery). The field is METADATA-ONLY: it is NEVER rendered into a
// prompt, NEVER surfaced to the model, and NEVER appears in any
// model-visible surface. It is an infrastructure-level routing key the
// fire path reads to route the outcome; the model has no access to it.
//
// The create-seam validates it: a non-empty OriginSessionID that names a
// non-existent session is rejected fail-closed (the same ErrInvalidArgument
// class as the other spec rejections). An empty OriginSessionID is always
// valid (delivery is OFF — the byte-identical pre-delivery posture).
OriginSessionID session.SessionID
// Owner is the verified caller the schedule is attributed to (ADR 0204
// decision 6). It is captured ONCE at create time — never derived at fire
// time, because the origin session may be swept by retention while the
// schedule lives on. The capture rule is the CREATE SEAM's business (the
// Schedule-tool path reads the executing session's owner via the origin
// binder; an out-of-band REST/CLI create reads the context principal); the
// store is identity-blind and round-trips the value verbatim. A nil Owner is
// an ownerless schedule (the unauthenticated path) — never a fabricated
// principal.
//
// A fire mints its "sched--" session under this owner with GrantType
// client_credentials (the fire is automated, not interactive), injected via
// the explicit owner seam so the scheduler's system principal does not become
// the fire's owner.
Owner *session.Principal
// FireTimeout is the per-fire wall-clock deadline (issue #386, the in-flight
// scheduled-fire state). Zero means "use the deployment default" (the operator-
// tier default applied by composition; a zero here is NOT "no timeout" — it
// defers to the deployment default, which may itself be zero for "no explicit
// deadline"). When non-zero, RecordFireStart stamps FireDeadline = start +
// FireTimeout on the ScheduleState, and a watchdog reads FireDeadline to
// terminate the in-flight run with session.StopTimeout when it lapses (a CLEAN,
// recoverable terminal, like StopBudget). It bounds a single fire's RUN, not
// the schedule's lifetime (MaxFires bounds the count). Per-fire: each fire gets
// a fresh deadline from its own start instant. The store stores it inertly
// (the store never interprets it); composition reads it at fire-start.
FireTimeout time.Duration
}
ScheduleSpec is the immutable definition of a schedule — the "what to run and when" half, set at creation and not mutated by firing. The durable FIRING state (next fire, counts, last session) lives in ScheduleState. The two halves together form a Schedule, which is what Load/List return.
Field-by-field contract:
- Name is the schedule's unique key (Save is an upsert by Name). It is a stable caller-chosen identifier; the store does not generate it.
- Prompt is the free-text user prompt the fire runs with. Parts is an OPTIONAL multimodal extension (image/audio content parts), validated by the same session.ValidateMediaParts the wire path uses — there is no second validation path. Either or both may be set; a schedule with neither is invalid (caught at composition's create-seam, not here — the store is structure-blind).
- Trigger is the firing trigger (a TriggerSpec: Cron XOR OneShot). The store is parser-free — it stores the raw expression verbatim and never interprets it; the CALLER (composition) computes the next fire and hands it to Claim. Validate enforces the exactly-one-of(Cron, OneShot) invariant; the store does not re-Validate on Save (the create-seam does, fail-closed).
- Selector selects the provider+model the fires run on. A zero value means the deployment default (the same opaque-string discipline as port.LLMRequest.Model).
- Profile is the session tool-surface profile ("" default, "no-fs" file-less). The store stores it inertly; composition interprets it at fire time.
- Workspace is the session cwd. "" means the deployment default.
- Mode is the session permission posture (the same session.PermissionMode a created session carries).
- Limits are the bounded budgets for each fire — subagent-grade caps (MaxTurns/MaxToolCalls/MaxConsecutiveFailures). They are PER-FIRE: each fire gets a fresh session with these limits, so a runaway fire is bounded exactly as a subagent is. A zero value disables that cap (the caller's responsibility to set sane defaults).
- Mutating is the explicit write opt-in. The DEFAULT is false (read-leaning): a schedule that does not opt in is treated as read-only for posture purposes, the same conservative default as a subagent. A schedule that will write (Edit/Write/Bash mutations) MUST set this true; composition's posture ladder applies.
- MaxFires bounds the TOTAL number of fires for a cron schedule (0 = forever). It is cron-only: a one-shot fires once by definition and MaxFires is ignored for it. Once FireCount reaches MaxFires the schedule is DONE (Enabled=false, NextFireAt zeroed) — the store enforces this in Claim.
- Misfire is the misfire policy (see MisfirePolicy). Default MisfireFireOnceNow.
- Singleton is whether to skip the next fire if a prior fire is still running (the singleton / skip-overlap guard). The intended default is true (overlapping fires of the same schedule are suppressed, so a slow run does not pile up concurrent fires); it is a bare `bool` whose zero value is false, and the Phase-2 create-seam is what sets it to true by default (Phase 1 has no create API, so a schedule's Singleton is whatever its Save carried). The authoritative cross-replica liveness oracle for the "prior still running" check is the per-session LEASE on ScheduleState.LastFireSessionID: a stale pointer to a finished fire (lease released/expired) yields a free trial-acquire, so the next fire is NOT skipped — a crashed fire self-heals by being treated as done.
- CreatedAt is the schedule's creation timestamp.
- OneShotRetry is the opt-in at-least-once retry for a one-shot (ADR 0059 Phase 2). The DEFAULT is false: a one-shot is at-most-once (a crash mid-fire SKIPS the slot — the claim-before-fire advance already happened, so a retry does not re-fire). A one-shot that cannot tolerate crash-loss sets this true: the tick loop's re-arm path re-enables the schedule (up to OneShotMaxRetries times) when it observes the prior fire crashed before recording an outcome (LastFireSessionID still pending) or recorded a StopError. It is one-shot-ONLY: setting it on a cron trigger is rejected at the create-seam (a cron self-heals via misfire already). A re-armed one-shot starts FRESH (the crashed fire's context is untrusted AND incomplete — the re-arm path ignores CarryContext).
- OneShotMaxRetries bounds the re-arm budget when OneShotRetry is true. The DEFAULT is 0 (off); the create-seam applies a default of 3 when OneShotRetry is true and OneShotMaxRetries is 0. OneShotRetryCount on the State is incremented on each re-arm; when it exceeds OneShotMaxRetries the schedule stays disabled (the one-shot is permanently done).
- CarryContext is the opt-in carried-context toggle (ADR 0059 Phase 2). The DEFAULT is false: each fire is a FRESH context (no prior fire's history is carried). When true, the fire path loads the prior fire's session and renders its conversation as a FENCED UNTRUSTED PREAMBLE prepended to the fire's prompt — NOT as seeded history. The carried context is UNTRUSTED (model-authored + tool-result-laden; a prior fire may have been prompt-injected), so it MUST NOT become replayable Conversation.Messages (which would carry injection forward as live instructions). The canonical governance fence (governance.FenceUntrusted + NeutraliseFraming) quarantines it so a forged closing marker or harness section header in the prior content cannot break out of its block. On prior-session-load failure (not found, decode error) the fire degrades to fresh-context (WARN, never fails the fire). A re-armed one-shot does NOT carry context on the retry.
- FireTimeout is the per-fire wall-clock deadline (issue #386, the in-flight scheduled-fire state). Zero means "use the deployment default" (a zero here is NOT "no timeout" — it defers to the operator-tier deployment default, which may itself be zero for "no explicit deadline"). When non-zero, RecordFireStart stamps ScheduleState.FireDeadline = start + FireTimeout, and a watchdog terminates the in-flight run with session.StopTimeout (a CLEAN, recoverable terminal, like StopBudget) when it lapses. It bounds a single fire's RUN, not the schedule's lifetime (MaxFires bounds the count). The store stores it inertly (the store never interprets it); composition reads it at fire-start.
type ScheduleState ¶
type ScheduleState struct {
// NextFireAt is the ground-truth next fire instant. It is advanced by Claim
// BEFORE the fire runs (claim-before-fire) and is the field Due compares against.
// The zero time means "no next fire" (a one-shot that fired, or a cron whose
// MaxFires is exhausted) — the schedule is effectively done.
NextFireAt time.Time
// LastFireAt is the instant of the most recent Claim (the start of the most
// recent fire), not its completion. Updated atomically in Claim.
LastFireAt time.Time
// FireCount is the total number of fires that have been Claimed (a Claim
// increments it). It is the counter MaxFires is checked against.
FireCount int
// Enabled is whether the schedule is active. A schedule may be disabled without
// deletion (pause/resume). Disabled schedules are excluded from Due even if
// NextFireAt is in the past. Claim sets Enabled=false when a cron exhausts
// MaxFires or a one-shot fires.
Enabled bool
// LastFireSessionID is the session id of the prior fire. The per-session LEASE
// on it is the authoritative cross-replica liveness oracle for the singleton
// check: a still-held lease means the prior fire is running (skip the next
// fire); a released/expired lease means it finished or crashed (fire freely).
// Claim sets this to port.PendingFireSessionID; RecordFire overwrites it with
// the real fire's session id.
LastFireSessionID session.SessionID
// OneShotRetryCount is the durable counter of one-shot re-arms (ADR 0059
// Phase 2). It is incremented atomically by ScheduleOneShotReArmer.ReArmOneShot
// on each re-arm. When it exceeds ScheduleSpec.OneShotMaxRetries the schedule
// stays disabled (the one-shot is permanently done — the retry budget is
// exhausted). The DEFAULT is 0 (no re-arms yet). It is one-shot-only: a cron
// schedule never re-arms (a cron self-heals via misfire) so the counter stays
// 0 for cron.
OneShotRetryCount int
// LastFireStartedAt is when the current fire's RUN actually began — the instant
// the fire's session was driven (RecordFireStart), DISTINCT from LastFireAt
// which is the Claim instant (a claim-before-fire advance happens BEFORE the
// run starts, so LastFireStartedAt >= LastFireAt). It is the in-flight liveness
// marker: zero means "the current fire has not started its run yet" (the
// crash-after-Claim state — Claim happened, RecordFireStart did not). Set by
// RecordFireStart, cleared by RecordFire (a terminal fire has no in-flight run).
// Issue #386.
LastFireStartedAt time.Time
// LastFireProgressAt is the last observed progress instant for the current
// fire (RecordFireProgress), advanced as the fire's run produces events. Zero
// means "no progress observed yet" (the run started but has not emitted, or
// RecordFireProgress was never called). It is best-effort liveness: a stale
// value (far behind the wall-clock) is a stuck-fire signal a watchdog may act
// on. Set by RecordFireStart (seeded to the start instant) and RecordFireProgress;
// cleared by RecordFire. Issue #386.
LastFireProgressAt time.Time
// FireDeadline is the current fire's wall-clock deadline — the instant at
// which the fire is considered to have exceeded its per-fire timeout
// (ScheduleSpec.FireTimeout, the deployment default when zero). It is set by
// RecordFireStart (start + FireTimeout, or zero when FireTimeout is zero / the
// deployment default applies) and cleared by RecordFire. A watchdog reads it to
// decide whether to terminate the in-flight run with StopTimeout. Zero means
// "no explicit deadline (default or not set)". Issue #386.
FireDeadline time.Time
}
ScheduleState is the durable FIRING state of a schedule — the mutable half that advances as the schedule fires. It is updated atomically by Claim (the claim-before-fire advance) and RecordFire (the post-fire outcome), and persisted by Save. The store is the ground truth; an in-memory timer in composition is a DERIVED lookahead over this state.
The claim-before-fire discipline: NextFireAt is advanced BEFORE the fire runs, as the atomic claim that gives at-most-once semantics — a peer replica's Due MUST NOT re-return a slot after Claim has advanced it. A crash mid-fire therefore SKIPS the slot (the advance already happened); a recurring schedule self-heals via the MisfireFireOnceNow policy on the next tick, but a one-shot can be LOST (decision #1 — the documented trade-off for exactly-once without distributed TX).
type ScheduleStore ¶
type ScheduleStore interface {
// Save upserts the schedule by Spec.Name. A schedule with the same name is
// overwritten; the State half is preserved on overwrite (a Save with a fresh
// State does not reset firing progress — call Delete + Save to reset). An
// implementation that cannot store schedules returns ErrScheduleUnsupported
// (wrapped).
Save(ctx context.Context, s Schedule) error
// Load returns the schedule stored under name. The not-found case MUST wrap
// ErrScheduleNotFound; any other error is an infrastructure failure (or
// ErrScheduleUnsupported if the backend cannot store schedules at all).
Load(ctx context.Context, name string) (Schedule, error)
// Delete removes the schedule stored under name. It is IDEMPOTENT: deleting an
// unknown name is success (the PrunableStore.Delete discipline), so callers
// tolerate List/Delete races by construction. Any returned error is an
// infrastructure failure (or ErrScheduleUnsupported).
Delete(ctx context.Context, name string) error
// List returns ALL stored schedules, in no guaranteed order. It applies NO
// filtering — retention policy (which schedules are prunable, age thresholds)
// is entirely the CALLER's business (the PrunableStore.List discipline).
// An implementation that cannot enumerate returns ErrScheduleUnsupported
// (wrapped).
List(ctx context.Context) ([]Schedule, error)
// Due returns the schedules whose NextFireAt is <= now AND Enabled AND (when
// MaxFires > 0) FireCount < MaxFires. This is the poll-the-store pattern: the
// in-memory timer in composition is a DERIVED lookahead, the store is ground
// truth. A schedule returned by Due is NOT yet claimed — the caller must Claim
// it to win the slot. Due is idempotent and side-effect-free; it does not
// advance state.
Due(ctx context.Context, now time.Time) ([]Schedule, error)
// Claim is the AT-MOST-ONCE atomic advance. It atomically: sets LastFireAt=now,
// advances NextFireAt to nextFire (the caller-computed next cron fire, or the
// zero time for a one-shot / a MaxFires-exhausted cron), increments FireCount,
// sets LastFireSessionID to port.PendingFireSessionID (the caller overwrites it
// via RecordFire with the real fire's session id), and (for a one-shot or an
// exhausted cron) sets Enabled=false and zeroes NextFireAt. It returns the
// claimed schedule (with the advanced State).
//
// nextFire is computed by the CALLER (composition, which has the cronparse
// dependency) — the store is parser-free and never interprets the cron
// expression. A zero nextFire means "no further fire" (one-shot done, or cron
// exhausted): Claim sets Enabled=false and zeroes NextFireAt.
//
// The at-most-once fence is the durable NextFireAt advance itself — there is no
// owner/claim-holder field (unlike SessionLease, whose Owner fences concurrent
// writers on the SAME id). A schedule's fire slot is claimed by advancing
// NextFireAt past `now`; a peer replica's Due then no longer returns it, so a
// second Claim on the same slot is structurally impossible (the slot is no
// longer due). Cross-replica overlap of a STILL-RUNNING prior fire is a
// SEPARATE concern, handled by the caller's trial-lease on
// ScheduleState.LastFireSessionID (the singleton check), not by Claim.
//
// A crash mid-fire SKIPS the slot — the advance already happened, so a retry
// does not re-fire. The not-found case wraps ErrScheduleNotFound (a Claim on a
// deleted schedule is an error, not a silent no-op).
Claim(ctx context.Context, name string, now, nextFire time.Time) (Schedule, error)
// ClaimNow is the manual-trigger variant of Claim: it performs the SAME atomic
// advance (LastFireAt=now, NextFireAt=nextFire, FireCount++,
// LastFireSessionID=PendingFireSessionID, disable on zero nextFire) but does
// NOT enforce the NextFireAt <= now due-check — it claims the slot regardless of
// whether it is due. It is the FireNow primitive (an explicit manual trigger
// bypasses the cadence but still claims atomically for at-most-once). The
// Enabled + MaxFires checks STILL apply (a disabled or exhausted schedule
// cannot be force-fired). The not-found case wraps ErrScheduleNotFound.
//
// At-most-once WITHOUT the due-check: Claim's fence is "NextFireAt is past now
// (a peer's Claim advanced it)", which a not-yet-due slot fails. ClaimNow cannot
// use that fence (a future slot would pass), so it fences on LastFireAt: a
// ClaimNow at the SAME now as a prior ClaimNow is rejected (LastFireAt == now
// ⇒ the advance already happened). This mirrors Claim's discipline — the durable
// advance IS the fence, there is no owner/claim-holder field. It is
// crash-recoverable by design (unlike a pending-sentinel fence, which would
// wedge a schedule forever after a hard crash between ClaimNow and RecordFire —
// the exact wedge the singleton check's doc warns against): a crash leaves
// LastFireAt == now, but a later ClaimNow at a new now sees a stale LastFireAt
// != now and proceeds, so the schedule self-heals instead of wedging. The
// tick-loop Claim is UNAFFECTED — it keeps its due-check (a due slot is the only
// thing the tick loop should fire).
ClaimNow(ctx context.Context, name string, now, nextFire time.Time) (Schedule, error)
// SetEnabled atomically sets the schedule's Enabled flag WITHOUT touching
// any other State field (unlike Save, which preserves State on a Spec
// overwrite — Save CANNOT mutate Enabled because it preserves the existing
// State half). It is the pause/resume primitive: PauseSchedule sets
// Enabled=false; ResumeSchedule sets Enabled=true. The not-found case wraps
// ErrScheduleNotFound. An implementation that cannot store schedules returns
// ErrScheduleUnsupported (wrapped).
SetEnabled(ctx context.Context, name string, enabled bool) error
// RecordFire records the outcome of a fire (f) and updates the schedule's
// LastFireSessionID to f.SessionID (overwriting the port.PendingFireSessionID
// value Claim set). It is IDEMPOTENT per fire id: recording the same f.ID twice is
// a no-op (the second call returns nil without mutating state), so a caller
// may safely retry after a transient infrastructure failure. The not-found
// case (the schedule was deleted between Claim and RecordFire) wraps
// ErrScheduleNotFound.
//
// It FLIPS the fire terminal and clears the in-flight ScheduleState fields
// (LastFireStartedAt/LastFireProgressAt/FireDeadline) — a recorded (terminal)
// fire has no in-flight run. It overwrites any in-flight fire record the same
// f.ID had under RecordFireStart with the terminal one (Stop/Err set). Issue #386.
RecordFire(ctx context.Context, f ScheduleFire) error
// RecordFireStart persists the IN-FLIGHT fire (issue #386, the in-flight
// scheduled-fire state) — the fire's run has begun but not yet produced a
// terminal outcome. It persists the fire record f (ID, SessionID, StartedAt,
// Deadline) as IN-FLIGHT: Stop empty, StartedAt set. It also sets
// ScheduleState.LastFireSessionID to the REAL session id f.SessionID
// (overwriting the port.PendingFireSessionID sentinel Claim stamped) and
// ScheduleState.LastFireStartedAt to f.StartedAt (and seeds
// LastFireProgressAt to f.StartedAt when the caller passed a zero ProgressAt).
// The FireDeadline field on the state is set to f.Deadline (zero when no
// explicit deadline applies). The not-found case (the schedule was deleted
// between Claim and RecordFireStart) wraps ErrScheduleNotFound.
//
// It is IDEMPOTENT per fire id: recording the same f.ID twice (with the same
// StartedAt) is a no-op for the in-flight record (it does not re-stamp or
// double-advance), so a caller may safely retry after a transient
// infrastructure failure. A RecordFireStart for a fire id that is ALREADY
// terminal (a prior RecordFire recorded it) is a no-op too (a terminal fire
// is not re-opened) — the caller should not interleave RecordFireStart after
// RecordFire, but the store is honest about it. Issue #386.
RecordFireStart(ctx context.Context, name string, fire ScheduleFire) error
// RecordFireProgress advances the in-flight fire's last-observed-progress
// instant (issue #386). It updates ScheduleState.LastFireProgressAt to `at`
// (when `at` is after the stored value; an earlier `at` is ignored so a
// reordered/delayed update cannot rewind progress) and the in-flight fire
// record's ProgressAt to `at`.
//
// fireID is the id of the in-flight fire record the caller's RecordFireStart
// wrote (the same `fire.ID` RecordFireStart took). It targets the SINGLE fire
// record by its known key directly — there is NO directory/keyspace scan to
// locate the schedule's in-flight fire (review finding M1: scanning every fire
// record under the store mutex on every turn boundary is O(N) in the fire
// population, up to ~10k with 7d retention, and blocks Claim/RecordFire/List/
// Due). The caller (the fire loop) has fireID in scope.
//
// It is BEST-EFFORT and IDEMPOTENT: a missing in-flight fire record (no prior
// RecordFireStart, or it was already flipped terminal) is a no-op success (the
// progress is recorded on the state alone), and a not-found SCHEDULE wraps
// ErrScheduleNotFound. It never re-opens a terminal fire: a progress write to
// an already-TERMINAL fire record is a no-op for the record (it MUST NOT
// revert the record from terminal back to in-flight — review finding M2, the
// cross-replica race a non-atomic GET-then-SET had where a concurrent terminal
// RecordFire's SET landing between the GET and SET reverted the record). Issue
// #386.
RecordFireProgress(ctx context.Context, name string, fireID string, at time.Time) error
// LoadFire returns the fire record stored under fireID. The not-found case
// wraps ErrScheduleNotFound; any other error is an infrastructure failure. It
// is the pull-only result-delivery read path for v1.
LoadFire(ctx context.Context, fireID string) (ScheduleFire, error)
// ListFires returns the fire records for a schedule, in no guaranteed order.
// It is the list companion to LoadFire. The not-found case for the SCHEDULE
// wraps ErrScheduleNotFound; an empty fire list for an existing schedule is a
// successful empty slice (not an error). An implementation that cannot store
// schedules returns ErrScheduleUnsupported (wrapped).
ListFires(ctx context.Context, scheduleName string) ([]ScheduleFire, error)
}
ScheduleStore is the OPTIONAL durable schedule registry port (scheduled-tasks Phase 1a) — a peer of port.SessionLease / port.EventLog. It is discovered by type assertion exactly like PrunableStore / SessionLease: a store/backend that does not implement it is simply never consulted, and composition wires a scheduler ONLY when an operator selects a backend by flag — the default path is byte-identical with no scheduling.
The loop is storage-agnostic: engine/agent NEVER imports this port. The tick loop, cron parsing, misfire policy application, and the leader-lease acquisition all live in COMPOSITION (internal/app), exactly as the run-entry lease and the event-log persist live in composition. The store is the durable ground truth the tick loop polls; an in-memory timer is a DERIVED lookahead over Due, never the source of truth.
AT-MOST-ONCE (the core contract): Claim is the atomic advance that gives exactly-once firing across replicas. It advances NextFireAt and LastFireAt, increments FireCount, and sets LastFireSessionID to port.PendingFireSessionID — all BEFORE the fire runs (claim-before-fire). A peer replica's Due MUST NOT re-return a slot after Claim has advanced it. A crash mid-fire SKIPS the slot (the advance already happened); a recurring schedule self-heals via the MisfireFireOnceNow policy on the next tick; a one-shot can be LOST (decision #1). The caller computes the next cron fire (the store is parser-free) and passes it to Claim.
MISFIRE: when a schedule's NextFireAt is in the past at tick time, the misfire policy applies (see MisfirePolicy). The default MisfireFireOnceNow fires once immediately for the missed slot; MisfireSkip skips it. The policy is read from ScheduleSpec.Misfire by composition at tick time, not by the store.
CONCURRENCY: implementations MUST be safe for concurrent calls across DISTINCT schedule names — one process ticks many schedules at once, and two replicas may tick the same store. Calls for the SAME name from one process are serialised by the caller (the tick loop is single-threaded per schedule). Claim MUST be atomic with respect to other Claims on the same name (the at-most-once guarantee).
type SessionCreator ¶
SessionCreator is the OPTIONAL atomic first-publication capability of a SessionStore. Create publishes s only when no authoritative snapshot exists under s.ID. The existence check and publication MUST be one backend-atomic operation across all handles sharing that backend; a Load-then-Save sequence does not satisfy this contract.
Any existing snapshot, including one with the same owner and content, causes Create to return an error wrapping ErrSessionAlreadyExists. That collision MUST NOT mutate the existing snapshot, derivative metadata or generations, event log, or tool-call sidecar. Save remains the update/upsert operation for a snapshot whose initial Create succeeded.
type SessionDeleteSupport ¶
type SessionDeleteSupport interface {
SupportsSessionDelete() bool
}
SessionDeleteSupport is the optional authoritative capability signal for a SessionStore that implements PrunableStore for compatibility even when its backend cannot delete sessions. Consumers should prefer this signal when it is present; a PrunableStore without it supports deletion by contract.
type SessionDiscoveryMeta ¶
type SessionDiscoveryMeta struct {
ID session.SessionID
ModifiedAt time.Time
State session.State
Turns int
ModelID string
CreatedAt time.Time
Title string
TitleProvenance session.TitleProvenance
Owner *session.Principal
Workspace string
Kind session.SessionKind
Relationship session.SessionRelationship
// EstimatedBytes is a content-free backend estimate of bytes reclaimed by
// deleting this session family. Zero means unavailable, never a measured
// assertion that the family occupies no storage.
EstimatedBytes int64
}
SessionDiscoveryMeta is the additive bounded-inventory projection. It keeps SessionMeta source-compatible while carrying the trusted taxonomy and workspace needed by discovery clients.
type SessionLease ¶
type SessionLease interface {
// Acquire grants the lease for id to owner. It SUCCEEDS (returning the
// granted Lease) when the lease is free, expired, or already held by owner;
// a takeover (free/expired/other-owner→owner) returns a STRICTLY GREATER
// Token than any prior grant for that id, while a same-owner re-acquire need
// not advance the token. It returns ErrLeaseHeld (wrapped) when the lease is
// held by a DIFFERENT, still-live owner, and ErrLeaseUnsupported (wrapped)
// when the backend cannot lease at all.
Acquire(ctx context.Context, id session.SessionID, owner string) (Lease, error)
// Renew extends a lease the caller still holds, returning a REFRESHED Lease
// (new Expiry, SAME Token — the immutable-value-object discipline; the caller
// stores the returned value). It returns ErrLeaseHeld (wrapped) when the
// caller no longer holds the lease — it expired and was taken by another
// owner, was released, or the owner/token no longer match. That error is the
// LOSS SIGNAL: the renewer treats it as "cancel the run". ErrLeaseUnsupported
// is wrapped only by a backend that never supported leasing (an
// already-acquired lease implies the backend supports it, so a healthy seam
// never starts returning Unsupported mid-hold).
Renew(ctx context.Context, l Lease) (Lease, error)
// Release relinquishes a lease the caller holds; it is IDEMPOTENT (releasing
// an unheld/unknown lease, or one whose owner/token no longer match, is
// success and a no-op — Release only ever drops the caller's OWN hold). A
// non-nil error is an infrastructure failure, never "not held".
Release(ctx context.Context, l Lease) error
}
SessionLease is the OPTIONAL cross-process single-writer seam for session state (ADR 0027 Phase 4, multi-replica readiness). It is discovered by type assertion exactly like PrunableStore: a store/backend that does not implement it is simply never leased, and composition wires a lease ONLY when an operator selects a backend by flag — the default path is byte-identical with no lease.
The loop is lease-agnostic: engine/agent NEVER imports this port. The hold is acquired at the server run-entry seam (after the same-process run-entry lock), renewed by a Service-owned goroutine, and released on session close / shutdown — the same storage-agnostic discipline as port.EventLog (the loop emits; composition persists).
CONCURRENCY: implementations MUST be safe for concurrent calls across DISTINCT session ids — one process leases many sessions at once. Calls for the SAME id from one process are serialised by the caller (one renewer per held lease).
type SessionLineageQuery ¶
type SessionLineageQuery struct {
RootID session.SessionID
RootIncarnation session.IncarnationID
Limit int
}
SessionLineageQuery asks for records in one exact root lifetime. Root records (including tombstones) are returned for audit; a direct child must name both RootID and RootIncarnation in Parent, Origin, or DebugTarget relationship fields.
type SessionLineageReader ¶
type SessionLineageReader interface {
ReadSessionLineage(ctx context.Context, query SessionLineageQuery) (SessionLineageResult, error)
}
SessionLineageReader is the OPTIONAL bounded durable-lineage capability of a SessionStore. Implementations query only their content-free index; they never discover relationships by parsing session IDs or loading transcripts.
type SessionLineageRecord ¶
type SessionLineageRecord struct {
ID session.SessionID
Kind session.SessionKind
Relationship session.SessionRelationship
OwnerScope [32]byte
Incarnation string
State SessionLineageState
DeletedAt time.Time
}
SessionLineageRecord is the content-free durable identity of one session. Pruned records retain only a non-reversible owner-scope token, never Principal PII. Retained records are always snapshot-revalidated before authorization. There is no tombstone-expiry API: conforming stores retain them indefinitely (including across restart and later reuse of the same session ID).
type SessionLineageResult ¶
type SessionLineageResult struct {
Records []SessionLineageRecord
Truncated bool
}
SessionLineageResult contains the current retained root first when present, followed by historical root tombstones, then direct records ordered by session ID, retained incarnation before tombstones, and incarnation as the final tie breaker. Truncated reports that additional records existed beyond Limit.
type SessionLineageState ¶
type SessionLineageState string
SessionLineageState is the closed persistence state of a lineage record.
const ( // SessionLineageRetained identifies a session whose snapshot remains stored. SessionLineageRetained SessionLineageState = "retained" // SessionLineagePruned identifies a content-deleted lineage tombstone. SessionLineagePruned SessionLineageState = "pruned" )
type SessionLiveness ¶
type SessionLiveness interface {
Register(ctx context.Context, id session.SessionID, cancel context.CancelFunc) (release func(), err error)
IsLive(session.SessionID) bool
}
SessionLiveness protects engine-owned child sessions for their complete lifecycle. Register marks id live until the returned idempotent release is called. Implementations may also acquire a distributed lease; in that case registration fails rather than allowing the child to become runnable without exclusion. cancel is invoked if an acquired lease is lost.
Multiple registrations for one id are counted; IsLive remains true until all registrations are released. Implementations must bound and join any renewal work before release returns.
type SessionMeta ¶
type SessionMeta struct {
// ID is the stored session's opaque logical id, byte-exact. A backend whose
// physical key or filename is a LOSSY transform of the id (so that two
// distinct ids could share one) MUST recover the id from stored content
// instead of from the key. A lossless key is free to be the source: keying
// verbatim, or trimming a fixed prefix, satisfies this.
ID session.SessionID
// ModifiedAt is the last-write timestamp (file mtime, or the store's
// nearest equivalent).
ModifiedAt time.Time
// State is the persisted lifecycle state (idle/running/awaiting/completed/
// ...). Empty when the snapshot could not be decoded or carries an unknown
// state.
State session.State
// Turns is the persisted model-call count. Zero when the snapshot could not
// be decoded.
Turns int
// ModelID is the resolved model id this session ran on (bare string, no
// provider context). Empty when the session never resolved a model or the
// snapshot could not be decoded.
ModelID string
// CreatedAt is the creation timestamp. Zero when the snapshot could not be
// decoded.
CreatedAt time.Time
// Title is the human-readable session label (seeded once from the first
// genuine user prompt, clamped). Populated from the snapshot Title ONLY — a
// session whose Title was never seeded (a pre-Title snapshot, or a
// multimodal-only first prompt) carries "" here; the caller may fall back to
// the lazy deriveTitle walk via a full Load if it needs the derived value.
Title string
// Owner is the verified caller the session is attributed to (ADR 0204), or
// nil when the session is ownerless.
Owner *session.Principal
}
SessionMeta is the lightweight picker metadata for a stored session: the fields a session LISTING (the /sessions picker) needs to render a row WITHOUT loading the full conversation. It is a PROJECTION of the latest snapshot — state, turn count, model id, title, and creation time — with the large conversation (messages array) skipped entirely. Kind and Relationship preserve the validated producer taxonomy needed to classify the row without parsing its ID. The store adapter populates it by reading ONLY the last snapshot line into a small struct, so listing N sessions is O(N × last-line-read) rather than O(N × filesize).
It is owned by the PORT (so the server adapter references the shape without importing any concrete store) and implemented by a store via the optional MetaLister interface — discovered by type assertion, exactly like PrunableStore. A store that does NOT implement MetaLister falls back to the Load-per-row path (correct, just slower). State carries the persisted session.State verbatim; an invalid/unknown state is left empty (the row still surfaces its id/mtime, matching the Load-fails zeroed-fields behaviour).
type SessionMetadataCursor ¶
type SessionMetadataCursor struct {
ModifiedAt time.Time
ID session.SessionID
Generation string
Scope string
Continuation string
}
SessionMetadataCursor is an adapter-issued keyset position. Public transports encode the whole value as an opaque token. Generation and Scope bind a page sequence to one backend view and filter set; Continuation is an opaque value owned and validated only by the issuing pager. ModifiedAt and ID retain the neutral ordering boundary used for response validation.
type SessionMetadataPage ¶
type SessionMetadataPage struct {
Sessions []SessionDiscoveryMeta
NextCursor *SessionMetadataCursor
TotalCount int
}
SessionMetadataPage is one best-effort keyset page. Concurrent saves may move rows to an earlier page; the response remains bounded and owner-filtered.
func PaginateSessionMetadata ¶
func PaginateSessionMetadata(rows []SessionDiscoveryMeta, request SessionMetadataPageRequest) SessionMetadataPage
PaginateSessionMetadata applies the shared owner-filter, ordering, and keyset rules to an adapter's metadata scan. It is retained for callers that form a single page without a generation-bound continuation. Pager implementations should use PaginateSessionMetadataBound.
func PaginateSessionMetadataBound ¶
func PaginateSessionMetadataBound(rows []SessionDiscoveryMeta, request SessionMetadataPageRequest, generation string) (SessionMetadataPage, error)
PaginateSessionMetadataBound applies generation- and filter-bound pagination for scan-based adapters. It returns ErrSessionMetadataCursorRestart rather than mixing rows when the current inventory or owner scope differs from the cursor. Indexed adapters may implement the same contract with adapter-private opaque continuations instead of scanning.
generation is the CALLER's own cheap, monotonic "has anything in this store changed" signal (e.g. a counter bumped on every Save/Delete) — this helper does not derive one from rows itself. An earlier version computed a generation by JSON-marshalling and SHA-256-hashing the entire filtered row set on every call; the real cost that removed is a full JSON encode + SHA-256 of every row on every page (a large constant factor) — the row copy/sort prepareSessionMetadataRows does is still O(rows) per call regardless, so this is not an asymptotic change. A caller with no cheaper signal available may still pass a content hash, but should prefer a real counter. generation must be non-empty: an empty value cannot mean "unbound" here (that's PaginateSessionMetadata) — silently downgrading would let a stale cursor mix rows instead of restarting, exactly what ErrSessionMetadataCursorRestart exists to prevent.
type SessionMetadataPageRequest ¶
type SessionMetadataPageRequest struct {
Limit int
Cursor *SessionMetadataCursor
OwnershipEnforced bool
Owner *session.Principal
}
SessionMetadataPageRequest asks an optional pager for one bounded metadata page. Ownership is part of the storage query so filtering happens before page formation and TotalCount; a nil Owner with OwnershipEnforced selects no rows.
type SessionMetadataPager ¶
type SessionMetadataPager interface {
PageSessionMetadata(ctx context.Context, request SessionMetadataPageRequest) (SessionMetadataPage, error)
}
SessionMetadataPager is the OPTIONAL bounded inventory seam. SessionStore remains the required Save/Load pair. Implementations order rows by (ModifiedAt DESC, ID ASC), filter ownership before paging/counting, return at most request.Limit rows, and use strict keyset continuation after Cursor.
type SessionMigrationError ¶
type SessionMigrationError struct {
ItemHandle string `json:"item_handle"`
ReasonCode string `json:"reason_code"`
Message string `json:"message"`
}
SessionMigrationError is one bounded, content-free terminal item result.
type SessionMigrationFamily ¶
type SessionMigrationFamily struct {
ID session.SessionID `json:"-"`
Handle string `json:"handle"`
Fingerprint string `json:"fingerprint"`
OwnerKey string `json:"owner_key"`
Kind session.SessionKind `json:"kind"`
State session.State `json:"state"`
Bytes int64 `json:"bytes"`
}
SessionMigrationFamily is an adapter-private candidate projected through the optional migration capability. ID is never exposed on a management response.
type SessionMigrationInspection ¶
type SessionMigrationInspection struct {
Available bool
Generation string
V1Families int64
V2Families int64
InvalidFamilies int64
SkippedFamilies int64
CurrentBytes int64
ReclaimableBytes int64
TemporaryBytes int64
Families []SessionMigrationFamily
}
SessionMigrationInspection is a read-only physical inventory. Families is consumed only by the server orchestrator and is not a wire projection.
type SessionMigrationJob ¶
type SessionMigrationJob struct {
ID string `json:"id"`
PrincipalKey string `json:"principal_key"`
State SessionMigrationState `json:"state"`
Generation string `json:"generation"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
V1Families int64 `json:"v1_families"`
V2Families int64 `json:"v2_families"`
InvalidFamilies int64 `json:"invalid_families"`
SkippedFamilies int64 `json:"skipped_families"`
CurrentBytes int64 `json:"current_bytes"`
ReclaimableBytes int64 `json:"reclaimable_bytes"`
TemporaryBytes int64 `json:"temporary_bytes"`
Processed int64 `json:"processed"`
Migrated int64 `json:"migrated"`
Failed int64 `json:"failed"`
Errors []SessionMigrationError `json:"errors,omitempty"`
TerminalItems map[string]bool `json:"terminal_items,omitempty"`
}
SessionMigrationJob is durable resumable progress. PrincipalKey is a one-way binding and must never be projected to clients.
type SessionMigrationState ¶
type SessionMigrationState string
SessionMigrationState is the durable lifecycle of a semantics-preserving session snapshot migration job.
const ( // SessionMigrationPlanned is a read-only plan not yet applied. SessionMigrationPlanned SessionMigrationState = "planned" // SessionMigrationRunning may process another bounded batch. SessionMigrationRunning SessionMigrationState = "running" // SessionMigrationCancelled retains committed work and stops future items. SessionMigrationCancelled SessionMigrationState = "cancelled" // SessionMigrationCompleted has converged all eligible families. SessionMigrationCompleted SessionMigrationState = "completed" )
type SessionMigrationStore ¶
type SessionMigrationStore interface {
InspectSessionMigration(context.Context) (SessionMigrationInspection, error)
MigrateSessionFamily(context.Context, SessionMigrationFamily) (string, error)
AcquireSessionMigrationJob(context.Context, string) (acquired context.Context, release func() error, err error)
CheckSessionMigrationJobOwnership(context.Context) error
SaveSessionMigrationJob(context.Context, SessionMigrationJob) error
LoadSessionMigrationJob(context.Context, string) (SessionMigrationJob, error)
}
SessionMigrationStore is an optional physical-maintenance capability. The engine loop never consumes it; authenticated server composition does. A mutating load-to-checkpoint sequence must hold AcquireSessionMigrationJob for the job's opaque ID. Implementations must provide stable cross-process exclusion, bind the exact acquisition to the returned context, and reject ownership checks, mutations, and checkpoints made without that acquisition. The returned release function relinquishes it and must be called exactly once.
type SessionStorageHealth ¶
type SessionStorageHealth struct {
Available bool
CurrentBytes int64
CurrentBytesAvailable bool
ReclaimableBytes int64
ReclaimableBytesAvailable bool
SessionCount int64
FileCount int64
V1Count int64
V2Count int64
MainCount int64
ChildCount int64
ScheduledCount int64
UnknownCount int64
CorruptCount int64
}
SessionStorageHealth is an aggregate, content-free measurement derived from a backend's bounded metadata index. Availability bits distinguish an honest zero measurement from a value the backend cannot provide.
type SessionStorageHealthProvider ¶
type SessionStorageHealthProvider interface {
SessionStorageHealth(ctx context.Context) (SessionStorageHealth, error)
}
SessionStorageHealthProvider is the OPTIONAL bounded storage-health seam. Implementations must use only an existing metadata index and cheap file/object metadata. They must not load session snapshots or traverse transcripts.
type SessionStore ¶
type SessionStore interface {
// Save persists the current state of s.
Save(ctx context.Context, s *session.Session) error
// Load retrieves the session with the given id. The not-found case MUST wrap
// port.ErrSessionNotFound; any other error is an infrastructure failure.
Load(ctx context.Context, id session.SessionID) (*session.Session, error)
}
SessionStore persists and retrieves server-side session state, enabling pause/resume and reload. Adapters provide an in-memory store (default) and an append-only JSONL replay log.
EVENT-SOURCED Load (the reconstruction contract). mecatl's own adapters persist a snapshot (engine/adapter/sessnap) and Load deserializes it. A host whose system of record is an append-only EVENT LOG instead may implement Load by FOLDING its event stream into a *session.Session — engine/adapter/eventsource.Fold is the reference implementation. Such a backend MUST populate the fields a caller relies on:
- MUST round-trip (a folded session must carry these): Conversation (the user/assistant/tool message sequence, tool-pairing-valid — user-role turns INCLUDED, since the loop emits the log-only EvUserPrompt at every user-message record site), State, the recorded stop reason, the pending ask (when awaiting), the failure permanence flag (ResultPayload.Permanent — so a permanently-failed session reconstructs with FailurePermanence()==true and the recover advisory fires), cumulative Usage (the SUM of every per-run EvResult.Usage — the budget brake reads it), and the metadata the events do not carry (id, mode, limits, workspace, profile, provider/model selector, reasoning effort, authoritative title/provenance, session kind/relationship, adoption source/request digest, createdAt — supplied out-of-band, e.g. eventsource.SessionMeta). A legacy empty title/provenance may be derived from the first genuine EvUserPrompt.
- Run-scoped: Counters reflect only the LATEST run segment (they reset on Reopen); the run plumbing (diagnostics binding, askID serials) is rebuilt fresh.
REPLAY-FIDELITY LIMITATION (the one residual gap): the opaque assistant-message replay fields — Message.Reasoning, Message.ProviderPhase, ToolCall.ItemID — are NOT carried on the event stream (they reach the conversation only via Session.RecordAssistant), so a pure event fold is byte-identical-replay faithful ONLY for providers that leave them empty (plain chat). A host that needs byte-identical replay for a reasoning provider must carry those fields in its OWN richer event schema. See engine/COMPATIBILITY.md ("Session reconstruction contract") and ADR 0038.
type StoredSession ¶
StoredSession is one stored session's retention-relevant identity: its id plus when its snapshot was last modified (Save time, file mtime, or the store's nearest equivalent). It deliberately carries NO session content — listing is a retention/inventory concern, never a load.
type StreamProgress ¶
type StreamProgress = session.StreamProgress
StreamProgress aliases the session-owned semantic progress vocabulary.
type StreamProgressError ¶
type StreamProgressError interface {
error
StreamProgress() session.StreamProgress
}
StreamProgressError exposes the semantic progress of a terminal stream error.
type ToolCallRecorder ¶
type ToolCallRecorder interface {
// ToolCall records that a tool was executed, with its result, the time it
// spent waiting in the dispatch queue before execution started (queued), and
// the wall time its execution then took (took).
//
// queued is the coordinated-omission measure: it is the gap between when the
// call ENTERED dispatch and when its execution actually began. For a read-only
// call cleared to run immediately it is near-zero; for a mutating call held by
// the read-parallel/mutate-serial ordering (or behind a permission ask) it is
// the real wait the model's call sat through. Both durations are 0 when no
// Clock is injected.
ToolCall(id session.SessionID, call session.ToolCall, result session.ToolResult, queued, took time.Duration)
}
ToolCallRecorder records structured observability for tool execution. It is a tool-call audit seam, distinct from the model-visible conversation and from any general-purpose diagnostic logging (see Diagnostics).
type TriggerKind ¶
type TriggerKind int
TriggerKind discriminates how a schedule fires.
const ( // TriggerNone is the zero value: no trigger set. A TriggerSpec with Kind() // TriggerNone is invalid (Validate rejects it). TriggerNone TriggerKind = iota // TriggerCron means the schedule fires on a cron expression (TriggerSpec.Cron). TriggerCron // TriggerOneShot means the schedule fires once at a wall-clock instant // (TriggerSpec.OneShot). TriggerOneShot )
type TriggerSpec ¶
type TriggerSpec struct {
// Cron is the cron expression (5-field or @-macro). Mutually exclusive with
// OneShot. Empty unless this is a cron trigger.
Cron string
// OneShot is the single wall-clock instant to fire at. Mutually exclusive with
// Cron. The zero time means "not set". It MUST be in the future at schedule
// creation; the store does not re-validate this on save.
OneShot time.Time
}
TriggerSpec is the sum type for a schedule's firing trigger. Exactly ONE of Cron / OneShot is set: Cron is a 5-field cron expression OR a macro (@every <duration>, @daily, @hourly, …) the composition layer parses via its cronparse dependency; OneShot is a single future wall-clock instant. The store is parser-free — it stores the raw expression verbatim and never interprets it; the CALLER (composition) computes the next fire and hands it to Claim.
The zero value is invalid (neither field set); Validate enforces the exactly-one invariant fail-closed.
func (TriggerSpec) Kind ¶
func (t TriggerSpec) Kind() TriggerKind
Kind reports which trigger arm is set, or TriggerNone if neither (or, defensively, both — Kind never lies about a single arm when both are set; Validate is the authoritative gate). It is a convenience discriminator for callers that have already validated the spec.
func (TriggerSpec) Validate ¶
func (t TriggerSpec) Validate() error
Validate enforces the exactly-one-of(Cron, OneShot) invariant: it returns an error (wrapping no sentinel — it is a value-object structural check, not a store error) if both are set or neither is set. It does NOT validate the cron expression's grammar (that is composition's job, which has the cronparse dep) — only the structural XOR.