capture

package
v0.1.1 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: 22 Imported by: 0

Documentation

Overview

Package capture holds the receipt-building primitives every behalf capture surface needs: the cross-process monotonic counter, the intent and step-key digests, the payload-slot builder, the authority/attribution rollup, and the seal-sign-envelope step.

The lift, stated plainly

Every function here is a lift of an unexported function in internal/proxy/capture.go, byte-for-byte in behaviour. The proxy was NOT edited to call this package: the Week-3 hooks work that needed these primitives was scoped to leave the canonical capture surface alone, so for now there are two copies of each computation. That is a drift risk and it is named here rather than hidden.

The risk is contained by test, not by discipline: internal/hooks runs the real MCP proxy against a fake server and asserts that the proxy's own `attempt.intent_digest`, `step_key` and `emitter.counter` are exactly what this package computes for the same inputs (see internal/hooks/crosssurface_test.go). If a future change moves one copy, that test fails. The follow-up is to point internal/proxy at this package and delete its private copies.

One constant here is load-bearing across processes rather than merely duplicated: CounterLockFile MUST match the proxy's `counterLockFile`, or the two surfaces will allocate the same counter value concurrently and Q48's gap detector will report loss that never happened.

Index

Constants

View Source
const (
	ProvenanceCaller       = "caller"
	ProvenanceHookSession  = "hook-session"
	ProvenanceTraceparent  = "traceparent"
	ProvenanceProxySession = "proxy-session"
)

The Q7 run_id precedence rungs, matching the schema's `run_id_provenance` enum. `hook-session` is the Claude Code rung; `proxy-session` is the last-resort "this capture process's own session", which the hook surface also falls back to when a payload carries no session id.

View Source
const (
	// EnvRunID is the caller/SDK-supplied key — the top rung, and the one
	// thing that makes two capture surfaces agree on a run_id (Q7).
	EnvRunID = "BEHALF_RUN_ID"
	// EnvTraceparent is the W3C traceparent the caller exported.
	EnvTraceparent = "TRACEPARENT"
)

Environment variables the run_id precedence reads.

View Source
const CounterLockFile = "emitter.counter.lock"

CounterLockFile serializes the read-increment-write of the per-emitter monotonic counter across every process sharing one state dir (Q48).

This exact name is also spelled in internal/proxy. Two surfaces may run concurrently against one state directory — a Claude Code session driving both the hook binary and an MCP server wrapped in behalf-proxy is the normal case, not an exotic one — and a lock file with a different name is no lock at all.

Variables

This section is empty.

Functions

func Actor

func Actor(auth *receipt.Authority, labels map[string]string) *receipt.Actor

Actor names who acted, when the chain proves a key. The canonical actor identity is the deepest hop's key thumbprint — keys are what the cryptography proves — and self-reported names ride as verbatim asserted labels, per MCP's own warning that they are not verified by the protocol (Q16).

The frozen schema requires `actor.jkt` whenever `actor` is present, so a receipt with no chain has no actor object and therefore nowhere to put its labels. That is a real loss and the callers here handle it by also writing the raw source frame to the CAS, where the self-reported names survive as customer-held evidence rather than as receipt fields (Q49).

func Authority

func Authority(hops []aat.Hop, results []aat.HopResult, carriageRoute string) (*receipt.Authority, receipt.Attribution)

Authority builds the receipt's embedded chain and the two attribution axes from a verified chain (Q10, Q12, Q18).

A carried hop's own claim about its verification status is discarded: what is recorded is what this surface checked, because a token that grades itself is not evidence (Q29). A hop with no result records `asserted` with a reason — never an empty status, which the frozen schema rejects and a reader would misread.

carriageRoute is stamped on every hop: how the chain reached this surface. It is metadata, since verification comes from the signatures regardless (Q15).

func Emit

func Emit(key *identity.Key, r *receipt.Receipt) (receiptID string, env []byte, err error)

Emit seals the receipt once, signs those exact bytes with the emitter key (DSSE/PAE), and returns the stored envelope bytes. Seal is the single serialization point: nothing downstream re-marshals the payload (export-format-v1.md §1.2).

func FieldDigests

func FieldDigests(raw []byte) *receipt.Manifest

FieldDigests is the Q37 field-digest manifest: one entry per top-level JSON field, each carrying the SHA-256 of that field's exact raw value bytes. The computation lives in internal/payload, which also reads it back at rehydration time — one implementation, because two would drift and a drifted comparison would report field changes that never happened (Q83).

func IntentDigest

func IntentDigest(name string, args []byte) string

