Skip to content

Latest commit

 

History

History
289 lines (207 loc) · 15.3 KB

File metadata and controls

289 lines (207 loc) · 15.3 KB

@takk/agenticstash - Technical Specification

Version: 1.0.0 Status: Stable License: Apache-2.0

This document is the binding contract between @takk/agenticstash and its consumers. Behavior described here is covered by SemVer: breaking changes require a major version bump and a deprecation cycle (see SEMVER POLICY).


1. Purpose

agenticstash is a universal, zero-runtime-dependency library and CLI that records every source of non-determinism a Massive Intelligence (IM) agent run touches, then serves it back on replay, so an irreproducible production run becomes step-through-debuggable. It is the rr of the agentic world, the git stash of agent runs:

  • Record model outputs, tool and MCP responses, the clock, randomness, and any wrapped call.
  • Replay them deterministically, re-throwing recorded errors at the original site.
  • Detect divergence between a recording and the code under replay, in one pass.
  • Fork a run at a decision point to explore an alternate branch.
  • Diff two recordings to find exactly where they differ.
  • Seal a recording with a tamper-evident SHA-256 digest, and verify it later.
  • Redact secrets and PII before they ever reach a recording.

It is library-shaped, not service-shaped: no central server, no SaaS dependency, no SDK lock-in. Determinism is by substitution: the library replays the values the original run observed, it does not make a model deterministic. A code path that changes its calls or their order will, by design, diverge.


2. Public surface

2.1 Entry points

The package ships ten subpath exports, each with separate import (ESM) and require (CJS) conditions and matching .d.ts / .d.cts files:

Subpath Default Use
. ./dist/index.{js,cjs} Core: createStash, loadStash, re-exports, types, errors
./record ./dist/record/index.{js,cjs} createRecorder, DROP
./replay ./dist/replay/index.{js,cjs} createReplayer
./storage ./dist/storage/index.{js,cjs} BlobStore, encodeRecording, decodeRecording, recordingStats
./fork ./dist/fork/index.{js,cjs} fork
./diff ./dist/diff/index.{js,cjs} diffRecordings
./interceptors ./dist/interceptors/index.{js,cjs} createDeterministicClock, createSeededRandom, wrap, wrapSync
./mcp ./dist/mcp/index.{js,cjs} interceptMcpClient, recordMcpTool
./seal ./dist/seal/index.{js,cjs} sealRecording, verifyRecording
./edge ./dist/edge/index.{js,cjs} The full core under a worker condition
./package.json ./package.json Manifest access for tooling

An agenticstash binary is exposed via package.json#bin -> ./dist/cli/index.js.

The whole engine is Node-free (the seal uses the Web Crypto API, not node:crypto), so ./edge re-exports the entire core verbatim; there is no reduced subset.

2.2 Core API

createStash(options?: StashOptions): Stash

The unified record-or-replay facade. Instrument an agent once; construct without a replay recording to record, with one to replay the same calls.

interface Stash {
  readonly mode: 'record' | 'replay';
  intercept<T>(channel, key, produce: () => Promise<T> | T, options?: InterceptOptions): Promise<T>;
  interceptSync<T>(channel, key, produce: () => T, options?: InterceptOptions): T;
  value<T>(channel, key, value: T, options?: InterceptOptions): T;
  recording(): Recording;
  save(): string;
  consumed(): number;          // replay mode; 0 when recording
  remaining(): RecordedEvent[];// replay mode; [] when recording
  divergences(): Divergence[]; // replay mode with onDivergence: 'collect'
  report(): DivergenceReport;
}

loadStash(json, { strict?, onDivergence? }) decodes a recording and returns a replay-mode stash.

Recording and replay

  • createRecorder(options?) returns a Recorder (record, recordAsync, value, recording, size). DROP is the sentinel a RedactFn returns to store a metadata-only event.
  • createReplayer(recording, { strict?, onDivergence? }) returns a Replayer (replay, replayAsync, consumed, remaining, divergences, report). With onDivergence: 'collect' it records every divergence and keeps replaying so one pass yields a full DivergenceReport.

Storage, fork, diff, seal

  • BlobStore, encodeRecording(recording): string, decodeRecording(json): Recording (validates shape, throws ERR_INVALID_RECORDING), recordingStats(recording): RecordingStats.
  • fork(recording, { at, override?, id? }): Recording.
  • diffRecordings(a, b): DiffResult.
  • sealRecording(recording): Promise<RecordingSeal>, verifyRecording(recording, seal): Promise<VerifyResult>. The seal is a SHA-256 hash chain, an integrity seal, not a digital signature.

