@statewavedev/sdk 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,9 +97,9 @@ console.log(`${timeline.episodes.length} episodes, ${timeline.memories.length} m
97
97
  await sw.deleteSubject("user-42");
98
98
  ```
99
99
 
100
- ## Governance & audit (v0.8)
100
+ ## Governance & audit (v0.8+)
101
101
 
102
- The SDK surfaces the [state-assembly receipts](https://github.com/smaramwbc/statewave-docs/blob/main/receipts.md) and [sensitivity-labels / policy](https://github.com/smaramwbc/statewave-docs/blob/main/sensitivity-labels.md) layer added in server v0.8.
102
+ The SDK surfaces the [state-assembly receipts](https://github.com/smaramwbc/statewave-docs/blob/main/receipts.md) and [sensitivity-labels / policy](https://github.com/smaramwbc/statewave-docs/blob/main/sensitivity-labels.md) layer added in server v0.8, plus the v0.9 [HMAC signing](https://github.com/smaramwbc/statewave/blob/main/docs/state-assembly-receipts.md) and [as-of replay](https://github.com/smaramwbc/statewave/blob/main/docs/replay.md) surfaces.
103
103
 
104
104
  ```typescript
105
105
  import { StatewaveClient } from "@statewavedev/sdk";
@@ -140,6 +140,39 @@ for (const r of receipts) {
140
140
  console.log(r.receiptId, r.task);
141
141
  }
142
142
 
143
+ // Verify the HMAC signature on a stored receipt (v0.9+).
144
+ // `valid` is true | false | null — see ReceiptVerifyResult for the
145
+ // full reason vocabulary (no_signature / key_unavailable / etc.).
146
+ if (bundle.receiptId) {
147
+ const verdict = await sw.verifyReceipt(bundle.receiptId);
148
+ if (verdict.valid === true) {
149
+ console.log(`signature OK — signed by ${verdict.keyId}`);
150
+ } else if (verdict.valid === false) {
151
+ console.log("signature mismatch — body may have been tampered with");
152
+ } else {
153
+ console.log(`verdict undetermined: ${verdict.reason}`);
154
+ }
155
+ }
156
+
157
+ // Replay the receipt against current memories using the original
158
+ // policy bundle captured on the receipt (v0.9+). Returns a diff
159
+ // envelope showing what changed since emission. Pre-v0.9 receipts
160
+ // throw StatewaveUnreplayableError with reason="missing_policy_snapshot".
161
+ import { StatewaveUnreplayableError } from "@statewavedev/sdk";
162
+ try {
163
+ const replay = await sw.replayReceipt(bundle.receiptId!);
164
+ if (replay.diff.contextHash.changed) {
165
+ console.log(`replay differs from original: new id ${replay.replayReceiptId}`);
166
+ }
167
+ } catch (err) {
168
+ if (err instanceof StatewaveUnreplayableError) {
169
+ // err.reason ∈ {"missing_policy_snapshot", "nested_replay", "invalid_snapshot"}
170
+ console.log(`replay refused: ${err.reason}`);
171
+ } else {
172
+ throw err;
173
+ }
174
+ }
175
+
143
176
  // Set per-memory sensitivity labels (server normalizes — dedup, lowercase, trim).
144
177
  // Memories with labels become subject to any active policy bundle for the tenant.
145
178
  const updated = await sw.setMemoryLabels({
@@ -246,7 +279,10 @@ All response types are fully typed:
246
279
  - `BatchCreateResult` — batch ingestion response
247
280
  - `SubjectSummary` — subject with episode/memory counts
248
281
  - `ListSubjectsResult` — paginated subject listing
249
- - `Receipt` + `ReceiptSelectedEntry` + `ReceiptPolicy` + `ReceiptOutput` — state-assembly audit artifact (v0.8) and its nested shapes
282
+ - `Receipt` + `ReceiptSelectedEntry` + `ReceiptPolicy` + `ReceiptOutput` — state-assembly audit artifact (v0.8+) and its nested shapes; v0.9 added HMAC signature fields (`receiptSignatureKeyId`, `receiptSignatureAlgorithm`), `policySnapshot` for replay, and `region` for residency
283
+ - `ReceiptVerifyResult` — `valid` (true | false | null) + `keyId` + `algorithm` + `reason` for the v0.9 HMAC verify endpoint
284
+ - `ReceiptReplayResult` / `ReceiptReplayDiff` — original + replay receipt ids plus the structural diff envelope from `POST /v1/receipts/{id}/replay` (v0.9)
285
+ - `StatewaveUnreplayableError` — thrown by `replayReceipt(...)` on HTTP 422; `.reason` is a discriminated union of `"missing_policy_snapshot" | "nested_replay" | "invalid_snapshot"`
250
286
  - `ReceiptList` — cursor-paginated receipt listing
251
287
  - `Health` + `HealthFactor` — customer health score and its explainable factors
252
288
  - `SLASummary` + `SessionSLA` — SLA metrics, aggregate and per-session
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BatchCreateResult, ClientOptions, CompileJob, CompileResult, ContextBundle, CreateEpisodeParams, CreateHandoffParams, CreateResolutionParams, DeleteResult, Episode, GetContextParams, GetSLAParams, Handoff, Health, ListReceiptsParams, ListResolutionsParams, ListSubjectsResult, Memory, Receipt, ReceiptList, Resolution, SLASummary, SearchMemoriesParams, SearchResult, SetMemoryLabelsParams, Timeline } from "./types.js";
1
+ import type { BatchCreateResult, ClientOptions, CompileJob, CompileResult, ContextBundle, CreateEpisodeParams, CreateHandoffParams, CreateResolutionParams, DeleteResult, Episode, GetContextParams, GetSLAParams, Handoff, Health, ListReceiptsParams, ListResolutionsParams, ListSubjectsResult, Memory, Receipt, ReceiptList, ReceiptReplayResult, ReceiptVerifyResult, Resolution, SLASummary, SearchMemoriesParams, SearchResult, SetMemoryLabelsParams, Timeline, UnreplayableReason } from "./types.js";
2
2
  /** Structured error from the Statewave API. */
3
3
  export declare class StatewaveAPIError extends Error {
4
4
  readonly statusCode: number;
@@ -11,6 +11,26 @@ export declare class StatewaveAPIError extends Error {
11
11
  export declare class StatewaveConnectionError extends Error {
12
12
  constructor(message?: string);
13
13
  }
14
+ /**
15
+ * Raised by `replayReceipt(...)` when the server refuses with HTTP 422.
16
+ * Subclass of `StatewaveAPIError` so generic handlers still catch it;
17
+ * adds a typed `reason` field so callers can branch on the structured
18
+ * refusal vocabulary without parsing the error code.
19
+ *
20
+ * `reason` is a discriminated union of:
21
+ * - `"missing_policy_snapshot"` — pre-v0.9 receipt. No
22
+ * `policySnapshot` was captured at emission and the replay engine
23
+ * cannot synthesise one retroactively.
24
+ * - `"nested_replay"` — the receipt is itself a replay
25
+ * (`mode === "as_of_replay"`). v0.9 ships one level only; replay
26
+ * the source receipt referenced by `parentReceiptId` instead.
27
+ * - `"invalid_snapshot"` — the snapshot's YAML failed to parse.
28
+ * Tampering or corruption at the column level.
29
+ */
30
+ export declare class StatewaveUnreplayableError extends StatewaveAPIError {
31
+ readonly reason: UnreplayableReason;
32
+ constructor(reason: UnreplayableReason, statusCode: number, code: string, message: string, details?: unknown, requestId?: string);
33
+ }
14
34
  export declare class StatewaveClient {
15
35
  private baseUrl;
16
36
  private defaultHeaders;
@@ -47,6 +67,52 @@ export declare class StatewaveClient {
47
67
  * to fetch the next page.
48
68
  */
49
69
  listReceipts(params: ListReceiptsParams): Promise<ReceiptList>;
70
+ /**
71
+ * Verify the HMAC signature on a stored receipt (v0.9+ #157).
72
+ *
73
+ * Returns a `ReceiptVerifyResult` with `valid` ∈ `{true, false, null}`:
74
+ * - `true` — signature matches the canonical body (`reason === "ok"`).
75
+ * - `false` — signature does not cover the body
76
+ * (`reason === "signature_mismatch"`).
77
+ * - `null` — verdict could not be determined; `reason` is one of
78
+ * `"no_signature"` (unsigned receipt — pre-v0.9 or tenant didn't
79
+ * opt in), `"key_unavailable"` (the keyId rotated out of operator
80
+ * config), or `"unsupported_algorithm"` (forward-compat).
81
+ *
82
+ * Comparison is constant-time on the server side. The signing key
83
+ * bytes never appear on the response — only the public `keyId` is
84
+ * echoed.
85
+ *
86
+ * Throws `StatewaveAPIError` on 404 (receipt not found or belongs to
87
+ * a different tenant — indistinguishable on the wire) and other
88
+ * non-2xx responses.
89
+ */
90
+ verifyReceipt(receiptId: string): Promise<ReceiptVerifyResult>;
91
+ /**
92
+ * Re-run the original retrieval against current memories using the
93
+ * original policy bundle captured in the receipt's `policySnapshot`
94
+ * (v0.9+ #159).
95
+ *
96
+ * Emits a new `mode="as_of_replay"` receipt with `parentReceiptId`
97
+ * pointing at the source; the original receipt is **never**
98
+ * modified. Returns the new `replayReceiptId` plus a structural
99
+ * diff envelope (added/removed selected entries, filter changes,
100
+ * context-hash diff).
101
+ *
102
+ * Semantic: current code + original policy. Replay is *not*
103
+ * byte-for-byte reproduction; memories that were added, tombstoned,
104
+ * or supersession-resolved between the original emission and now
105
+ * will appear in the diff. See `docs/replay.md` in the server repo
106
+ * for the design rationale.
107
+ *
108
+ * Throws `StatewaveUnreplayableError` (HTTP 422) when:
109
+ * - `reason === "missing_policy_snapshot"` — pre-v0.9 receipt.
110
+ * - `reason === "nested_replay"` — the receipt is itself a replay.
111
+ * - `reason === "invalid_snapshot"` — snapshot YAML failed to parse.
112
+ *
113
+ * Throws `StatewaveAPIError` on 404 and other non-2xx responses.
114
+ */
115
+ replayReceipt(receiptId: string): Promise<ReceiptReplayResult>;
50
116
  /**
51
117
  * Compute the customer health score (0–100) for a subject, with the
52
118
  * explainable factors that drove it. Backs proactive risk triage.
package/dist/client.js CHANGED
@@ -59,6 +59,40 @@ export class StatewaveConnectionError extends Error {
59
59
  this.name = "StatewaveConnectionError";
60
60
  }
61
61
  }
62
+ /** The documented refusal vocabulary for the v0.9 replay endpoint.
63
+ * Mirrors the `UnreplayableReason` type alias in `./types.ts`.
64
+ * Auto-promotion to `StatewaveUnreplayableError` only happens when
65
+ * the server returns a code with one of these reasons — a future
66
+ * unknown reason stays on the generic `StatewaveAPIError` path. */
67
+ const UNREPLAYABLE_REASONS = new Set([
68
+ "missing_policy_snapshot",
69
+ "nested_replay",
70
+ "invalid_snapshot",
71
+ ]);
72
+ /**
73
+ * Raised by `replayReceipt(...)` when the server refuses with HTTP 422.
74
+ * Subclass of `StatewaveAPIError` so generic handlers still catch it;
75
+ * adds a typed `reason` field so callers can branch on the structured
76
+ * refusal vocabulary without parsing the error code.
77
+ *
78
+ * `reason` is a discriminated union of:
79
+ * - `"missing_policy_snapshot"` — pre-v0.9 receipt. No
80
+ * `policySnapshot` was captured at emission and the replay engine
81
+ * cannot synthesise one retroactively.
82
+ * - `"nested_replay"` — the receipt is itself a replay
83
+ * (`mode === "as_of_replay"`). v0.9 ships one level only; replay
84
+ * the source receipt referenced by `parentReceiptId` instead.
85
+ * - `"invalid_snapshot"` — the snapshot's YAML failed to parse.
86
+ * Tampering or corruption at the column level.
87
+ */
88
+ export class StatewaveUnreplayableError extends StatewaveAPIError {
89
+ reason;
90
+ constructor(reason, statusCode, code, message, details, requestId) {
91
+ super(statusCode, code, message, details, requestId);
92
+ this.name = "StatewaveUnreplayableError";
93
+ this.reason = reason;
94
+ }
95
+ }
62
96
  export class StatewaveClient {
63
97
  baseUrl;
64
98
  defaultHeaders;
@@ -195,6 +229,56 @@ export class StatewaveClient {
195
229
  qs.set("limit", String(params.limit));
196
230
  return this.get(`/v1/receipts?${qs}`);
197
231
  }
232
+ /**
233
+ * Verify the HMAC signature on a stored receipt (v0.9+ #157).
234
+ *
235
+ * Returns a `ReceiptVerifyResult` with `valid` ∈ `{true, false, null}`:
236
+ * - `true` — signature matches the canonical body (`reason === "ok"`).
237
+ * - `false` — signature does not cover the body
238
+ * (`reason === "signature_mismatch"`).
239
+ * - `null` — verdict could not be determined; `reason` is one of
240
+ * `"no_signature"` (unsigned receipt — pre-v0.9 or tenant didn't
241
+ * opt in), `"key_unavailable"` (the keyId rotated out of operator
242
+ * config), or `"unsupported_algorithm"` (forward-compat).
243
+ *
244
+ * Comparison is constant-time on the server side. The signing key
245
+ * bytes never appear on the response — only the public `keyId` is
246
+ * echoed.
247
+ *
248
+ * Throws `StatewaveAPIError` on 404 (receipt not found or belongs to
249
+ * a different tenant — indistinguishable on the wire) and other
250
+ * non-2xx responses.
251
+ */
252
+ async verifyReceipt(receiptId) {
253
+ return this.get(`/v1/receipts/${encodeURIComponent(receiptId)}/verify`);
254
+ }
255
+ /**
256
+ * Re-run the original retrieval against current memories using the
257
+ * original policy bundle captured in the receipt's `policySnapshot`
258
+ * (v0.9+ #159).
259
+ *
260
+ * Emits a new `mode="as_of_replay"` receipt with `parentReceiptId`
261
+ * pointing at the source; the original receipt is **never**
262
+ * modified. Returns the new `replayReceiptId` plus a structural
263
+ * diff envelope (added/removed selected entries, filter changes,
264
+ * context-hash diff).
265
+ *
266
+ * Semantic: current code + original policy. Replay is *not*
267
+ * byte-for-byte reproduction; memories that were added, tombstoned,
268
+ * or supersession-resolved between the original emission and now
269
+ * will appear in the diff. See `docs/replay.md` in the server repo
270
+ * for the design rationale.
271
+ *
272
+ * Throws `StatewaveUnreplayableError` (HTTP 422) when:
273
+ * - `reason === "missing_policy_snapshot"` — pre-v0.9 receipt.
274
+ * - `reason === "nested_replay"` — the receipt is itself a replay.
275
+ * - `reason === "invalid_snapshot"` — snapshot YAML failed to parse.
276
+ *
277
+ * Throws `StatewaveAPIError` on 404 and other non-2xx responses.
278
+ */
279
+ async replayReceipt(receiptId) {
280
+ return this.post(`/v1/receipts/${encodeURIComponent(receiptId)}/replay`, undefined);
281
+ }
198
282
  // -- Support: health, SLA, handoff, resolutions ----------------------
199
283
  /**
200
284
  * Compute the customer health score (0–100) for a subject, with the
@@ -353,6 +437,17 @@ export class StatewaveClient {
353
437
  const body = await resp.json();
354
438
  const err = body?.error;
355
439
  if (err && typeof err.code === "string") {
440
+ // Promote unreplayable.<reason> refusals into a typed
441
+ // exception so callers can `catch (e) { if (e instanceof
442
+ // StatewaveUnreplayableError) ... e.reason }` without
443
+ // string-matching the error code. Forward-compat: an
444
+ // unrecognised future reason stays on the generic path.
445
+ if (resp.status === 422 && err.code.startsWith("unreplayable.")) {
446
+ const reason = err.code.slice("unreplayable.".length);
447
+ if (UNREPLAYABLE_REASONS.has(reason)) {
448
+ throw new StatewaveUnreplayableError(reason, resp.status, err.code, err.message ?? resp.statusText, err.details, err.request_id);
449
+ }
450
+ }
356
451
  throw new StatewaveAPIError(resp.status, err.code, err.message ?? resp.statusText, err.details, err.request_id);
357
452
  }
358
453
  }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { StatewaveClient, StatewaveAPIError, StatewaveConnectionError } from "./client.js";
1
+ export { StatewaveClient, StatewaveAPIError, StatewaveConnectionError, StatewaveUnreplayableError, } from "./client.js";
2
2
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { StatewaveClient, StatewaveAPIError, StatewaveConnectionError } from "./client.js";
1
+ export { StatewaveClient, StatewaveAPIError, StatewaveConnectionError, StatewaveUnreplayableError, } from "./client.js";
package/dist/types.d.ts CHANGED
@@ -101,14 +101,38 @@ export interface ReceiptOutput {
101
101
  canonicalizationVersion: number;
102
102
  tokenEstimate: number;
103
103
  }
104
+ /**
105
+ * v0.9 (#159) — self-contained policy bundle envelope embedded on
106
+ * every v0.9+ receipt. Self-sufficient: the replay engine evaluates
107
+ * against this bundle even if the live `policy_bundles` row has
108
+ * since been deleted or overwritten.
109
+ *
110
+ * - A null inner pair (`bundleHash` AND `bundleYaml` both null)
111
+ * records "no policy bundle was active at emission" — a valid,
112
+ * replayable state.
113
+ * - The whole envelope being absent (`Receipt.policySnapshot ===
114
+ * undefined`) marks "pre-v0.9 receipt, no snapshot was ever
115
+ * captured" — the replay endpoint refuses those.
116
+ */
117
+ export interface PolicySnapshot {
118
+ bundleHash: string | null;
119
+ bundleYaml: string | null;
120
+ /** ISO-8601 UTC timestamp captured at receipt emission. */
121
+ capturedAt: string;
122
+ }
104
123
  /**
105
124
  * Immutable per-retrieval audit artifact for a single context assembly.
106
125
  * See `docs/state-assembly-receipts.md` in the server repository.
126
+ *
127
+ * The `mode` discriminator distinguishes:
128
+ * - `"retrieval"` — receipts emitted by `/v1/context` + `/v1/handoff`.
129
+ * - `"as_of_replay"` — receipts emitted by `POST /v1/receipts/{id}/replay`
130
+ * (v0.9+); the `parentReceiptId` points at the source receipt.
107
131
  */
108
132
  export interface Receipt {
109
133
  receiptId: string;
110
134
  parentReceiptId: string | null;
111
- mode: "retrieval" | string;
135
+ mode: "retrieval" | "as_of_replay" | string;
112
136
  queryId: string | null;
113
137
  taskId: string | null;
114
138
  tenantId: string | null;
@@ -119,8 +143,24 @@ export interface Receipt {
119
143
  selectedEntries: ReceiptSelectedEntry[];
120
144
  policy: ReceiptPolicy;
121
145
  output: ReceiptOutput;
146
+ /** Server region the receipt was emitted from (v0.9+ residency).
147
+ * `null` in single-region deployments. */
122
148
  region: string | null;
149
+ /** HMAC-SHA256 hex digest over the canonical body (v0.9+ #157).
150
+ * `null` for pre-v0.9 receipts or tenants without signing
151
+ * configured — those verify cleanly as
152
+ * `{valid: null, reason: "no_signature"}`. */
123
153
  receiptSignature: string | null;
154
+ /** Operator key id used to sign (v0.9+). `null`/`undefined` when unsigned. */
155
+ receiptSignatureKeyId?: string | null;
156
+ /** Algorithm + canonical-form version (e.g. "hmac-sha256-canonical-v1")
157
+ * (v0.9+). `null`/`undefined` when unsigned. */
158
+ receiptSignatureAlgorithm?: string | null;
159
+ /** Embedded policy bundle YAML + hash + capture timestamp (v0.9+ #159).
160
+ * See `PolicySnapshot`. `undefined` for pre-v0.9 receipts (the
161
+ * replay endpoint refuses those with
162
+ * `unreplayable.missing_policy_snapshot`). */
163
+ policySnapshot?: PolicySnapshot | null;
124
164
  }
125
165
  export interface ReceiptList {
126
166
  receipts: Receipt[];
@@ -134,6 +174,75 @@ export interface ListReceiptsParams {
134
174
  cursor?: string;
135
175
  limit?: number;
136
176
  }
177
+ /**
178
+ * Result of `GET /v1/receipts/{id}/verify` (v0.9+ #157).
179
+ *
180
+ * `valid` is the verdict:
181
+ * - `true` — HMAC matches the canonical body. `reason === "ok"`.
182
+ * - `false` — math checked, signature does not cover the body.
183
+ * `reason === "signature_mismatch"`.
184
+ * - `null` — verdict could not be determined. `reason` is one of:
185
+ * - `"no_signature"` — receipt is unsigned (pre-v0.9 or tenant
186
+ * didn't opt in).
187
+ * - `"key_unavailable"` — the `keyId` rotated out of operator
188
+ * config; receipt is no longer verifiable on this binary.
189
+ * - `"unsupported_algorithm"` — receipt signed under a canonical
190
+ * form / algorithm variant this binary doesn't implement.
191
+ *
192
+ * Comparison is constant-time on the server side; the signing key
193
+ * bytes never appear on the response.
194
+ */
195
+ export interface ReceiptVerifyResult {
196
+ valid: boolean | null;
197
+ keyId: string | null;
198
+ algorithm: string | null;
199
+ reason: "ok" | "signature_mismatch" | "no_signature" | "key_unavailable" | "unsupported_algorithm" | string;
200
+ }
201
+ /**
202
+ * Structural diff envelope returned by `POST /v1/receipts/{id}/replay`.
203
+ * Entries are matched by their `memoryId` / `episodeId` so re-ranking
204
+ * the same entry is reported under `common`, not as add+remove.
205
+ */
206
+ export interface ReceiptReplayDiff {
207
+ contextHash: {
208
+ original: string | null;
209
+ replay: string | null;
210
+ changed: boolean;
211
+ };
212
+ selectedEntries: {
213
+ added: ReceiptSelectedEntry[];
214
+ removed: ReceiptSelectedEntry[];
215
+ common: number;
216
+ };
217
+ filtersApplied: {
218
+ added: unknown[];
219
+ removed: unknown[];
220
+ };
221
+ }
222
+ /**
223
+ * Response from `POST /v1/receipts/{id}/replay` (v0.9+ #159).
224
+ *
225
+ * Semantic: current code + original policy. Replay re-runs the
226
+ * original retrieval against the *current* memory state but with
227
+ * the *original* policy bundle frozen on the receipt's
228
+ * `policySnapshot`. The original receipt is never modified;
229
+ * `replayReceiptId` points at a new `mode="as_of_replay"` receipt
230
+ * linked back to the source via `parentReceiptId`.
231
+ *
232
+ * `replayReceiptId` is `null` when the replay-receipt write itself
233
+ * failed (rare, fail-open path). The `diff` envelope is still
234
+ * authoritative in that case.
235
+ */
236
+ export interface ReceiptReplayResult {
237
+ originalReceiptId: string;
238
+ replayReceiptId: string | null;
239
+ diff: ReceiptReplayDiff;
240
+ }
241
+ /** The set of refusal reasons the server returns from
242
+ * `POST /v1/receipts/{id}/replay` when a receipt cannot be replayed.
243
+ * Used by `StatewaveUnreplayableError.reason` so callers can switch
244
+ * on the structured value without parsing error code strings. */
245
+ export type UnreplayableReason = "missing_policy_snapshot" | "nested_replay" | "invalid_snapshot";
137
246
  export interface Timeline {
138
247
  subjectId: string;
139
248
  episodes: Episode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewavedev/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Official TypeScript SDK for Statewave — the open-source memory runtime for AI agents.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",