Agent trajectory
Every step, tool proposal, approval, and dollar an agent run touches, appended once and replayed byte-identical, never a mutable log an incident review can't trust.
What it is
agent-trajectory is the append-only event contract a governed agent run writes into: eleven event kinds spanning run, step, model call, tool proposal/approval/result, and checkpoint, each Zod-`.strict()`-validated. Sensitive bodies (prompts, tool args, tool results) never inline; they're carried only as a sha256 `DigestRef`. A deterministic `project()` folds any event order into one byte-identical projection, and a park/approve/deny state machine holds paused runs with their snapshot encrypted at rest.
What ships in the module
Browser-safe entry point
Import @caisson/agent-trajectory/browser inside a client bundle for the strict event schema, the in-memory append-only store, the run-state port, both deterministic projections, and the Claude-transcript adapter, so a dashboard can replay and validate a trajectory client-side. The main entry keeps the full node-capable surface including the two Postgres-backed stores, and every browser-entry export is also on it.
Eleven-kind closed event vocabulary
EVENT_KINDS fixes the whole vocabulary, run.started/finished, step.started/finished, model.call, model.usage, tool.proposed/approved/denied/result, checkpoint. TrajectoryEvent is a Zod discriminatedUnion keyed on kind, each variant .strict(), so an unknown field or a made-up kind is rejected at the boundary, not silently stored.
Sensitive bodies never inline, DigestRef only
The rendered prompt on model.call, the tool arguments on tool.proposed, the tool output on tool.result, and the serialized state on checkpoint are all typed DigestRef, a sha256 digest, a byte length, and an optional encRef pointer. The trajectory log itself is safe to persist, replay, and anchor without ever holding the bodies it references.
Append-only store, idempotent and gap-rejecting
createMemoryTrajectoryStore()'s append() enforces a monotonic 0-based seq per run: re-appending a byte-identical event at an already-recorded seq is a no-op (safe retry), a different event at that seq throws ConflictError, and a seq beyond the next free slot throws too, no rewrites, no gaps.
billingStatus honesty bands, enforced by schema
Every model.usage event carries billingStatus: metered | priced | estimated | unsupported. A superRefine enforces the honesty: credits can only be nonzero on metered or priced events, and priceBookVersion provenance is only legal on priced, an estimated adapter output can never masquerade as a charge.
Deterministic replay: project() and projectToolCalls()
project() sorts by seq before folding, so a shuffled batch always resolves to the same RunProjection (step tree, per-band usage totals, checkpoints) with JSON.stringify byte-identical across runs. projectToolCalls() is the sibling fold an eval scorer reads: one entry per toolCallId with its proposal, approval/denial, and result.
Paused-run state, encrypted at rest
createPgRunStateStore()'s park() seals the caller's opaque parkedState through @caisson/field-crypto's encryptField before it reaches the row, keyed to the run's own primary key as the row-binding identity; claimResume() is the only path that opens it back. deny() and finish() null the snapshot out on every terminal transition, a run that will never resume keeps no plaintext around.
.strict()
.superRefine((v, ctx) => {
// The previously comment-only invariant, now enforced (ADR-0360 U-4): credit claims are only
// legal on billing-grade bands; provenance only decorates the band it explains.
if (
v.credits > 0 &&
v.billingStatus !== "metered" &&
v.billingStatus !== "priced"
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["credits"],
message: `credits must be 0 when billingStatus is "${v.billingStatus}" (only metered/priced carry credit claims)`,
});
}
if (v.priceBookVersion !== undefined && v.billingStatus !== "priced") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["priceBookVersion"],
message: `priceBookVersion is only valid on billingStatus "priced" (got "${v.billingStatus}")`,
});
}
});- superRefine rejects a nonzero credits value on any billingStatus other than metered or priced, an estimated adapter's token count can never be smuggled in as a charge.
- priceBookVersion is only legal on a priced event, the schema itself pins provenance to the band it explains, not left to caller discipline.