Interceptors and the MCP bridge

  • createDeterministicClock(stash, source?, key?), createSeededRandom(stash, seed?, key?), wrap(stash, channel, key, fn), wrapSync(...).
  • interceptMcpClient(stash, client), recordMcpTool(stash, name, handler), both duck-typed, no MCP SDK import.

2.3 Error model

Agentic Stash throws a single error type on purpose. There is no class hierarchy; branch on the stable code, never on the message.

type AgenticStashErrorCode =
  | 'ERR_DIVERGENCE'           // a strict replay saw a call that does not match the recording
  | 'ERR_RECORDING_EXHAUSTED' // replay asked for an event past the recording (no live tail)
  | 'ERR_INVALID_RECORDING'   // a recording failed validation on load
  | 'ERR_INVALID_INPUT'       // a malformed argument, option, or non-serializable value
  | 'ERR_NOT_FOUND';          // a missing blob, event, or record

isAgenticStashError(value): value is AgenticStashError is the exported type guard. details is safe to log and never contains secrets.

2.4 Data model

The atom is RecordedEvent; a Recording is the ordered tape plus a content-addressed blob table.

interface RecordedEvent {
  seq: number;        // global monotonic order, zero-based
  channel: string;    // 'llm', 'tool', 'mcp', 'clock', 'random', or custom
  key: string;        // stable call-site key within the channel
  ordinal: number;    // ordinal within (channel, key)
  outcome: 'return' | 'throw';
  ref: string;        // content-addressed reference into Recording.blobs
  inputRef?: string;  // content hash of the input, when supplied (divergence detection)
  at?: number;        // informational wall-clock time; never used for replay
  label?: string;
}

interface Recording {
  version: 1;
  id: string;
  events: readonly RecordedEvent[];
  blobs: Readonly<Record<string, unknown>>;
  meta: Readonly<Record<string, unknown>>;
}

The stable, SemVer-protected shapes are: Channel, Outcome, StashMode, RecordedEvent, Recording, InterceptOptions, StashOptions, Stash, RecordingStats, Divergence, DivergenceKind, DivergenceReport, RedactContext, RedactFn, RecordingSeal, VerifyResult, plus the per-module option and result types (RecorderOptions, ReplayerOptions, ForkOptions, DiffResult, EventOnly, EventChange, DeterministicClock, SeededRandom, McpToolCall, McpClientLike).


3. Architecture

+-----------------------------------------+
| Caller code (instrumented once)         |
| const s = createStash()  // or {replay} |
| await s.intercept('llm','plan', call)   |
+-------------------+---------------------+
                    | record OR replay (same call site)
        +-----------+-----------+
        v                       v
+----------------+      +----------------------+
| record         |      | replay               |
| capture value  |      | serve recorded value |
| or throw       |      | re-throw recorded err|
| store as event |      | check input hash     |
+-------+--------+      | collect divergences  |
        |              +----------+-----------+
        v                         |
+-----------------------------------------+
| storage: content-addressed Recording    |
| (BlobStore dedup + encode/decode)       |
+-------------------+---------------------+
        +-----------+-----------+-----------------+
        v           v           v                 v
   +--------+  +--------+  +-----------+    +-------------+
   | fork   |  | diff   |  | seal      |    | redact      |
   | branch |  | a vs b |  | SHA-256   |    | mask / DROP |
   +--------+  +--------+  | hash chain|    | at record   |
                          +-----------+    +-------------+

3.1 Recording and replay

The recorder runs a producer, stores its return value or thrown error as a content-addressed blob, and appends an event keyed by (channel, key, ordinal). The replayer serves the next recorded event for a (channel, key) pair in order. When an input is supplied, its content hash is recorded; on a strict replay a mismatch is a divergence.

3.2 Divergence

onDivergence: 'throw' (default) raises ERR_DIVERGENCE on the first input mismatch. onDivergence: 'collect' records each divergence (input-mismatch, extra-call, missing-call) and keeps replaying, so report() returns the full DivergenceReport with firstDivergence in one pass.

3.3 Storage

BlobStore deduplicates structurally equal payloads via a dependency-free cyrb53 content hash (a non-cryptographic addressing hash with full-content collision safety), with structuredClone defensive copies. Recordings serialize to portable JSON.

3.4 Fork

fork keeps every event before at, optionally overrides the decision at at with a new value, and drops the tail. Replaying the fork serves the shared prefix then runs live (an opt-in live tail), so a single recorded run roots an alternate branch.

3.5 Diff

diffRecordings aligns events by (channel, key, ordinal) identity, not raw sequence, and reports added, removed, and changed events plus the earliest firstDivergence.

3.6 Seal

sealRecording folds the recording id and every event (with its value and input payloads) into a SHA-256 hash chain via the Web Crypto API and returns the root digest. verifyRecording recomputes the chain and reports whether the recording is byte-for-byte the one sealed. Any change to an event, a value, their order, or the id breaks the root. It is an integrity seal, not a digital signature.

