spool

package
v0.1.2 Latest Latest
Warning

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

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

Documentation

Overview

Package spool is the MCP proxy's durable capture spool — the thing that makes Q4's intent contract true.

Custody begins at the capture surface (Q48): the proxy stamps a per-emitter monotonic counter and spools the INTENT durably before the tools/call request is forwarded, then spools the COMPLETION (the signed DSSE receipt envelope) once the response is observed. In the common case the two merge into one completion receipt; a crash between them leaves an intent with no completion, and recovery flushes it as an `orphan_intent` receipt carrying the spooled intent digest (Q4, Q5) — the payment-fired-agent-died case leaves evidence.

The proxy never appends to the log itself: one appender process per log (Q57). A drain moves spool -> log with at-least-once delivery, which is safe because ingest dedups on receipt_id (Q46).

Layout

A spool directory holds append-only segment files:

seg-<020d unix-nanos>-<8 hex>.jsonl        an active or sealed segment
seg-....jsonl.cursor                       bytes consumed by the drain
seg-....jsonl.done                         segment fully consumed

One JSON object per line, `\n`-terminated, and every record is fsync'd before Append returns — that is the durability the intent contract needs. Segment names sort lexicographically in creation order.

Records

{"type":"intent","intent_id":…,"intent_digest":…,"tool":…,…}
{"type":"completion","intent_id":…,"receipt_id":…,"envelope":{…}}

The completion's envelope is spliced in verbatim, never re-marshaled: the signed bytes are the stored bytes (Q27, export-format-v1.md §1.2).

Liveness and consumption

A writer holds an advisory lock on its own segment for the segment's lifetime. Recovery therefore distinguishes a segment another process is still writing (skip: its unmatched intents are calls in flight, not orphans) from a quiescent one (its unmatched intents are orphans). Completion matching always considers every scanned segment, so an intent completed after a rotation is still matched.

Consumption uses both mechanisms the brief allows, each where it fits: a `.cursor` sidecar records how many bytes of a segment the drain has consumed (so a live segment can be drained incrementally and never re-delivers), and a fully-consumed quiescent segment is renamed to `.done` so recovery need not scan it again. `.done` marking stops at the first segment that is not fully consumable, which preserves the invariant recovery depends on: if a segment is `.done`, every earlier segment is too, so a scanned intent can never have its completion hidden inside a `.done` file.

Index

Constants

View Source
const (
	TypeIntent     = "intent"
	TypeCompletion = "completion"
)

Record types.

View Source
const DefaultSegmentMaxBytes = 8 << 20

DefaultSegmentMaxBytes rotates a segment once it passes this size.

Variables

This section is empty.

Functions

func Open

func Open(dir string) (*Spool, *Recovery, error)

Open prepares dir, scans it for orphaned intents, and opens a fresh segment for this writer. The returned Recovery is never nil.

Types

type Completion

type Completion struct {
	IntentID  string
	ReceiptID string
	Envelope  []byte
}

Completion pairs an intent with the signed receipt envelope that closes it. Envelope holds the exact DSSE envelope bytes.

func ReadAll

func ReadAll(dir string) ([]Completion, error)

ReadAll returns every completion still in the spool, in spool order, without consuming anything or moving any cursor. It is the read-only view — what `behalf doctor` and the tests use to look at pending evidence without draining it.

type DrainStats

type DrainStats struct {
	Completions int // completion records delivered to the sink
	Segments    int // segments read
	Done        int // segments renamed to .done
}

DrainStats summarizes one drain pass.

func Drain

func Drain(dir string, sink func(Completion) error) (*DrainStats, error)

Drain delivers every not-yet-consumed completion to sink, in spool order, then records how far it got. Delivery is at-least-once: a crash between sink success and the cursor write re-delivers, which ingest dedups on receipt_id (Q46). A sink error stops the pass with the cursor advanced only over records the sink accepted.

type Emitter

type Emitter struct {
	JKT     string `json:"jkt"`
	Counter int    `json:"counter"`
}

Emitter identifies the capture surface and its monotonic counter (Q48). The counter is allocated when the intent is spooled and is carried by whichever receipt records that crossing — the completion normally, the orphan_intent after a crash — so appended receipts have no counter gaps.

type Intent

type Intent struct {
	Type            string  `json:"type"`
	IntentID        string  `json:"intent_id"`
	IntentDigest    string  `json:"intent_digest"` // sha256(tool + "\n" + raw params bytes)
	Tool            string  `json:"tool"`
	Target          string  `json:"target,omitempty"`
	CapturedAt      string  `json:"captured_at"` // RFC 3339
	Emitter         Emitter `json:"emitter"`
	RunID           string  `json:"run_id"`
	RunIDProvenance string  `json:"run_id_provenance"`
	StepKey         string  `json:"step_key,omitempty"`
	RiskClass       string  `json:"risk_class"`
	RiskPolicyDig   string  `json:"risk_policy_digest"`
	InputDigest     string  `json:"input_digest,omitempty"` // CAS address of the raw params bytes
	InputSize       int     `json:"input_size,omitempty"`
	ChainRef        string  `json:"chain_ref,omitempty"` // CAS address of the carried chain material
}

Intent is the durably-spooled record written before a tools/call is forwarded (Q4). Beyond the four capture facts the contract names (intent_id, intent_digest, tool, captured_at) plus the emitter stamp, it carries exactly what minting a schema-valid `orphan_intent` receipt needs after a crash: the capture-time run grouping, step key, risk assignment and payload/chain references. All of these are capture-time facts that cannot be recovered later (receipt-schema-v1.md §9).

type Recovery

type Recovery struct {
	Orphans []Intent
}

Recovery is what Open found: intents in quiescent segments with no completion anywhere. The caller mints an `orphan_intent` receipt for each and spools it as a completion of its own (Q4).

func Recover

func Recover(dir string) (*Recovery, error)

Recover scans dir and returns the intents that have no completion and live in a segment no writer holds open. Intents in a live segment are calls in flight, not orphans, and are deliberately left alone.

type Spool

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

Spool is an open spool directory with one active segment.

func (*Spool) AppendCompletion

func (s *Spool) AppendCompletion(intentID, receiptID string, envelope []byte) error

AppendCompletion durably spools the signed receipt envelope that closes intentID. The envelope bytes are spliced in verbatim — the span rule.

func (*Spool) AppendIntent

func (s *Spool) AppendIntent(in Intent) error

AppendIntent durably spools an intent record. It returns only after the bytes are fsync'd: forwarding the request before this returns would put the trust-boundary crossing outside custody (Q4, Q48).

func (*Spool) Close

func (s *Spool) Close() error

Close seals the active segment and releases its lock.

func (*Spool) Dir

func (s *Spool) Dir() string

Dir returns the spool directory.

Jump to

Keyboard shortcuts

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