@attestry/sdk 0.6.0

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.
Files changed (99) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +1269 -0
  3. package/dist/client.d.ts +58 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +74 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/constants.d.ts +7 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +43 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/errors.d.ts +16 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +41 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/index.d.ts +17 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +20 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lines-parser.d.ts +50 -0
  20. package/dist/lines-parser.d.ts.map +1 -0
  21. package/dist/lines-parser.js +211 -0
  22. package/dist/lines-parser.js.map +1 -0
  23. package/dist/ndjson-parser.d.ts +57 -0
  24. package/dist/ndjson-parser.d.ts.map +1 -0
  25. package/dist/ndjson-parser.js +245 -0
  26. package/dist/ndjson-parser.js.map +1 -0
  27. package/dist/resources/abac-policies.d.ts +1034 -0
  28. package/dist/resources/abac-policies.d.ts.map +1 -0
  29. package/dist/resources/abac-policies.js +1519 -0
  30. package/dist/resources/abac-policies.js.map +1 -0
  31. package/dist/resources/audit-log.d.ts +588 -0
  32. package/dist/resources/audit-log.d.ts.map +1 -0
  33. package/dist/resources/audit-log.js +629 -0
  34. package/dist/resources/audit-log.js.map +1 -0
  35. package/dist/resources/batch.d.ts +845 -0
  36. package/dist/resources/batch.d.ts.map +1 -0
  37. package/dist/resources/batch.js +1074 -0
  38. package/dist/resources/batch.js.map +1 -0
  39. package/dist/resources/chat.d.ts +151 -0
  40. package/dist/resources/chat.d.ts.map +1 -0
  41. package/dist/resources/chat.js +124 -0
  42. package/dist/resources/chat.js.map +1 -0
  43. package/dist/resources/check.d.ts +348 -0
  44. package/dist/resources/check.d.ts.map +1 -0
  45. package/dist/resources/check.js +543 -0
  46. package/dist/resources/check.js.map +1 -0
  47. package/dist/resources/compliance-check.d.ts +330 -0
  48. package/dist/resources/compliance-check.d.ts.map +1 -0
  49. package/dist/resources/compliance-check.js +402 -0
  50. package/dist/resources/compliance-check.js.map +1 -0
  51. package/dist/resources/decisions.d.ts +1208 -0
  52. package/dist/resources/decisions.d.ts.map +1 -0
  53. package/dist/resources/decisions.js +1362 -0
  54. package/dist/resources/decisions.js.map +1 -0
  55. package/dist/resources/evidence-pack.d.ts +1080 -0
  56. package/dist/resources/evidence-pack.d.ts.map +1 -0
  57. package/dist/resources/evidence-pack.js +1789 -0
  58. package/dist/resources/evidence-pack.js.map +1 -0
  59. package/dist/resources/gate.d.ts +613 -0
  60. package/dist/resources/gate.d.ts.map +1 -0
  61. package/dist/resources/gate.js +737 -0
  62. package/dist/resources/gate.js.map +1 -0
  63. package/dist/resources/incidents.d.ts +136 -0
  64. package/dist/resources/incidents.d.ts.map +1 -0
  65. package/dist/resources/incidents.js +229 -0
  66. package/dist/resources/incidents.js.map +1 -0
  67. package/dist/resources/regulatory-changes.d.ts +307 -0
  68. package/dist/resources/regulatory-changes.d.ts.map +1 -0
  69. package/dist/resources/regulatory-changes.js +365 -0
  70. package/dist/resources/regulatory-changes.js.map +1 -0
  71. package/dist/resources/safe-input-read.d.ts +21 -0
  72. package/dist/resources/safe-input-read.d.ts.map +1 -0
  73. package/dist/resources/safe-input-read.js +57 -0
  74. package/dist/resources/safe-input-read.js.map +1 -0
  75. package/dist/resources/ship-gate.d.ts +475 -0
  76. package/dist/resources/ship-gate.d.ts.map +1 -0
  77. package/dist/resources/ship-gate.js +727 -0
  78. package/dist/resources/ship-gate.js.map +1 -0
  79. package/dist/resources/vision.d.ts +540 -0
  80. package/dist/resources/vision.d.ts.map +1 -0
  81. package/dist/resources/vision.js +1036 -0
  82. package/dist/resources/vision.js.map +1 -0
  83. package/dist/retry.d.ts +103 -0
  84. package/dist/retry.d.ts.map +1 -0
  85. package/dist/retry.js +224 -0
  86. package/dist/retry.js.map +1 -0
  87. package/dist/sse-parser.d.ts +64 -0
  88. package/dist/sse-parser.d.ts.map +1 -0
  89. package/dist/sse-parser.js +271 -0
  90. package/dist/sse-parser.js.map +1 -0
  91. package/dist/transport.d.ts +142 -0
  92. package/dist/transport.d.ts.map +1 -0
  93. package/dist/transport.js +455 -0
  94. package/dist/transport.js.map +1 -0
  95. package/dist/types.d.ts +61 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.js +3 -0
  98. package/dist/types.js.map +1 -0
  99. package/package.json +44 -0