IntentDigest is sha256 over an operation name, a newline, and the raw argument bytes — the anchor an orphan_intent, a denial or a failed delegation points at when there is no action to reference (Q4, Q5).

The proxy feeds it the MCP tool name and the raw `params` bytes as forwarded. A hook feeds it the normalised operation name and the raw `tool_input` bytes. The construction is the same; the inputs are what each surface could actually see, which is why the two surfaces' digests do not collide and cannot be compared (see internal/hooks/dedup.go).

func JWKThumbprint

func JWKThumbprint(jwk map[string]any) string

JWKThumbprint returns the RFC 7638 thumbprint of an OKP/Ed25519 JWK, or "" for anything else — v1 proves Ed25519 keys and says nothing about the rest (Q17).

func NextCounter

func NextCounter(stateDir string) (int, error)

NextCounter allocates the next per-emitter monotonic counter under stateDir, atomically across processes. Stamped before spooling so loss or reordering between capture and append is detectable (Q48).

func NormalizedArgSchema

func NormalizedArgSchema(args []byte) string

NormalizedArgSchema renders the sorted top-level key paths of an arguments object. Absent or non-object arguments normalize to "".

func RFC3339

func RFC3339(t time.Time) string

RFC3339 renders a capture timestamp the way the schema's `captured_at` wants it.

func RetainHopTokens

func RetainHopTokens(blobs *cas.Store, hops []aat.Hop) error

RetainHopTokens writes every signed hop's compact JWS to the customer-held CAS, so that the `evidence_ref` each hop already carries resolves to something.

Why this had to exist

The frozen schema types a hop's `verification.evidence_ref` as "what a reader should fetch": `sha256:<digest of the hop's compact JWS>`. Every capture surface wrote that value from the first record. Nothing wrote the blob. So the reference pointed into an empty store, and the delegation chain's signatures — the property that makes behalf different from a transparency log — were checked exactly once, at capture, by behalf's own code, with the evidence then discarded.

That is the self-graded exam this codebase refuses everywhere else (see aat.Mint's doc on why `verification` is not a claim). The receipt says `verified`; a receipt is not evidence of its own verification; and until this call there was nothing left for a sceptic to re-run the check against. It is also the reason the offline verifier cannot check chains at all (ENG-38): the tokens never reached an export because they never reached the store.

The digest is the same function on both sides — `cas.Digest` and `aat.ParHash` are both lowercase-hex SHA-256 over the same bytes — so the blob lands at exactly the address the receipt already names. No receipt byte changes, which is what lets this be a fix rather than a schema migration.

Unsigned hops have no token and no evidence, and are skipped: the receipt records no `evidence_ref` for them either, because there is nothing to fetch.

Failure to store is returned rather than swallowed. A capture surface that cannot write to the customer's own store has a problem the caller needs to know about, and silently emitting a receipt whose evidence reference dangles is the state this function exists to end.

func Slot

func Slot(store *cas.Store, role string, raw []byte, contentType string) (receipt.Slot, error)

Slot writes raw into the customer-held CAS and returns the payload slot describing it: digest, custody, content type, size, content-address ref, state, and — for JSON — the field-digest manifest (Q34–Q38, Q83). The bytes are the customer's; behalf's record holds the digest and the reference, never the content (Q35).

func StepKey

func StepKey(name string, args []byte, ordinal int) string

StepKey is sha256 over the operation name, the normalized argument schema and the causal ordinal, each newline-separated (Q85). Identical calls hash the same across runs; a call whose argument shape changed hashes differently, which is what makes `behalf diff` work on day-one data.

func TraceIDFromTraceparent

func TraceIDFromTraceparent(tp string) string

TraceIDFromTraceparent extracts the 32-hex trace-id from a W3C traceparent header value ("00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>"). It is the `traceparent` rung of the Q7 run_id precedence, shared because both surfaces read the same header. Anything that does not parse yields "", which drops the rung.

Types

type IDSource

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

IDSource mints ULIDs from an injectable clock and entropy source. receipt_id is client-minted at capture so a retried send can never occupy two immutable chain positions (Q46). Both defaults are production behaviour — crypto/rand — and a deterministic recording overrides them.

func NewIDSource

func NewIDSource(entropy io.Reader) *IDSource

NewIDSource returns an ID source over entropy; nil means crypto/rand.

func (*IDSource) ULIDAt

func (s *IDSource) ULIDAt(t time.Time) string

ULIDAt mints a ULID whose timestamp is t.

Jump to

Keyboard shortcuts

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