3.7 Redaction

A RedactFn on the stash or recorder transforms each value and input before storage. Return a masked value to keep structure, or DROP to store only a marker (metadata-only). Redaction is one-way: a redacted field replays as its redacted form.


4. Operational SLOs

The library is small; targets here are runtime characteristics, not service SLOs.

Target Budget
Runtime dependencies (required) 0
ESM core bundle (dist/index.js, brotli) <= 12 kB
CJS core bundle (dist/index.cjs, brotli) <= 12 kB
seal subpath (brotli) <= 5 kB
edge ESM bundle (brotli) <= 12 kB
Tarball size (full package) <= 200 KB
Engines Node >= 20.0.0
Runtime (Node, Deno, Bun, edge/worker, browser bundlers) full core in every target

The bundle budgets are enforced in CI via size-limit (.size-limit.json); the rest are design intent.


5. Stability promise

5.1 What counts as the public API

For 1.0.0 onward:

  • Every name exported from ./dist/index.{js,cjs,d.ts} and from each subpath export.
  • Every type, interface, function signature, and string-literal union variant reachable from those exports.
  • The shape of every type listed in §2.4.
  • The AgenticStashErrorCode values.
  • The recording envelope (version: 1) and the seal hash-chain algorithm (a change to either breaks existing recordings or seals and is a major).
  • The CLI flags and subcommands of agenticstash.

Not part of the public API:

  • Anything inside src/ not re-exported from a documented entry point.
  • The internal blob-addressing hash (cyrb53), as long as content addressing and dedup hold within a recording.
  • The exact wording of error messages and CLI help text.

5.2 SemVer policy

Change Bump
Bug fix, internal refactor, doc-only patch (1.0.0 -> 1.0.1)
New export, new optional field, new optional surface minor (1.0.0 -> 1.1.0)
Renaming/removing an export, signature change, recording-shape or seal-format change, CLI flag removal major (1.0.0 -> 2.0.0)

5.3 Deprecation policy

Breaking a public API requires:

  1. Announce the deprecation in a minor release of the current major: add @deprecated JSDoc on the export and a runtime console.warn (debounced once per process).
  2. Ship the deprecated API for at least one further minor of the same major. Consumers must always have a non-deprecated path.
  3. Remove only in the next major release, accompanied by a MIGRATING.md with a migration recipe.

Security-driven exceptions ship in the next patch across all supported majors with a ### Security CHANGELOG entry.

5.4 License and provenance invariants

  • License stays Apache-2.0 within a major.
  • NOTICE is preserved verbatim in the tarball.
  • Every release is published with --provenance (SLSA attestation by GitHub Actions). Consumers can verify via npm view @takk/agenticstash@<version> --json | jq .dist.attestations.

6. Runtime expectations

  • agenticstash is a library; it makes no network calls and reads no environment at import time.
  • Recording and replay are deterministic given the same calls in the same order; the only stateful surface is the in-memory event tape the stash holds.
  • The recorder is transparent: it returns the producer's value (or re-throws its error) unchanged, so wrapping a call does not alter behavior while recording.
  • No function persists to disk. The CLI reads only the input files you point it at and writes only to the paths you specify.

7. Test surface

  • Unit tests for hashing and serialization, the recorder, the replayer (including divergence collection), storage, fork, diff, interceptors, the MCP bridge, sealing and verification, redaction, and the error model.
  • CLI tests that drive the command logic in-process via an injected CliIO and assert exit codes (sysexits: 0 success, 64 usage, 65 bad data, 66 unreadable input, 1 on a failed seal) and output.
  • A dist smoke script (scripts/smoke-dist.mjs) that exercises the built artefact end-to-end, including record-then-replay determinism, divergence detection, fork override, the seal, redaction, the MCP bridge, and the CLI exit codes.

Coverage thresholds are enforced via vitest.config.ts. Current run (1.0.0): 86 tests passing across 14 suites, statements 87.96%, branches 76.94%, functions 89.83%, lines 88.62%.


8. Non-goals (in 1.0)

  • A hosted replay UI or SaaS. Agentic Stash is an embeddable library; step-by-step state-inspection UI is a separate product.
  • Making a model deterministic. Determinism is by substitution of recorded values, not by constraining the model.
  • A digital signature. The seal is tamper-evident (a hash chain); authorship signing is deferred.
  • Concurrent same-key call ordering. Replay assumes a deterministic per-key call order; use distinct keys for concurrent same-key calls, or wait for the input-matched mode planned for 1.1.
  • Automatic instrumentation. Calls must be wrapped explicitly (intercept / wrap); provider adapters that lower this friction are on the roadmap.

See TASK.md for the live deferred-work list.