@@ -0,0 +1,1362 @@
1
+ // ─── Decisions resource ─────────────────────────────────────────────────────
2
+ //
3
+ // Wraps the immutable decision-record surface (Prompt 4 / Prompt 8 / Prompt 11):
4
+ //
5
+ // - POST /api/v1/decisions ingest a record (append to the chain)
6
+ // - POST /api/v1/decisions/bulk append 1-500 records (partial-success envelope)
7
+ // - GET /api/v1/decisions/:id retrieve one record
8
+ // - GET /api/v1/decisions list (cursor-paginated) — Prompt 8 § 8.2
9
+ // - GET /api/v1/decisions/stream SSE feed of records as they're appended
10
+ // - GET /api/v1/decisions/export NDJSON export of a system's chain + Merkle trailer
11
+ //
12
+ // Decision records form an append-only hash-chained log per system; the
13
+ // `recordHash` of row N includes `prevRecordHash` of row N-1, so any
14
+ // tampering is detectable downstream by `verify-chain`. The kernel
15
+ // route deliberately omits `canonicalPayload` from the response (it's
16
+ // large BYTEA and only needed by the verifier endpoint).
17
+ //
18
+ // Cross-org isolation: the retrieve route returns 404 (not 403) when the
19
+ // caller does not own the record. The SDK surfaces that as
20
+ // `AttestryAPIError` with `status === 404` — the same shape as a genuine
21
+ // miss. Existence cannot be enumerated across orgs. The stream + export
22
+ // routes apply the same isolation at the WHERE-clause level — a caller
23
+ // never receives events / records from another org, regardless of
24
+ // cursor or systemId. (Export of a cross-org or nonexistent systemId
25
+ // returns 200 with zero records and a trailer with `recordCount: 0`.)
26
+ import { AttestryError } from "../errors.js";
27
+ import { parseNDJSONResponse } from "../ndjson-parser.js";
28
+ import { parseSSEData, parseSSEResponse } from "../sse-parser.js";
29
+ import { readInputField } from "./safe-input-read.js";
30
+ /**
31
+ * Public stream event types. Today the kernel emits exactly one
32
+ * (`decision.appended`) — extracted as `as const` so consumers can
33
+ * iterate, narrow, and so the drift-detection pin in
34
+ * `src/lib/incidents/__tests__/sdk-drift.test.ts` can compare
35
+ * structurally against the kernel's `formatSSEFrame` default. When the
36
+ * kernel adds a new event type (e.g. `decision.tombstoned`), update
37
+ * BOTH this array AND the kernel emitter, and bump the SDK minor.
38
+ */
39
+ export const DECISION_STREAM_EVENT_TYPES = Object.freeze([
40
+ "decision.appended",
41
+ ]);
42
+ export class DecisionsResource {
43
+ client;
44
+ constructor(client) {
45
+ this.client = client;
46
+ }
47
+ /**
48
+ * Retrieve one decision record by id.
49
+ *
50
+ * Server returns 400 for malformed UUIDs and 404 for not-found OR
51
+ * cross-org records (deliberate conflation — see route docstring).
52
+ * Both surface as `AttestryAPIError` with the corresponding status.
53
+ *
54
+ * Throws `TypeError` synchronously for invalid `id` BEFORE issuing
55
+ * a request — empty string, non-string, lone-surrogate UTF-16, or
56
+ * path-traversal segments (`"."` / `".."` / strings containing
57
+ * `\0`) all reject. The path-traversal guard exists because
58
+ * `encodeURIComponent` does NOT encode `.` or `..`, and `fetch`'s
59
+ * URL normalization would collapse `retrieve("..")` to the LIST
60
+ * endpoint at `/api/v1/decisions/` — silently redirecting to a
61
+ * different resource. Hostile-review F1 (cross-resource fix
62
+ * symmetric to `decisions.verifyChain`); validation centralized in
63
+ * the shared `encodePathSegment` helper.
64
+ *
65
+ * Rejects with `AttestryError` (P2 hardening) if the kernel emits
66
+ * a non-object response shape (`null`, scalar, or array). Rejects
67
+ * with `AttestryAPIError` (P3 hardening) if the kernel responds
68
+ * with a non-`application/json` Content-Type — protects against
69
+ * proxy-injected HTML 200 pages parsing into junk consumer state.
70
+ */
71
+ retrieve(id, options) {
72
+ const encoded = encodePathSegment(id, "id", "decisions.retrieve");
73
+ return this.client
74
+ ._request({
75
+ method: "GET",
76
+ path: `/api/v1/decisions/${encoded}`,
77
+ options,
78
+ })
79
+ .then((result) => {
80
+ // P2 hardening (extended F1 sweep — sync GET completeness):
81
+ // validate the kernel returned an object. A regression to
82
+ // null/scalar/array would let TypeScript-typed access crash
83
+ // consumers with a cryptic error. Throw AttestryError at the
84
+ // SDK boundary instead. Per-field shape (id is UUID, etc.)
85
+ // is faithful-courier — NOT validated here (P4 candidate).
86
+ assertNonNullObjectResponse(result, "decisions.retrieve");
87
+ return result;
88
+ });
89
+ }
90
+ /**
91
+ * Append a decision record to the org's append-only hash chain.
92
+ *
93
+ * Wraps `POST /api/v1/decisions`. Returns the persisted record (with
94
+ * `canonicalPayload` BYTEA omitted — the kernel's `toResponseShape()`
95
+ * helper drops it from the wire response since the client already has
96
+ * the input digest for verification).
97
+ *
98
+ * **Idempotency replay**: subsequent calls with the same
99
+ * `idempotencyKey` AND identical canonical payload return the SAME
100
+ * record (server returns HTTP 200 `decision.idempotency_replay`; SDK
101
+ * resolves the same `Promise<DecisionRecord>` as a fresh insert,
102
+ * which returns 201). Different payload with the same key throws
103
+ * `AttestryAPIError` with `status === 409`
104
+ * `decision.idempotency_conflict`. Status-code distinction is NOT
105
+ * surfaced in the SDK return type — both 2xx resolve identically.
106
+ *
107
+ * **At-least-once delivery**: pass an `idempotencyKey` to make
108
+ * 429-retries safe under network failure. Without one, a retry that
109
+ * succeeds-but-loses-the-response could create a duplicate record.
110
+ * Body is re-stringified per attempt (carry-forward invariant #4).
111
+ *
112
+ * **Plan limits (402)**: when the org has exhausted its
113
+ * `decisionsPerMonth` quota, the kernel throws `PlanLimitError`
114
+ * (mapped to HTTP 402). The SDK surfaces it as `AttestryAPIError`
115
+ * with `status === 402` and `details: {feature, currentPlan,
116
+ * upgradeRequired}` — the structured body lets dashboards route the
117
+ * user straight to the upgrade flow (B.1 carry-forward).
118
+ *
119
+ * Errors:
120
+ * - `AttestryAPIError` (status 401) — auth required
121
+ * - `AttestryAPIError` (status 402) — plan limit (with
122
+ * `details.feature` / `details.currentPlan` /
123
+ * `details.upgradeRequired`)
124
+ * - `AttestryAPIError` (status 404) — system not found OR cross-org
125
+ * attestation (collapsed deliberately to prevent enumeration)
126
+ * - `AttestryAPIError` (status 409) — idempotency conflict (same
127
+ * key, different payload)
128
+ * - `AttestryAPIError` (status 413) — canonical payload exceeds 256KB
129
+ * - `AttestryAPIError` (status 422) — Zod validation failed (field
130
+ * errors in `details`) OR I-JSON validation failed (NaN /
131
+ * Infinity / BigInt / undefined / Symbol — `details.path` names
132
+ * the offending field) OR refine-clause failed
133
+ * (clientSignature/clientKeyId pairing)
134
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried by
135
+ * default — invariant #18)
136
+ * - `AttestryAPIError` (status 500) — internal invariant violation
137
+ * (chain head missing — should never fire in practice)
138
+ * - `AttestryError` ("invalid request body: ...") — body
139
+ * serialization failed (BigInt, circular reference) BEFORE fetch
140
+ * (carry-forward invariant #4)
141
+ * - `AttestryError` ("request aborted by caller") — caller-supplied
142
+ * `options.signal` fired (pre-aborted or mid-flight)
143
+ * - `TypeError` (synchronous, no fetch issued) — input failed SDK-
144
+ * side type validation (see below)
145
+ *
146
+ * SDK-side validation (synchronous `TypeError`, no fetch issued):
147
+ * - `input` itself: must be a non-null, non-array object
148
+ * - `systemId` / `inputDigest`: required non-empty strings
149
+ * - Optional string fields: when provided, must be non-empty strings
150
+ * (outputDigest, attestationId, clientSignature, clientKeyId,
151
+ * idempotencyKey, humanOversightState, policyOutcome)
152
+ * - Optional array fields: when provided, must be `Array.isArray`
153
+ * (frameworkClaims, toolInvocations, delegationChain). Empty
154
+ * arrays pass through.
155
+ * - Optional `zkProof`: when provided, must be a non-null,
156
+ * non-array object.
157
+ *
158
+ * **Format validation deferred to server** (UUID, hash regex,
159
+ * base64, enum membership, length caps, refine pairing,
160
+ * inner-array shape, inner-zkProof shape). Decision D5 in the
161
+ * build-round audit.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * const record = await client.decisions.ingest({
166
+ * systemId: "550e8400-e29b-41d4-a716-446655440000",
167
+ * inputDigest: "sha256:abc123...",
168
+ * frameworkClaims: [
169
+ * { framework: "eu_ai_act", article: "Art.13", claim: "human oversight provided" },
170
+ * ],
171
+ * humanOversightState: "approved",
172
+ * policyOutcome: "permitted",
173
+ * idempotencyKey: "ingest-2026-05-06-trace-789", // safe retries
174
+ * });
175
+ * console.log(record.id, record.sequenceNumber, record.recordHash);
176
+ * ```
177
+ */
178
+ ingest(input, options) {
179
+ // Top-level input shape — must be a non-null, non-array object.
180
+ // typeof null === "object" and typeof [] === "object", so guard
181
+ // both explicitly.
182
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
183
+ throw new TypeError("decisions.ingest: `input` must be an object");
184
+ }
185
+ // Defensive field snapshot — read each input field EXACTLY ONCE
186
+ // via `readInputField`, which converts a throwing accessor's
187
+ // exception into the documented synchronous `TypeError` input
188
+ // contract (session-22 hostile review #1 — the SDK-wide MEDIUM-1
189
+ // getter-throws fix). All validation below operates on the locals;
190
+ // the request body still sends the original `input` (a throwing
191
+ // getter is caught here first, so the transport is never reached).
192
+ const systemId = readInputField(input, "systemId", "decisions.ingest");
193
+ const inputDigest = readInputField(input, "inputDigest", "decisions.ingest");
194
+ const outputDigest = readInputField(input, "outputDigest", "decisions.ingest");
195
+ const attestationId = readInputField(input, "attestationId", "decisions.ingest");
196
+ const humanOversightState = readInputField(input, "humanOversightState", "decisions.ingest");
197
+ const policyOutcome = readInputField(input, "policyOutcome", "decisions.ingest");
198
+ const clientSignature = readInputField(input, "clientSignature", "decisions.ingest");
199
+ const clientKeyId = readInputField(input, "clientKeyId", "decisions.ingest");
200
+ const idempotencyKey = readInputField(input, "idempotencyKey", "decisions.ingest");
201
+ const frameworkClaims = readInputField(input, "frameworkClaims", "decisions.ingest");
202
+ const toolInvocations = readInputField(input, "toolInvocations", "decisions.ingest");
203
+ const delegationChain = readInputField(input, "delegationChain", "decisions.ingest");
204
+ const zkProof = readInputField(input, "zkProof", "decisions.ingest");
205
+ // Required: systemId (UUID server-side; SDK only enforces non-empty
206
+ // string). Failing here throws synchronously with no fetch — invariant.
207
+ if (typeof systemId !== "string" || systemId.length === 0) {
208
+ throw new TypeError("decisions.ingest: `systemId` is required and must be a non-empty string");
209
+ }
210
+ // Required: inputDigest (hash regex server-side; SDK only enforces
211
+ // non-empty string).
212
+ if (typeof inputDigest !== "string" || inputDigest.length === 0) {
213
+ throw new TypeError("decisions.ingest: `inputDigest` is required and must be a non-empty string");
214
+ }
215
+ // Optional string fields — non-empty when provided. Format checks
216
+ // (hash regex, UUID, base64, enum membership) deferred to server.
217
+ validateOptionalNonEmptyString(outputDigest, "outputDigest", "decisions.ingest");
218
+ validateOptionalNonEmptyString(attestationId, "attestationId", "decisions.ingest");
219
+ validateOptionalNonEmptyString(humanOversightState, "humanOversightState", "decisions.ingest");
220
+ validateOptionalNonEmptyString(policyOutcome, "policyOutcome", "decisions.ingest");
221
+ validateOptionalNonEmptyString(clientSignature, "clientSignature", "decisions.ingest");
222
+ validateOptionalNonEmptyString(clientKeyId, "clientKeyId", "decisions.ingest");
223
+ validateOptionalNonEmptyString(idempotencyKey, "idempotencyKey", "decisions.ingest");
224
+ // Optional arrays — empty arrays pass through faithfully (server's
225
+ // `.default([])` would produce the same persisted shape). The
226
+ // SDK does NOT validate inner items — kernel `.strict()` does that;
227
+ // duplicating here would risk drift.
228
+ validateOptionalArray(frameworkClaims, "frameworkClaims", "decisions.ingest");
229
+ validateOptionalArray(toolInvocations, "toolInvocations", "decisions.ingest");
230
+ validateOptionalArray(delegationChain, "delegationChain", "decisions.ingest");
231
+ // Optional zkProof object — non-null, non-array. Inner shape
232
+ // (type / proof / publicSignals) validated by server.
233
+ if (zkProof !== undefined) {
234
+ if (zkProof === null ||
235
+ typeof zkProof !== "object" ||
236
+ Array.isArray(zkProof)) {
237
+ throw new TypeError("decisions.ingest: `zkProof` must be an object when provided");
238
+ }
239
+ }
240
+ return this.client._request({
241
+ method: "POST",
242
+ path: "/api/v1/decisions",
243
+ body: input,
244
+ options,
245
+ });
246
+ }
247
+ /**
248
+ * Append up to 500 decision records in a single request, with a
249
+ * partial-success envelope.
250
+ *
251
+ * Wraps `POST /api/v1/decisions/bulk`. Returns a `BulkIngestResult`
252
+ * describing which records persisted and which failed — the call
253
+ * **resolves successfully even when every record failed**. Partial
254
+ * success is the entire point of the endpoint; the caller branches on
255
+ * `result.totalFailed` (or `result.failed.length`) if they care about
256
+ * per-record errors. Top-level failures (auth, rate limit, plan limit,
257
+ * oversize batch) DO throw `AttestryAPIError` with the corresponding
258
+ * HTTP status.
259
+ *
260
+ * **Per-record codes** — see `BulkFailedSummary.code` JSDoc for the
261
+ * full list. Most relevant for retries:
262
+ * - `"idempotency_unique_violation"`: race condition. Bulk does NOT
263
+ * auto-recover — retry the failed record individually via
264
+ * `decisions.ingest()` to invoke per-record race recovery.
265
+ * - `"system_not_found"`: cross-org system OR cross-system
266
+ * attestationId. Collapsed for enumeration safety.
267
+ *
268
+ * **At-least-once delivery**: pass an `idempotencyKey` on every item.
269
+ * A 429-retry of the same batch then returns duplicates as
270
+ * `failed[i].code === "idempotency_unique_violation"`; other items
271
+ * insert normally. Without per-item keys, a retry that succeeds-but-
272
+ * loses-the-response can create duplicate records.
273
+ *
274
+ * **Plan limits (402)**: the kernel checks the FULL batch size against
275
+ * the org's `decisionsPerMonth` quota. A 100-record batch with 50
276
+ * quota remaining is rejected wholesale (none persisted) — partial
277
+ * quota fills are a reconciliation hazard. The 402 carries the same
278
+ * `details: {feature, currentPlan, upgradeRequired}` shape as
279
+ * `decisions.ingest` (B.1 carry-forward).
280
+ *
281
+ * Errors:
282
+ * - `AttestryAPIError` (status 401) — auth required
283
+ * - `AttestryAPIError` (status 402) — plan limit (with
284
+ * `details.feature` / `details.currentPlan` /
285
+ * `details.upgradeRequired`)
286
+ * - `AttestryAPIError` (status 413) — defensive top-level batch
287
+ * size guard (>500 items). Verbatim message: `"Bulk ingest
288
+ * limited to 500 records per request"`. In practice the kernel's
289
+ * Zod `.max(500)` fires first with a 422; this 413 only surfaces
290
+ * if the schema is bypassed.
291
+ * - `AttestryAPIError` (status 422) — Zod validation failed (one or
292
+ * more `items` malformed; OR top-level Zod fails for >500 items
293
+ * OR empty array — server's `.min(1).max(500)`).
294
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried by
295
+ * default — invariant #18). Body re-stringified per attempt.
296
+ * - `AttestryError` ("invalid request body: ...") — body
297
+ * serialization failed (BigInt, circular reference) BEFORE fetch
298
+ * (carry-forward invariant #4)
299
+ * - `AttestryError` ("request aborted by caller") — caller-supplied
300
+ * `options.signal` fired (pre-aborted or mid-flight)
301
+ * - `TypeError` (synchronous, no fetch issued) — input failed SDK-
302
+ * side type validation (see below)
303
+ *
304
+ * Notably ABSENT from the top-level error chain (vs `decisions.ingest`):
305
+ * - 404 (system not found) → per-record `failed[i].code === "system_not_found"`
306
+ * - 409 (idempotency conflict) → per-record `code === "idempotency_conflict"`
307
+ * - 500 (chain head missing) → per-record `code === "chain_head_missing"`
308
+ *
309
+ * SDK-side validation (synchronous `TypeError`, no fetch issued):
310
+ * - `input` itself: must be a non-null, non-array object
311
+ * - `input.items`: required, must be `Array.isArray`
312
+ *
313
+ * **Format and per-item validation deferred to server**. The SDK does
314
+ * NOT pre-cap `items.length` at 500 (kernel's `.max(500)` is the
315
+ * authority — a future cap raise would otherwise require an SDK
316
+ * change). It does NOT recurse into `items[i]` to validate per-record
317
+ * shape (symmetric to ingest's `frameworkClaims` / `toolInvocations`
318
+ * policy — server's `.strict()` Zod is the schema authority).
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * const result = await client.decisions.bulk({
323
+ * items: [
324
+ * { systemId, inputDigest, idempotencyKey: "trace-001" },
325
+ * { systemId, inputDigest, idempotencyKey: "trace-002" },
326
+ * ],
327
+ * });
328
+ * console.log(`${result.totalInserted}/${result.totalSubmitted} succeeded`);
329
+ * for (const failure of result.failed) {
330
+ * if (failure.code === "idempotency_unique_violation") {
331
+ * // retry via single-record endpoint to invoke race recovery
332
+ * await client.decisions.ingest(originalItems[failure.index]);
333
+ * }
334
+ * }
335
+ * ```
336
+ */
337
+ bulk(input, options) {
338
+ // Top-level input shape — must be a non-null, non-array object.
339
+ // Symmetric to `decisions.ingest` validation. typeof null === "object"
340
+ // and typeof [] === "object", so guard both explicitly.
341
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
342
+ throw new TypeError("decisions.bulk: `input` must be an object");
343
+ }
344
+ // Required: `items` must be an array (Array.isArray rejects null,
345
+ // strings, numbers, plain objects). Empty array is allowed at SDK
346
+ // level — server's `.min(1)` rejects with 422; per-item shape and
347
+ // upper bound (500) are also server's authority.
348
+ //
349
+ // Read via `readInputField` — a throwing `items` accessor surfaces
350
+ // as the documented synchronous `TypeError`, not the getter's raw
351
+ // exception (session-22 hostile review #1 — the SDK-wide MEDIUM-1
352
+ // getter-throws fix).
353
+ const items = readInputField(input, "items", "decisions.bulk");
354
+ if (!Array.isArray(items)) {
355
+ throw new TypeError("decisions.bulk: `items` is required and must be an array");
356
+ }
357
+ return this.client._request({
358
+ method: "POST",
359
+ path: "/api/v1/decisions/bulk",
360
+ body: input,
361
+ options,
362
+ });
363
+ }
364
+ /**
365
+ * List decision records the caller can see, cursor-paginated.
366
+ *
367
+ * Pagination is keyset-based over `(createdAt DESC, id DESC)` —
368
+ * identical-microsecond timestamps don't cause skipped rows. Pass
369
+ * back `response.nextCursor` as `input.cursor` to fetch the next page.
370
+ *
371
+ * Returns a slim per-row shape (`DecisionListItem`) — subset of
372
+ * `DecisionRecord`, deliberately omitting heavy fields. Call
373
+ * `decisions.retrieve(id)` for the full record.
374
+ *
375
+ * Errors:
376
+ * - `AttestryAPIError` (status 400) — malformed cursor (server-side)
377
+ * - `AttestryAPIError` (status 401) — auth required
378
+ * - `AttestryAPIError` (status 403) — api-key missing `read:assessments`
379
+ * - `AttestryAPIError` (status 422) — invalid query parameters
380
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried by default)
381
+ *
382
+ * SDK-side validation (throws `TypeError` synchronously):
383
+ * - Each optional string field (systemId, from, to, framework,
384
+ * article, tool, cursor) must be a non-empty string when provided.
385
+ * Format validation (UUID, ISO date) is deferred to the server.
386
+ * - `limit` must be a number when provided.
387
+ * - `includeTombstoned` must be a boolean when provided.
388
+ *
389
+ * Response-shape validation (P2 hardening):
390
+ * - Rejects with `AttestryError` if the kernel response isn't a
391
+ * non-null object, lacks an `items` array, or has a `nextCursor`
392
+ * that isn't a string-or-null. Per-row shape is faithful-courier
393
+ * (NOT validated — P4 candidate).
394
+ *
395
+ * Transport-shape validation (P3 hardening):
396
+ * - Rejects with `AttestryAPIError` if the kernel responds with a
397
+ * non-`application/json` Content-Type — protects against
398
+ * proxy-injected HTML 200 pages parsing into junk consumer state.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * let cursor: string | undefined;
403
+ * for (let page = 0; page < 100; page++) {
404
+ * const { items, nextCursor } = await client.decisions.list({
405
+ * systemId,
406
+ * limit: 50,
407
+ * cursor,
408
+ * });
409
+ * for (const item of items) console.log(item.id, item.sequenceNumber);
410
+ * if (nextCursor === null) break;
411
+ * cursor = nextCursor;
412
+ * }
413
+ * ```
414
+ */
415
+ list(input = {}, options) {
416
+ // Snapshot each query field via `readInputField` — a throwing
417
+ // accessor surfaces as the documented synchronous `TypeError`
418
+ // rather than the getter's raw exception (session-22 hostile
419
+ // review #3 — completes the SDK-wide MEDIUM-1 getter-throws fix;
420
+ // reviews #1-#2 converted decisions.ingest / .bulk but missed the
421
+ // decisions query methods). The `as` cast restores each field's
422
+ // declared `DecisionsListInput` type for the typed query below.
423
+ const systemId = readInputField(input, "systemId", "decisions.list");
424
+ const from = readInputField(input, "from", "decisions.list");
425
+ const to = readInputField(input, "to", "decisions.list");
426
+ const framework = readInputField(input, "framework", "decisions.list");
427
+ const article = readInputField(input, "article", "decisions.list");
428
+ const tool = readInputField(input, "tool", "decisions.list");
429
+ const cursor = readInputField(input, "cursor", "decisions.list");
430
+ const limit = readInputField(input, "limit", "decisions.list");
431
+ const includeTombstoned = readInputField(input, "includeTombstoned", "decisions.list");
432
+ validateOptionalNonEmptyString(systemId, "systemId");
433
+ validateOptionalNonEmptyString(from, "from");
434
+ validateOptionalNonEmptyString(to, "to");
435
+ validateOptionalNonEmptyString(framework, "framework");
436
+ validateOptionalNonEmptyString(article, "article");
437
+ validateOptionalNonEmptyString(tool, "tool");
438
+ validateOptionalNonEmptyString(cursor, "cursor");
439
+ if (limit !== undefined && typeof limit !== "number") {
440
+ throw new TypeError("decisions.list: `limit` must be a number when provided");
441
+ }
442
+ if (includeTombstoned !== undefined &&
443
+ typeof includeTombstoned !== "boolean") {
444
+ throw new TypeError("decisions.list: `includeTombstoned` must be a boolean when provided");
445
+ }
446
+ // Synchronous lone-surrogate guard: encodeQuery → encodeURIComponent
447
+ // throws raw URIError for malformed UTF-16. Cross-phase follow-up
448
+ // to the decisions.export hostile-review fix (commit 0428777).
449
+ if (systemId !== undefined) {
450
+ assertEncodableQueryString(systemId, "systemId", "decisions.list");
451
+ }
452
+ if (from !== undefined) {
453
+ assertEncodableQueryString(from, "from", "decisions.list");
454
+ }
455
+ if (to !== undefined) {
456
+ assertEncodableQueryString(to, "to", "decisions.list");
457
+ }
458
+ if (framework !== undefined) {
459
+ assertEncodableQueryString(framework, "framework", "decisions.list");
460
+ }
461
+ if (article !== undefined) {
462
+ assertEncodableQueryString(article, "article", "decisions.list");
463
+ }
464
+ if (tool !== undefined) {
465
+ assertEncodableQueryString(tool, "tool", "decisions.list");
466
+ }
467
+ if (cursor !== undefined) {
468
+ assertEncodableQueryString(cursor, "cursor", "decisions.list");
469
+ }
470
+ return this.client
471
+ ._request({
472
+ method: "GET",
473
+ path: "/api/v1/decisions",
474
+ query: {
475
+ systemId,
476
+ from,
477
+ to,
478
+ framework,
479
+ article,
480
+ tool,
481
+ cursor,
482
+ limit,
483
+ // Hostile-round H1: kernel uses `z.coerce.boolean()` which calls
484
+ // `Boolean(value)` — `Boolean("false") === true`. So a literal
485
+ // `?includeTombstoned=false` would silently RETURN TOMBSTONED
486
+ // records (server interprets the string as truthy). Workaround:
487
+ // when the caller explicitly passes `false`, OMIT the param so
488
+ // the server's `default(false)` applies — same behavior the
489
+ // user intended. Only emit the param when `true`. When kernel
490
+ // upgrades to a string-aware boolean schema (.preprocess or
491
+ // similar), this workaround becomes a no-op (passes "true"
492
+ // through; "false"/omit difference vanishes).
493
+ includeTombstoned: includeTombstoned === true ? true : undefined,
494
+ },
495
+ options,
496
+ })
497
+ .then((result) => {
498
+ // P2 hardening: validate response shape. The kernel emits
499
+ // `{success:true, data:{items, nextCursor}}` and the transport
500
+ // unwraps `data` to give us `{items, nextCursor}`. A kernel-
501
+ // side regression (e.g., emitting `data: null` or `data: {
502
+ // items: "scalar" }`) would let TypeScript-typed access
503
+ // produce undefined / crash consumers. Throw AttestryError at
504
+ // the SDK boundary for a clear message.
505
+ assertDecisionsListResponse(result);
506
+ return result;
507
+ });
508
+ }
509
+ /**
510
+ * Subscribe to decision-record events as they're appended.
511
+ *
512
+ * Returns an `AsyncIterable<DecisionStreamEvent>` — consume with
513
+ * `for await (const event of stream)`. Errors THROW (the iterator
514
+ * surfaces them via the for-await loop's natural error path), in
515
+ * contrast to `chat.stream()` which yields error chunks. Reason:
516
+ *
517
+ * - `chat.stream()` is a request/response (one POST → one iterator).
518
+ * Yielding errors inline lets consumers render them in the same UI
519
+ * stream as the assistant's text.
520
+ * - `decisions.stream()` is a long-lived subscription. An error
521
+ * means the connection is gone — yielding inline would force every
522
+ * consumer to write `if (chunk.type === 'error') break;`. Throwing
523
+ * gives clean `try/catch` semantics with typed error classes
524
+ * (`AttestryAPIError` for 4xx/5xx, `AttestryError` for network /
525
+ * abort).
526
+ *
527
+ * Errors surface as:
528
+ * - `AttestryAPIError` (status 401) — auth failed
529
+ * - `AttestryAPIError` (status 403) — insufficient permissions
530
+ * (api keys need `read:assessments` scope)
531
+ * - `AttestryAPIError` (status 400) — malformed `systemId` or
532
+ * `lastEventId` (server-side validation)
533
+ * - `AttestryAPIError` (status 429) — rate limited
534
+ * - `AttestryError` ("request aborted by caller") — caller-provided
535
+ * `options.signal` fired (pre-abort or mid-iteration)
536
+ * - `AttestryError` ("network error: ...") — fetch-level failure
537
+ * before any frame; OR mid-stream connection drop (surfaces from
538
+ * the underlying reader, wrapped during iteration)
539
+ * - `AttestryError` ("SSE frame data was not valid JSON: ...") —
540
+ * defensive; the kernel always emits valid JSON in `data:` lines.
541
+ *
542
+ * Reconnection: the iterator does NOT auto-reconnect. On any error or
543
+ * clean termination (server-side 5min timeout closes the connection),
544
+ * the for-await loop ends. The caller then decides whether to call
545
+ * `stream({lastEventId: lastSeen.eventId})` to resume.
546
+ *
547
+ * Lazy: the request is NOT issued until the first iteration. Pass
548
+ * `options.signal` for cancellation — pre-aborted causes the first
549
+ * iteration to throw `AttestryError` with no fetch issued; mid-flight
550
+ * abort surfaces as `AttestryError` from the iterator.
551
+ *
552
+ * Heartbeat frames (`: heartbeat\n\n`) are silently consumed by the
553
+ * SSE parser and never yielded to the consumer.
554
+ *
555
+ * @example
556
+ * ```ts
557
+ * try {
558
+ * for await (const event of client.decisions.stream({ systemId })) {
559
+ * console.log(event.id, event.sequenceNumber);
560
+ * lastEventId = event.eventId; // for reconnection
561
+ * }
562
+ * } catch (err) {
563
+ * if (err instanceof AttestryAPIError && err.status === 401) {
564
+ * // re-auth
565
+ * } else if (err instanceof AttestryError) {
566
+ * // network drop — wait + retry with lastEventId
567
+ * }
568
+ * }
569
+ * ```
570
+ */
571
+ stream(input, options) {
572
+ // Snapshot input fields via `readInputField` — a throwing accessor
573
+ // surfaces as the documented synchronous `TypeError` rather than
574
+ // the getter's raw exception (session-22 hostile review #3 —
575
+ // completes the SDK-wide MEDIUM-1 getter-throws fix; reviews #1-#2
576
+ // missed the decisions query methods). `input` is optional; the
577
+ // locals are read only inside the guard. The `as` cast restores
578
+ // the declared `DecisionsStreamInput` field types. `runDecisionsStream`
579
+ // still receives the original `input` — a throwing getter is
580
+ // caught here first, so the helper is never reached on one.
581
+ if (input !== undefined) {
582
+ const systemId = readInputField(input, "systemId", "decisions.stream");
583
+ const lastEventId = readInputField(input, "lastEventId", "decisions.stream");
584
+ if (systemId !== undefined) {
585
+ if (typeof systemId !== "string" || systemId.length === 0) {
586
+ throw new TypeError("decisions.stream: `systemId` must be a non-empty string when provided");
587
+ }
588
+ }
589
+ if (lastEventId !== undefined) {
590
+ if (typeof lastEventId !== "string" || lastEventId.length === 0) {
591
+ throw new TypeError("decisions.stream: `lastEventId` must be a non-empty string when provided");
592
+ }
593
+ }
594
+ // Synchronous lone-surrogate guard for the systemId query string.
595
+ // Cross-phase follow-up to decisions.export hostile-review fix
596
+ // (commit 0428777). lastEventId rides on the Last-Event-ID header
597
+ // — Headers.set throws TypeError on its own for invalid values
598
+ // (CR/LF), no URIError concern, so no guard needed there.
599
+ if (systemId !== undefined) {
600
+ assertEncodableQueryString(systemId, "systemId", "decisions.stream");
601
+ }
602
+ }
603
+ return runDecisionsStream(this.client, input, options);
604
+ }
605
+ /**
606
+ * Export a system's decision chain as a streaming NDJSON response.
607
+ *
608
+ * Wraps `GET /api/v1/decisions/export`. Returns an
609
+ * `AsyncIterable<DecisionExportFrame>` — records arrive first (in
610
+ * `sequenceNumber` ascending order), then exactly one trailer that
611
+ * commits the batch to a Merkle root over per-record `recordHash`
612
+ * leaves.
613
+ *
614
+ * Errors **throw** from the iterator (long-lived stream semantics —
615
+ * symmetric with `decisions.stream`). Use `try / catch` around the
616
+ * for-await loop:
617
+ *
618
+ * @example
619
+ * ```ts
620
+ * try {
621
+ * for await (const frame of client.decisions.export({ systemId })) {
622
+ * if ("type" in frame && frame.type === "ExportTrailer") {
623
+ * // Final commit — verify Merkle root client-side post-Prompt-1.
624
+ * console.log(`${frame.recordCount} records, root=${frame.merkleRoot}`);
625
+ * } else {
626
+ * // Per-record line — DecisionListItem shape.
627
+ * process(frame);
628
+ * }
629
+ * }
630
+ * } catch (err) {
631
+ * if (err instanceof AttestryAPIError && err.status === 422) {
632
+ * // bad systemId / unknown query / malformed datetime
633
+ * } else if (err instanceof AttestryError) {
634
+ * // network drop, parser error, missing trailer
635
+ * }
636
+ * }
637
+ * ```
638
+ *
639
+ * **Empty export** — when the systemId has zero records (or doesn't
640
+ * exist / belongs to another org), the iterator yields a SINGLE
641
+ * frame: a trailer with `recordCount: 0`, `sequenceFrom: null`,
642
+ * `sequenceTo: null`, and the deterministic empty-export merkleRoot
643
+ * (`sha256:` + hex of `sha256("ATTESTRY-EMPTY-EXPORT")`). The SDK
644
+ * does NOT throw — the empty trailer is the kernel's success signal
645
+ * for "no data".
646
+ *
647
+ * **Missing trailer** — every successful 200 stream ends with a
648
+ * trailer. If the iterator exhausts without seeing one (mid-stream
649
+ * connection drop, kernel error after headers committed), the SDK
650
+ * throws `AttestryError("decisions.export: stream ended without
651
+ * trailer — connection dropped or server failed mid-stream")`. This
652
+ * surfaces a class of failures that the kernel can't return as 4xx
653
+ * (the response was already 200 by the time the error arose).
654
+ *
655
+ * **Trailer signing field** — today the trailer's `signing` field is
656
+ * the literal string `"unsigned-prompt-1-blocked"`. Once Prompt 1
657
+ * ships Ed25519 signing, the field is replaced by a structured proof.
658
+ * The SDK does NOT verify the signature — caller is responsible
659
+ * (post-Prompt-1).
660
+ *
661
+ * Errors:
662
+ * - `AttestryAPIError` (status 401) — auth required
663
+ * - `AttestryAPIError` (status 422) — invalid query (missing
664
+ * systemId / unknown key / non-UUID systemId / malformed datetime)
665
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried by
666
+ * default — invariant #18; initial fetch only — invariant #20)
667
+ * - `AttestryAPIError` — wrong content-type at 200 (proxy / LB
668
+ * error page wrapped at 200)
669
+ * - `AttestryError` ("decisions.export: stream ended without
670
+ * trailer ...") — mid-stream failure detected at iterator end
671
+ * - `AttestryError` ("network error during stream: ...") — TCP
672
+ * drop / proxy hang-up mid-stream
673
+ * - `AttestryError` ("request aborted by caller") — caller-supplied
674
+ * `options.signal` fired (pre-aborted or mid-flight)
675
+ * - `AttestryError` ("NDJSON line was not valid JSON: ...") —
676
+ * defensive; the kernel always emits valid JSON
677
+ * - `AttestryError` ("NDJSON line exceeded maximum buffer size ...") —
678
+ * defensive; the kernel's per-record line is well below 1 MiB
679
+ * - `TypeError` (synchronous, no fetch issued) — input failed
680
+ * SDK-side type validation (see below)
681
+ *
682
+ * Notably ABSENT from the error surface:
683
+ * - **No 402 plan-limit** — export is a READ, doesn't count against
684
+ * the org's `decisionsPerMonth` quota.
685
+ * - **No 404 system-not-found** — a non-existent or cross-org
686
+ * systemId returns 200 with zero records and a trailer with
687
+ * `recordCount: 0`. Consumers detect via the trailer.
688
+ *
689
+ * SDK-side validation (synchronous `TypeError`, no fetch issued):
690
+ * - `input` itself: must be a non-null, non-array object
691
+ * - `input.systemId`: required, non-empty string
692
+ * - `input.from` / `input.to`: optional; non-empty string when provided
693
+ * - `input.includeTombstoned`: optional; boolean when provided
694
+ *
695
+ * Format validation deferred to server (UUID, ISO datetime). The
696
+ * `includeTombstoned: false` boolean is forwarded LITERALLY (no
697
+ * workaround) — the kernel session-6 fix to `stringBoolean` accepts
698
+ * `"false"` correctly. Asymmetry from `decisions.list` (which still
699
+ * omits `false` as defense-in-depth) is deliberate — build-round D7.
700
+ *
701
+ * Lazy: the request is NOT issued until the first iteration. Pass
702
+ * `options.signal` for cancellation — pre-aborted causes the first
703
+ * iteration to throw `AttestryError` with no fetch issued; mid-flight
704
+ * abort surfaces as `AttestryError` from the iterator.
705
+ */
706
+ export(input, options) {
707
+ // Top-level shape — must be a non-null, non-array object.
708
+ // Symmetric to ingest / bulk validation. typeof null === "object"
709
+ // and typeof [] === "object", so guard both explicitly.
710
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
711
+ throw new TypeError("decisions.export: `input` must be an object");
712
+ }
713
+ // Snapshot each query field via `readInputField` — a throwing
714
+ // accessor surfaces as the documented synchronous `TypeError`
715
+ // rather than the getter's raw exception (session-22 hostile
716
+ // review #3 — completes the SDK-wide MEDIUM-1 getter-throws fix;
717
+ // reviews #1-#2 converted decisions.ingest / .bulk but missed the
718
+ // decisions query methods). The `as` cast restores each field's
719
+ // declared `DecisionsExportInput` type. `runDecisionsExport` still
720
+ // receives the original `input` — a throwing getter is caught here
721
+ // first, so the helper's own field reads are never reached on one.
722
+ const systemId = readInputField(input, "systemId", "decisions.export");
723
+ const from = readInputField(input, "from", "decisions.export");
724
+ const to = readInputField(input, "to", "decisions.export");
725
+ const includeTombstoned = readInputField(input, "includeTombstoned", "decisions.export");
726
+ // Required: systemId. Failing here throws synchronously with no
727
+ // fetch — invariant. Format check (UUID) deferred to server's Zod.
728
+ if (typeof systemId !== "string" || systemId.length === 0) {
729
+ throw new TypeError("decisions.export: `systemId` is required and must be a non-empty string");
730
+ }
731
+ // Optional date filters — non-empty strings when provided. ISO
732
+ // datetime format check is the server's job.
733
+ validateOptionalNonEmptyString(from, "from", "decisions.export");
734
+ validateOptionalNonEmptyString(to, "to", "decisions.export");
735
+ // Optional includeTombstoned — strict boolean when provided.
736
+ if (includeTombstoned !== undefined &&
737
+ typeof includeTombstoned !== "boolean") {
738
+ throw new TypeError("decisions.export: `includeTombstoned` must be a boolean when provided");
739
+ }
740
+ // Synchronous lone-surrogate guard: the underlying transport runs
741
+ // `encodeURIComponent` over each query value, which throws a raw
742
+ // `URIError` for malformed UTF-16 (lone surrogates like `\uD800`).
743
+ // Without this catch the URIError leaks into the consumer's
744
+ // for-await loop as a non-AttestryError class — inconsistent with
745
+ // `decisions.retrieve` (which already converts URIError → TypeError
746
+ // for the path segment). Hostile-review: validate the strings
747
+ // up-front so the failure is synchronous + named.
748
+ assertEncodableQueryString(systemId, "systemId", "decisions.export");
749
+ if (from !== undefined) {
750
+ assertEncodableQueryString(from, "from", "decisions.export");
751
+ }
752
+ if (to !== undefined) {
753
+ assertEncodableQueryString(to, "to", "decisions.export");
754
+ }
755
+ return runDecisionsExport(this.client, input, options);
756
+ }
757
+ /**
758
+ * Replay a system's hash chain and report integrity verdict.
759
+ *
760
+ * Wraps `GET /api/v1/decisions/verify-chain/{systemId}`. Returns a
761
+ * `ChainVerificationResult` describing whether tampering was detected
762
+ * and which records (if any) failed which check.
763
+ *
764
+ * **Critical contract — partial-success envelope**: the kernel returns
765
+ * **HTTP 200 with `chainValid: false`** when tampering is detected.
766
+ * The SDK resolves the Promise with the verdict body — it does **NOT**
767
+ * throw on `chainValid: false`. The customer asked the chain-integrity
768
+ * question and the kernel answered; the SDK is a faithful courier.
769
+ * Top-level structural failures (auth, rate limit, system-not-found,
770
+ * `ChainTooLong`) DO throw `AttestryAPIError`. Carry-forward invariant
771
+ * #12; same family as `decisions.bulk` (200 with `totalFailed > 0`
772
+ * resolves rather than throws).
773
+ *
774
+ * **Failure-mode discrimination**: the two ID arrays are surfaced
775
+ * separately so consumers can route on the SECURITY-vs-OPS distinction
776
+ * at the call site (the kernel uses the same distinction to fire the
777
+ * `chain.tampered` vs `chain.broken` webhook):
778
+ * - `tamperedRecordIds`: direct content tampering (security signal).
779
+ * - `brokenRecordIds`: gap in the chain (ops signal — missing record).
780
+ * Both arrays can be non-empty simultaneously.
781
+ *
782
+ * **Side effect (out-of-band)**: the kernel dispatches one of three
783
+ * fire-and-forget webhooks AFTER the response body is built but BEFORE
784
+ * returning — `chain.verified` (when valid), `chain.tampered` (when
785
+ * `tamperedRecordIds.length > 0`), or `chain.broken` (when only
786
+ * `brokenRecordIds.length > 0`). The SDK does NOT see / verify these;
787
+ * they're surfaced through the webhooks resource (a different SDK
788
+ * surface). Consumers who want webhook-based observability subscribe
789
+ * via the kernel's webhook endpoints.
790
+ *
791
+ * **413 with export hint**: when the chain length exceeds
792
+ * `MAX_SYNC_CHAIN_LENGTH` (50,000 records), the kernel returns 413
793
+ * with the export-endpoint hint. The transport stores the entire
794
+ * parsed error body under `AttestryAPIError.details`, and the kernel's
795
+ * own structured `details` object nests inside — so the consumer-side
796
+ * access path is `err.details?.details?.hint` (double-`details`).
797
+ * Consumers detect this case via
798
+ * `error.details?.details?.hint?.includes("decisions/export")` and
799
+ * fall back to streaming the chain through `decisions.export()` for
800
+ * offline verification. The `ChainTooLongError` kernel class is
801
+ * internal; its 413 surface is what the SDK exposes.
802
+ *
803
+ * Errors:
804
+ * - `AttestryAPIError` (status 400) — `systemId` failed server-side
805
+ * UUID format check (`isValidUuid` rejects, NOT a Zod 422).
806
+ * - `AttestryAPIError` (status 401) — auth required (no session
807
+ * and no api-key).
808
+ * - `AttestryAPIError` (status 403) — propagates if upstream
809
+ * `AuthError` was thrown with a custom 403 statusCode (rare; the
810
+ * route's default for cross-org systems is 404).
811
+ * - `AttestryAPIError` (status 404) — system not found OR cross-org
812
+ * system (deliberate enumeration-safety collapse, same shape as
813
+ * retrieve / ingest).
814
+ * - `AttestryAPIError` (status 413) — `ChainTooLong` (>50,000 records)
815
+ * with `err.details?.details?.hint` referencing `/api/v1/decisions/export`.
816
+ * (Double-`details`: transport stores the whole parsed body under
817
+ * `.details`; the kernel's own `details` object nests inside.)
818
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried by
819
+ * default — invariant #18; per-IP `assessmentLimiter`).
820
+ * - `AttestryAPIError` (status 500) — internal error with a SCRUBBED
821
+ * message (no leak of the underlying kernel error). Surfaces e.g.
822
+ * when the DB connection drops mid-verification.
823
+ * - `AttestryError` ("request aborted by caller") — caller-supplied
824
+ * `options.signal` fired (pre-aborted or mid-flight).
825
+ * - `TypeError` (synchronous, no fetch issued) — `systemId` failed
826
+ * SDK-side validation (empty / non-string / lone-surrogate /
827
+ * path-traversal segment).
828
+ *
829
+ * Notably ABSENT from the error chain:
830
+ * - **No 402 plan-limit** — verifyChain is a READ; doesn't count
831
+ * against `decisionsPerMonth` quota.
832
+ * - **No 422** — the only input is a path segment, validated as a
833
+ * UUID via `isValidUuid` (which returns 400, not 422). No query
834
+ * schema, no body schema, no Zod.
835
+ *
836
+ * SDK-side validation (synchronous `TypeError`, no fetch issued):
837
+ * - `systemId`: must be a non-empty string.
838
+ * - `systemId`: must NOT be the exact string `"."` or `".."` — these
839
+ * survive `encodeURIComponent` but get collapsed by `fetch`'s
840
+ * URL normalization into the parent endpoint, silently redirecting
841
+ * the request to a different resource. NUL bytes (`\0`) also
842
+ * rejected. Hostile-review F1.
843
+ * - `systemId`: must be encodable via `encodeURIComponent` — lone
844
+ * surrogates throw a synchronous `TypeError` with `cause: err`
845
+ * wrapping the original `URIError`. Mirror of `decisions.retrieve`'s
846
+ * L1 pattern (carry-forward invariant #32).
847
+ *
848
+ * **Format validation deferred to server** (UUID format check happens
849
+ * server-side via `isValidUuid`, returns 400).
850
+ *
851
+ * Response-shape validation (P2 hardening):
852
+ * - Rejects with `AttestryError` if the kernel response isn't a
853
+ * non-null object (`null`, scalar, or array). Per-field shape
854
+ * (e.g. `chainValid: boolean`) is faithful-courier — NOT
855
+ * validated.
856
+ *
857
+ * Transport-shape validation (P3 hardening):
858
+ * - Rejects with `AttestryAPIError` if the kernel responds with a
859
+ * non-`application/json` Content-Type. NOTE: `chainValid: false`
860
+ * is a normal 200 response and resolves the promise (carry-forward
861
+ * invariant #12); only structural failures throw.
862
+ *
863
+ * @example
864
+ * ```ts
865
+ * const verdict = await client.decisions.verifyChain(systemId);
866
+ * if (!verdict.chainValid) {
867
+ * if (verdict.tamperedRecordIds.length > 0) {
868
+ * // SECURITY signal: someone edited stored bytes / hashes.
869
+ * await notifySecurity({ systemId, ids: verdict.tamperedRecordIds });
870
+ * } else if (verdict.brokenRecordIds.length > 0) {
871
+ * // OPS signal: a record went missing.
872
+ * await notifyOps({ systemId, ids: verdict.brokenRecordIds });
873
+ * }
874
+ * }
875
+ * console.log(`verified up to sequence ${verdict.lastVerifiedSequence}`);
876
+ *
877
+ * // 413 → fall back to export + offline verification:
878
+ * try {
879
+ * await client.decisions.verifyChain(largeSystemId);
880
+ * } catch (err) {
881
+ * if (err instanceof AttestryAPIError && err.status === 413) {
882
+ * // err.details?.details?.hint references /api/v1/decisions/export
883
+ * // (double-details: transport's wrap + kernel's structured detail)
884
+ * for await (const frame of client.decisions.export({ systemId: largeSystemId })) {
885
+ * // verify chain offline ...
886
+ * }
887
+ * }
888
+ * }
889
+ * ```
890
+ */
891
+ verifyChain(systemId, options) {
892
+ // Validation + URL-segment encoding centralized in the shared
893
+ // `encodePathSegment` helper. Throws TypeError synchronously for
894
+ // empty/non-string/path-traversal/lone-surrogate inputs (mirror
895
+ // of `decisions.retrieve`'s validation; carry-forward invariants
896
+ // #32 + hostile-review F1).
897
+ const encoded = encodePathSegment(systemId, "systemId", "decisions.verifyChain");
898
+ return this.client
899
+ ._request({
900
+ method: "GET",
901
+ path: `/api/v1/decisions/verify-chain/${encoded}`,
902
+ options,
903
+ })
904
+ .then((result) => {
905
+ // P2 hardening (extended F1 sweep — sync GET completeness):
906
+ // validate the kernel returned an object. The kernel returns
907
+ // 200 with `chainValid: false` on tampering (carry-forward
908
+ // invariant #12 — the SDK does NOT throw on chainValid:false),
909
+ // so the response is ALWAYS an object on the success path.
910
+ // Null/scalar/array would be a kernel-side regression. Per-
911
+ // field shape (chainValid is boolean, etc.) is faithful-courier
912
+ // — NOT validated here.
913
+ assertNonNullObjectResponse(result, "decisions.verifyChain");
914
+ return result;
915
+ });
916
+ }
917
+ }
918
+ /**
919
+ * Validate an optional input field is a non-empty string when provided.
920
+ * Shared by `decisions.list` and `decisions.ingest`; symmetric to the
921
+ * `decisions.stream` inline-validation pattern. Throws `TypeError` (not
922
+ * `AttestryError`) so consumer code can branch on `instanceof TypeError`
923
+ * uniformly across resources for input-validation errors.
924
+ *
925
+ * `methodName` defaults to `"decisions.list"` to preserve the original
926
+ * call sites' message format. New callers (e.g., `decisions.ingest`)
927
+ * pass their method name explicitly so the surfaced TypeError names
928
+ * the right method.
929
+ *
930
+ * Format validation (UUID, ISO date, etc.) is deferred to the server —
931
+ * the kernel's Zod gate is the schema authority (build-round D5).
932
+ */
933
+ function validateOptionalNonEmptyString(value, fieldName, methodName = "decisions.list") {
934
+ if (value === undefined)
935
+ return;
936
+ if (typeof value !== "string" || value.length === 0) {
937
+ throw new TypeError(`${methodName}: \`${fieldName}\` must be a non-empty string when provided`);
938
+ }
939
+ }
940
+ /**
941
+ * Synchronously verify a query-string value is encodable via
942
+ * `encodeURIComponent`. The platform throws `URIError` for malformed
943
+ * UTF-16 (lone surrogates such as `\uD800` / `\uDFFF`); the transport's
944
+ * `encodeQuery` does NOT catch it, so without this guard the failure
945
+ * leaks into the lazy iterator as a raw `URIError` — inconsistent with
946
+ * `decisions.retrieve` (which already converts URIError → TypeError on
947
+ * the path-segment encoding). Hostile-review: bring the export resource
948
+ * in line so callers see a synchronous, named TypeError.
949
+ *
950
+ * Cause-chained for debugging.
951
+ */
952
+ function assertEncodableQueryString(value, fieldName, methodName) {
953
+ try {
954
+ encodeURIComponent(value);
955
+ }
956
+ catch (err) {
957
+ throw new TypeError(`${methodName}: \`${fieldName}\` contains invalid UTF-16 sequences (${
958
+ // encodeURIComponent always throws URIError (an Error subclass),
959
+ // so the String(err) branch is unreachable. Defense-in-depth
960
+ // marker for the v8 coverage tool.
961
+ /* v8 ignore next */
962
+ err instanceof Error ? err.message : String(err)})`, { cause: err });
963
+ }
964
+ }
965
+ /**
966
+ * Validate + encode a single path-segment input. Returns the
967
+ * `encodeURIComponent`-encoded form, or throws `TypeError`
968
+ * synchronously for any of:
969
+ *
970
+ * - non-string / empty string → `${methodName}: \`${fieldName}\` is required`
971
+ * - exact `"."` or `".."` or strings containing `\0` →
972
+ * `${methodName}: \`${fieldName}\` contains invalid path-segment characters`
973
+ * - lone UTF-16 surrogates (encodeURIComponent throws URIError) →
974
+ * `${methodName}: \`${fieldName}\` contains invalid UTF-16 sequences (...)`
975
+ * with `cause: err` wrapping the original `URIError`
976
+ *
977
+ * **Hostile-review F1 origin (cross-resource fix)**: `encodeURIComponent`
978
+ * does NOT encode `.` or `..`, and WHATWG-spec `fetch` normalizes URL
979
+ * paths — so a literal `verifyChain("..")` (or `retrieve("..")`) would
980
+ * produce `/api/v1/decisions/verify-chain/..`, which the URL parser
981
+ * collapses to `/api/v1/decisions/` (the LIST endpoint). The kernel
982
+ * returns 200 with a list-shaped body, the SDK unwraps the envelope,
983
+ * and the consumer's `result.chainValid` (or `result.id`) reads
984
+ * `undefined`. Reject exact-match traversal segments AT the SDK
985
+ * boundary so the failure is loud and synchronous instead of silent
986
+ * cross-endpoint shadowing.
987
+ *
988
+ * Embedded `..` in a longer segment (e.g., `"foo/../bar"`) is safe —
989
+ * `encodeURIComponent` encodes `/` as `%2F`, so the path stays a
990
+ * single segment and the URL parser doesn't normalize.
991
+ *
992
+ * Carry-forward invariant #32 (URIError defect-class is uniformly
993
+ * handled) — the URIError → TypeError wrap with `{cause: err}` and
994
+ * the v8-ignore directive on the unreachable `String(err)` branch
995
+ * are centralized here for all path-segment methods.
996
+ *
997
+ * Used by:
998
+ * - `decisions.retrieve(id)` — fieldName: "id"
999
+ * - `decisions.verifyChain(systemId)` — fieldName: "systemId"
1000
+ * - Future `webhooks.delete(id)` / `webhooks.test(id)` — fieldName: "id"
1001
+ */
1002
+ function encodePathSegment(value, fieldName, methodName) {
1003
+ if (typeof value !== "string" || value.length === 0) {
1004
+ throw new TypeError(`${methodName}: \`${fieldName}\` is required`);
1005
+ }
1006
+ if (value === "." || value === ".." || value.includes("\0")) {
1007
+ throw new TypeError(`${methodName}: \`${fieldName}\` contains invalid path-segment characters`);
1008
+ }
1009
+ try {
1010
+ return encodeURIComponent(value);
1011
+ }
1012
+ catch (err) {
1013
+ throw new TypeError(`${methodName}: \`${fieldName}\` contains invalid UTF-16 sequences (${
1014
+ // encodeURIComponent always throws URIError (an Error subclass),
1015
+ // so the String(err) branch is unreachable. Defense-in-depth
1016
+ // marker for the v8 coverage tool.
1017
+ /* v8 ignore next */
1018
+ err instanceof Error ? err.message : String(err)})`, { cause: err });
1019
+ }
1020
+ }
1021
+ /**
1022
+ * Validate an optional input field is an array (any contents) when
1023
+ * provided. Used by `decisions.ingest` for nested array fields
1024
+ * (`frameworkClaims`, `toolInvocations`, `delegationChain`). Empty
1025
+ * arrays pass through faithfully — the server's `.default([])`
1026
+ * produces the same persisted shape.
1027
+ *
1028
+ * Inner-item validation (per-element shape, field caps) is deferred to
1029
+ * the server — duplicating it SDK-side would risk drift from the
1030
+ * kernel's `.strict()` Zod schema.
1031
+ */
1032
+ function validateOptionalArray(value, fieldName, methodName) {
1033
+ if (value === undefined)
1034
+ return;
1035
+ if (!Array.isArray(value)) {
1036
+ throw new TypeError(`${methodName}: \`${fieldName}\` must be an array when provided`);
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Human-readable type description for response-shape error messages.
1041
+ * Distinguishes `null` and `array` from generic `object`.
1042
+ *
1043
+ * Duplicated in `regulatory-changes.ts` and `incidents.ts` per project
1044
+ * pattern (small helper, leaf-resource modules, no shared module yet).
1045
+ */
1046
+ function describeType(value) {
1047
+ if (value === null)
1048
+ return "null";
1049
+ if (Array.isArray(value))
1050
+ return "array";
1051
+ return typeof value;
1052
+ }
1053
+ /**
1054
+ * P2 hardening (F1 sweep — sync GET completeness): validate that a
1055
+ * sync GET response is a non-null, non-array object. Used by
1056
+ * `decisions.retrieve` and `decisions.verifyChain`, both of which
1057
+ * return single-object responses (`DecisionRecord` and
1058
+ * `ChainVerificationResult`) where the only legitimate kernel
1059
+ * shape is an object.
1060
+ *
1061
+ * Asserts only:
1062
+ * - `raw` is non-null
1063
+ * - `raw` is `typeof "object"` (catches scalars: string/number/etc.)
1064
+ * - `raw` is NOT an array
1065
+ *
1066
+ * Per-field validation is faithful-courier — left to the consumer.
1067
+ * The transport's envelope unwrap is also unguarded against missing
1068
+ * `data` field (kernel emitting `{success:true}` without `data`
1069
+ * would let `{success:true}` reach this function, which passes
1070
+ * because it's a non-null object — that's a separate transport-
1071
+ * layer concern, P5 candidate).
1072
+ */
1073
+ function assertNonNullObjectResponse(raw, methodName) {
1074
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1075
+ throw new AttestryError(`${methodName}: expected an object response from the kernel (got ${describeType(raw)})`);
1076
+ }
1077
+ }
1078
+ /**
1079
+ * P2 hardening: validate the kernel's `decisions.list` response shape.
1080
+ *
1081
+ * The kernel emits `{success:true, data:{items: DecisionListItem[],
1082
+ * nextCursor: string|null}}`. The transport unwraps `data`, so we
1083
+ * receive `{items, nextCursor}` here. A kernel-side regression
1084
+ * (e.g., `data: null`, missing `items`, or `nextCursor` of wrong
1085
+ * type) would let TypeScript-typed access reach consumers
1086
+ * unchecked.
1087
+ *
1088
+ * Asserts:
1089
+ * - `result` is a non-null, non-array object.
1090
+ * - `result.items` is an array.
1091
+ * - `result.nextCursor` is a string OR null (NOT undefined, NOT
1092
+ * other types).
1093
+ *
1094
+ * Per-row item shape is NOT validated (faithful-courier; consumers
1095
+ * parse via their own per-row validators). Pinning per-row would
1096
+ * require a separate hardening initiative (P4 candidate).
1097
+ */
1098
+ function assertDecisionsListResponse(raw) {
1099
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1100
+ throw new AttestryError(`decisions.list: expected an object response from the kernel (got ${describeType(raw)})`);
1101
+ }
1102
+ const obj = raw;
1103
+ if (!Array.isArray(obj.items)) {
1104
+ throw new AttestryError(`decisions.list: kernel response missing or invalid \`items\` array (got ${describeType(obj.items)})`);
1105
+ }
1106
+ if (obj.nextCursor !== null && typeof obj.nextCursor !== "string") {
1107
+ throw new AttestryError(`decisions.list: kernel response \`nextCursor\` must be string or null (got ${describeType(obj.nextCursor)})`);
1108
+ }
1109
+ }
1110
+ /**
1111
+ * Internal — async generator backing `decisions.stream`. Lazy: the
1112
+ * request is NOT issued until the first iteration. The SSE parser
1113
+ * (`parseSSEResponse`) handles connection cleanup in its own `finally`
1114
+ * block — including the early-break case where a consumer exits the
1115
+ * for-await loop before the stream ends naturally.
1116
+ */
1117
+ async function* runDecisionsStream(client, input, options) {
1118
+ const headers = {};
1119
+ if (input?.lastEventId !== undefined) {
1120
+ headers["Last-Event-ID"] = input.lastEventId;
1121
+ }
1122
+ const query = {};
1123
+ if (input?.systemId !== undefined) {
1124
+ query.systemId = input.systemId;
1125
+ }
1126
+ const response = await client._streamRequest({
1127
+ path: "/api/v1/decisions/stream",
1128
+ query,
1129
+ headers,
1130
+ options,
1131
+ });
1132
+ // Wrap mid-iteration errors in SDK error classes for symmetry with
1133
+ // pre-iteration errors (which `streamRequest` already wraps in its
1134
+ // catch block). Without this wrap:
1135
+ // - Mid-flight signal-abort would surface as DOMException
1136
+ // `AbortError`, NOT as `AttestryError("request aborted by caller")`
1137
+ // — inconsistent with the pre-aborted path. Hostile-review H1.
1138
+ // - Mid-stream network failures (TCP RST, server crash, proxy
1139
+ // hang-up) would surface as raw TypeError / AbortError, NOT as
1140
+ // `AttestryError("network error during stream: ...")` — consumers
1141
+ // can't branch on `instanceof AttestryError` uniformly.
1142
+ // Hostile-review H2.
1143
+ // SDK errors raised inside the loop (parser validation, JSON parse,
1144
+ // missing fields) are already AttestryError — pass-through.
1145
+ try {
1146
+ for await (const frame of parseSSEResponse(response)) {
1147
+ // Skip metadata-only frames (no `data:` payload). The kernel never
1148
+ // emits these today — defensive.
1149
+ if (frame.data.length === 0)
1150
+ continue;
1151
+ // Validate the SSE-level `id:` line is present and non-empty. The
1152
+ // kernel ALWAYS emits `id: <cursor>` in `formatSSEFrame` — a frame
1153
+ // missing it is either a parser bug or a server-side regression.
1154
+ // Without this check, the SDK would silently set `eventId: ""`,
1155
+ // which the consumer would pass back as `Last-Event-ID: ` and the
1156
+ // server would 400 — better to fail-fast at the SDK boundary.
1157
+ if (typeof frame.id !== "string" || frame.id.length === 0) {
1158
+ throw new AttestryError("decisions.stream: SSE frame missing required `id:` field — server emitted a frame without a resume cursor");
1159
+ }
1160
+ const payload = parseSSEData(frame.data);
1161
+ // Validate the wire shape. The SDK is the typed boundary — if the
1162
+ // server emits a malformed payload (schema bug, version skew), we
1163
+ // throw a clear error rather than yielding `undefined as string`.
1164
+ if (payload === null ||
1165
+ typeof payload !== "object" ||
1166
+ typeof payload.id !== "string" ||
1167
+ typeof payload.systemId !== "string" ||
1168
+ typeof payload.sequenceNumber !== "number" ||
1169
+ typeof payload.recordHash !== "string" ||
1170
+ (payload.prevRecordHash !== null &&
1171
+ typeof payload.prevRecordHash !== "string") ||
1172
+ typeof payload.tombstoned !== "boolean" ||
1173
+ typeof payload.createdAt !== "string") {
1174
+ throw new AttestryError("decisions.stream: SSE frame payload missing required fields or wrong type");
1175
+ }
1176
+ yield {
1177
+ id: payload.id,
1178
+ systemId: payload.systemId,
1179
+ sequenceNumber: payload.sequenceNumber,
1180
+ recordHash: payload.recordHash,
1181
+ prevRecordHash: payload.prevRecordHash,
1182
+ tombstoned: payload.tombstoned,
1183
+ createdAt: payload.createdAt,
1184
+ eventId: frame.id,
1185
+ // `?? ""` defends against a frame with no `event:` line — the
1186
+ // kernel always emits it, so the empty-string fallback is
1187
+ // unreachable in tests. Defense-in-depth marker for v8.
1188
+ /* v8 ignore next */
1189
+ eventType: frame.event ?? "",
1190
+ };
1191
+ }
1192
+ }
1193
+ catch (err) {
1194
+ if (err instanceof AttestryError)
1195
+ throw err;
1196
+ if (isAbortError(err)) {
1197
+ throw new AttestryError("request aborted by caller", { cause: err });
1198
+ }
1199
+ throw new AttestryError(`network error during stream: ${
1200
+ // parseSSE doesn't wrap reader errors (unlike parseNDJSON
1201
+ // post hostile-fix), so the err can be a TypeError (Error
1202
+ // subclass — covered) or in principle any platform-thrown
1203
+ // value. Real fetch implementations always throw Error
1204
+ // subclasses; the String(err) branch is defense-in-depth
1205
+ // for non-Error throws.
1206
+ /* v8 ignore next */
1207
+ err instanceof Error ? err.message : String(err)}`, { cause: err });
1208
+ }
1209
+ }
1210
+ /**
1211
+ * True if `err` is an AbortError-shaped exception. Both browsers and
1212
+ * Node 18+ throw `DOMException { name: "AbortError" }` when fetch /
1213
+ * stream-read is aborted — but type-narrowing on DOMException alone is
1214
+ * too broad (it includes other DOM errors). Check by name instead.
1215
+ */
1216
+ function isAbortError(err) {
1217
+ return (err !== null &&
1218
+ typeof err === "object" &&
1219
+ "name" in err &&
1220
+ err.name === "AbortError");
1221
+ }
1222
+ /**
1223
+ * Internal — async generator backing `decisions.export`. Lazy: the
1224
+ * request is NOT issued until the first iteration. The NDJSON parser
1225
+ * (`parseNDJSONResponse`) handles connection cleanup in its own
1226
+ * `finally` block — including the early-break case where a consumer
1227
+ * exits the for-await loop before the stream ends naturally.
1228
+ *
1229
+ * Mid-stream contract: throws `AttestryError("...stream ended without
1230
+ * trailer...")` if the iterator exhausts without seeing a frame with
1231
+ * `type: "ExportTrailer"`. The kernel commits to a 200 BEFORE knowing
1232
+ * the stream will succeed; mid-stream errors after that point can't
1233
+ * surface as 4xx, only as truncation. The SDK detects truncation
1234
+ * via the missing trailer and surfaces a clear error class.
1235
+ *
1236
+ * Defensive frame ordering: per build-round hostile #11 / #12, the SDK
1237
+ * accepts trailer-then-records and multi-trailer streams in wire order
1238
+ * (the kernel always emits exactly one trailer last). Once any trailer
1239
+ * has been seen, the missing-trailer check passes — extra frames
1240
+ * yielded after a trailer are still validated and emitted.
1241
+ */
1242
+ async function* runDecisionsExport(client, input, options) {
1243
+ // Build query — emit `false` literally per build-round D7 (kernel
1244
+ // session-6 stringBoolean fix means this works server-side).
1245
+ // `decisions.list`'s defense-in-depth workaround (omit when false)
1246
+ // is NOT applied here — asymmetry is deliberate.
1247
+ const query = {
1248
+ systemId: input.systemId,
1249
+ from: input.from,
1250
+ to: input.to,
1251
+ includeTombstoned: input.includeTombstoned,
1252
+ };
1253
+ const response = await client._streamRequest({
1254
+ path: "/api/v1/decisions/export",
1255
+ query,
1256
+ options,
1257
+ expectedContentType: "application/x-ndjson",
1258
+ });
1259
+ let sawTrailer = false;
1260
+ // No try/catch around the for-await: post the hostile-review fix at
1261
+ // commit 0428777, parseNDJSON wraps every reader rejection as
1262
+ // AttestryError (AbortError → "request aborted by caller"; everything
1263
+ // else → "network error during stream: ..."). Frame-validation throws
1264
+ // in this loop are also AttestryError. So errors propagate naturally
1265
+ // with the right type. Asymmetry vs `runDecisionsStream` (which still
1266
+ // wraps at the resource layer) is intentional — parseSSE doesn't wrap
1267
+ // and live in another phase.
1268
+ for await (const raw of parseNDJSONResponse(response)) {
1269
+ // Every NDJSON line must be a JSON object — neither records nor
1270
+ // trailer can be a primitive, array, or null. Defensive: kernel
1271
+ // always emits objects, but a parser yielding e.g. a bare number
1272
+ // would otherwise pass through as `frame` of type `unknown`.
1273
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1274
+ throw new AttestryError("decisions.export: NDJSON line was not a JSON object");
1275
+ }
1276
+ const obj = raw;
1277
+ // Discriminator: trailer carries `type: "ExportTrailer"`.
1278
+ if (obj.type === "ExportTrailer") {
1279
+ // Validate trailer wire shape. The SDK is the typed boundary —
1280
+ // a malformed trailer (schema bug, version skew) throws here
1281
+ // rather than yielding `undefined as string` to the caller.
1282
+ if (typeof obj.systemId !== "string" ||
1283
+ typeof obj.recordCount !== "number" ||
1284
+ (obj.sequenceFrom !== null &&
1285
+ typeof obj.sequenceFrom !== "number") ||
1286
+ (obj.sequenceTo !== null && typeof obj.sequenceTo !== "number") ||
1287
+ typeof obj.merkleRoot !== "string" ||
1288
+ typeof obj.signing !== "string" ||
1289
+ typeof obj.generatedAt !== "string") {
1290
+ throw new AttestryError("decisions.export: ExportTrailer missing required fields or wrong type");
1291
+ }
1292
+ sawTrailer = true;
1293
+ yield {
1294
+ type: "ExportTrailer",
1295
+ systemId: obj.systemId,
1296
+ recordCount: obj.recordCount,
1297
+ sequenceFrom: obj.sequenceFrom,
1298
+ sequenceTo: obj.sequenceTo,
1299
+ merkleRoot: obj.merkleRoot,
1300
+ signing: obj.signing,
1301
+ generatedAt: obj.generatedAt,
1302
+ };
1303
+ }
1304
+ else {
1305
+ // Per-record frame — must match the DecisionListItem shape.
1306
+ // Validate field-by-field; jsonb arrays accept any contents.
1307
+ if (typeof obj.id !== "string" ||
1308
+ typeof obj.systemId !== "string" ||
1309
+ typeof obj.sequenceNumber !== "number" ||
1310
+ typeof obj.inputDigest !== "string" ||
1311
+ (obj.outputDigest !== null && typeof obj.outputDigest !== "string") ||
1312
+ !Array.isArray(obj.frameworkClaims) ||
1313
+ !Array.isArray(obj.toolInvocations) ||
1314
+ !Array.isArray(obj.delegationChain) ||
1315
+ (obj.humanOversightState !== null &&
1316
+ typeof obj.humanOversightState !== "string") ||
1317
+ (obj.policyOutcome !== null &&
1318
+ typeof obj.policyOutcome !== "string") ||
1319
+ (obj.prevRecordHash !== null &&
1320
+ typeof obj.prevRecordHash !== "string") ||
1321
+ typeof obj.recordHash !== "string" ||
1322
+ typeof obj.createdAt !== "string" ||
1323
+ typeof obj.tombstoned !== "boolean") {
1324
+ throw new AttestryError("decisions.export: NDJSON record missing required fields or wrong type");
1325
+ }
1326
+ yield {
1327
+ id: obj.id,
1328
+ systemId: obj.systemId,
1329
+ sequenceNumber: obj.sequenceNumber,
1330
+ inputDigest: obj.inputDigest,
1331
+ outputDigest: obj.outputDigest,
1332
+ frameworkClaims: obj.frameworkClaims,
1333
+ toolInvocations: obj.toolInvocations,
1334
+ delegationChain: obj.delegationChain,
1335
+ humanOversightState: obj.humanOversightState,
1336
+ policyOutcome: obj.policyOutcome,
1337
+ prevRecordHash: obj.prevRecordHash,
1338
+ recordHash: obj.recordHash,
1339
+ createdAt: obj.createdAt,
1340
+ tombstoned: obj.tombstoned,
1341
+ };
1342
+ }
1343
+ }
1344
+ // No catch needed: post the hostile-review fix at commit 0428777,
1345
+ // parseNDJSON wraps every reader rejection as AttestryError
1346
+ // (AbortError → "request aborted by caller"; everything else →
1347
+ // "network error during stream: ..."). Frame-validation throws in
1348
+ // the for-await body above are also AttestryError. So errors from
1349
+ // the loop are already typed and propagate naturally. (If a future
1350
+ // refactor un-wraps in parseNDJSON, the resulting raw exception
1351
+ // will surface to the consumer — that's the right behavior to
1352
+ // expose the regression early, not paper over it.)
1353
+ // Mid-stream contract: every successful 200 ends with a trailer. If
1354
+ // we exhausted the iterator without seeing one, the kernel committed
1355
+ // to 200 and then errored after the headers were sent — the response
1356
+ // can't be a 4xx by then. Surface as a clear AttestryError so the
1357
+ // caller can branch on it. Build-round D8 / hostile #10.
1358
+ if (!sawTrailer) {
1359
+ throw new AttestryError("decisions.export: stream ended without trailer — connection dropped or server failed mid-stream");
1360
+ }
1361
+ }
1362
+ //# sourceMappingURL=decisions.js.map