@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.
- package/LICENSE +190 -0
- package/README.md +1269 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +74 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +43 -0
- package/dist/constants.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/lines-parser.d.ts +50 -0
- package/dist/lines-parser.d.ts.map +1 -0
- package/dist/lines-parser.js +211 -0
- package/dist/lines-parser.js.map +1 -0
- package/dist/ndjson-parser.d.ts +57 -0
- package/dist/ndjson-parser.d.ts.map +1 -0
- package/dist/ndjson-parser.js +245 -0
- package/dist/ndjson-parser.js.map +1 -0
- package/dist/resources/abac-policies.d.ts +1034 -0
- package/dist/resources/abac-policies.d.ts.map +1 -0
- package/dist/resources/abac-policies.js +1519 -0
- package/dist/resources/abac-policies.js.map +1 -0
- package/dist/resources/audit-log.d.ts +588 -0
- package/dist/resources/audit-log.d.ts.map +1 -0
- package/dist/resources/audit-log.js +629 -0
- package/dist/resources/audit-log.js.map +1 -0
- package/dist/resources/batch.d.ts +845 -0
- package/dist/resources/batch.d.ts.map +1 -0
- package/dist/resources/batch.js +1074 -0
- package/dist/resources/batch.js.map +1 -0
- package/dist/resources/chat.d.ts +151 -0
- package/dist/resources/chat.d.ts.map +1 -0
- package/dist/resources/chat.js +124 -0
- package/dist/resources/chat.js.map +1 -0
- package/dist/resources/check.d.ts +348 -0
- package/dist/resources/check.d.ts.map +1 -0
- package/dist/resources/check.js +543 -0
- package/dist/resources/check.js.map +1 -0
- package/dist/resources/compliance-check.d.ts +330 -0
- package/dist/resources/compliance-check.d.ts.map +1 -0
- package/dist/resources/compliance-check.js +402 -0
- package/dist/resources/compliance-check.js.map +1 -0
- package/dist/resources/decisions.d.ts +1208 -0
- package/dist/resources/decisions.d.ts.map +1 -0
- package/dist/resources/decisions.js +1362 -0
- package/dist/resources/decisions.js.map +1 -0
- package/dist/resources/evidence-pack.d.ts +1080 -0
- package/dist/resources/evidence-pack.d.ts.map +1 -0
- package/dist/resources/evidence-pack.js +1789 -0
- package/dist/resources/evidence-pack.js.map +1 -0
- package/dist/resources/gate.d.ts +613 -0
- package/dist/resources/gate.d.ts.map +1 -0
- package/dist/resources/gate.js +737 -0
- package/dist/resources/gate.js.map +1 -0
- package/dist/resources/incidents.d.ts +136 -0
- package/dist/resources/incidents.d.ts.map +1 -0
- package/dist/resources/incidents.js +229 -0
- package/dist/resources/incidents.js.map +1 -0
- package/dist/resources/regulatory-changes.d.ts +307 -0
- package/dist/resources/regulatory-changes.d.ts.map +1 -0
- package/dist/resources/regulatory-changes.js +365 -0
- package/dist/resources/regulatory-changes.js.map +1 -0
- package/dist/resources/safe-input-read.d.ts +21 -0
- package/dist/resources/safe-input-read.d.ts.map +1 -0
- package/dist/resources/safe-input-read.js +57 -0
- package/dist/resources/safe-input-read.js.map +1 -0
- package/dist/resources/ship-gate.d.ts +475 -0
- package/dist/resources/ship-gate.d.ts.map +1 -0
- package/dist/resources/ship-gate.js +727 -0
- package/dist/resources/ship-gate.js.map +1 -0
- package/dist/resources/vision.d.ts +540 -0
- package/dist/resources/vision.d.ts.map +1 -0
- package/dist/resources/vision.js +1036 -0
- package/dist/resources/vision.js.map +1 -0
- package/dist/retry.d.ts +103 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +224 -0
- package/dist/retry.js.map +1 -0
- package/dist/sse-parser.d.ts +64 -0
- package/dist/sse-parser.d.ts.map +1 -0
- package/dist/sse-parser.js +271 -0
- package/dist/sse-parser.js.map +1 -0
- package/dist/transport.d.ts +142 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +455 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- 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
|