@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,1074 @@
1
+ // ─── Batch resource ─────────────────────────────────────────────────────────
2
+ //
3
+ // Wraps the bulk classification + assessment surface (session 18):
4
+ //
5
+ // - POST /api/v1/batch Body: {jobType, systemIds, config?}
6
+ // - GET /api/v1/batch/<UUID> Retrieve batch-job status + results
7
+ //
8
+ // Sixth non-decisions resource on `@attestry/sdk`. Sibling to
9
+ // `IncidentsResource`, `DecisionsResource`, `ChatResource`,
10
+ // `AuditLogResource`, `RegulatoryChangesResource`,
11
+ // `ComplianceCheckResource`, `CheckResource`, `GateResource`.
12
+ //
13
+ // **First SDK resource with asymmetric auth between methods on the
14
+ // same resource** (carry-forward invariant candidate #54). The two
15
+ // methods use DIFFERENT permission requirements:
16
+ // - `submit()` (POST): kernel uses
17
+ // `requireApiKeyWithPermission(req, CLASSIFY, WRITE_ASSESSMENTS)`
18
+ // — UNION/OR semantics (`Array.some()` at permissions.ts:53-55).
19
+ // **First SDK route to use a WRITE-side union pair**; every prior
20
+ // SDK route's union has been READ-side
21
+ // (`READ_ASSESSMENTS or READ_SYSTEMS`).
22
+ // - `get()` (GET): kernel uses
23
+ // `requireApiKeyWithPermission(req, READ_ASSESSMENTS)` — single-
24
+ // permission auth, NOT a union. Status reads only need READ.
25
+ // Pin BOTH 401 (no/invalid key) AND 403 (key has NONE of the required
26
+ // permissions) branches separately per invariant #45 / #54.
27
+ //
28
+ // **NEW plan-guard 403 surface on `submit()`** (carry-forward
29
+ // invariant candidate #55). The kernel calls
30
+ // `requirePlan(org, "hasBatchProcessing")` at route.ts:67 BEFORE Zod
31
+ // body parsing. A free-tier org submitting a batch hits the plan
32
+ // gate FIRST (independently of permissions). The kernel emits
33
+ // `PlanLimitError` → **403 with a DIFFERENT recovery path than the
34
+ // permission-403**. Wording difference (verify against
35
+ // `src/lib/middleware/plan-guard.ts:requirePlan` — the throw is at
36
+ // lines 106-111 of that file at the time of writing; the surrounding
37
+ // function spans lines 96-112):
38
+ // - Permission 403:
39
+ // `"API key lacks required permission. Required: classify or
40
+ // write:assessments. Key has: <perms>."`
41
+ // - Plan 403:
42
+ // `"The \"hasBatchProcessing\" feature is not available on your
43
+ // current plan (<plan>). Please upgrade to access this feature."`
44
+ // The SDK surfaces both uniformly as `AttestryAPIError(403)`;
45
+ // consumers regex-match `apiErr.message` if they need to distinguish
46
+ // (the wording difference is documented; no SDK-side discriminator
47
+ // helper today — invariant candidate #55 option A). A future kernel
48
+ // version adding structured error metadata would unlock a clean
49
+ // discriminator field on `apiErr.details`.
50
+ //
51
+ // **THIRD SDK route to PRE-VALIDATE every Zod closed-spec rule
52
+ // synchronously** (after `check.run` and `gate.evaluate`). The
53
+ // kernel uses `parseBody(request, batchSubmitSchema)` where
54
+ // `batchSubmitSchema = z.object({jobType: z.enum([...]), systemIds:
55
+ // z.array(z.string().uuid()).min(1).max(50), config:
56
+ // z.object({frameworks: z.array(z.string().min(1).max(100))
57
+ // .max(20).optional()}).optional()})`. The SDK pre-validates EVERY
58
+ // closed-spec rule synchronously:
59
+ // - `jobType` membership in the 3-string enum (#41 carry-forward).
60
+ // - `systemIds` array length [1, 50] inclusive + per-element UUID
61
+ // format (#49 carry-forward; **the `.min(1)` is new** —
62
+ // gate/check's frameworks was `.max(20)` with empty allowed).
63
+ // - `config.frameworks` carry-forward from gate/check exactly:
64
+ // array length ≤20 + per-element string length [1, 100].
65
+ // The SDK's runtime checks always run regardless of TypeScript types
66
+ // — `as any` casts do NOT bypass them. So 422 from this route
67
+ // reaches consumers ONLY via kernel-side rule changes the SDK
68
+ // hasn't synced to.
69
+ //
70
+ // **Asymmetric cross-org / not-found error code on `submit()`** —
71
+ // the kernel verifies every requested system belongs to the caller's
72
+ // org (route.ts:71-85) and collapses any cross-org OR missing system
73
+ // to **404 with offending IDs EMBEDDED in the message string**:
74
+ // `"Systems not found or not in your organization: <id1>, <id2>, ..."`.
75
+ // **NEW shape vs gate's literal 404** — the message embeds variable
76
+ // data (the comma-joined invalid UUIDs). The SDK documents but does
77
+ // NOT parse the embedded IDs (faithful courier — consumers can regex-
78
+ // match if they want the IDs).
79
+ //
80
+ // **400 surface on `get()`** — the kernel's `isValidUuid(id)` check
81
+ // at route.ts:36 returns false → `errorResponse("Invalid batch job
82
+ // ID format", 400)`. **First 400 on a non-XOR SDK route** —
83
+ // `complianceCheck.check`'s 400 fires when consumer provides BOTH
84
+ // `systemId` AND `orgName`; batch's 400 is a path-param format
85
+ // failure. The SDK pre-validates the UUID format synchronously (D7
86
+ // — same regex as `systemId` in `submit()`), so the kernel 400 is
87
+ // reachable only via `as any` casts or a kernel-side switch to a
88
+ // different UUID flavor.
89
+ //
90
+ // **TWO silent kernel-side truncations** (faithful courier;
91
+ // invariant candidate #50):
92
+ // 1. `orgSystems` row-population on `submit()` — `.limit(500)` at
93
+ // route.ts:76. The kernel reads up to 500 systems from the
94
+ // caller's org to verify each `systemIds[i]` belongs to the org.
95
+ // If the org has >500 systems, the 501st+ are absent from
96
+ // `systemMap`; a `systemIds[i]` referencing one of those would
97
+ // surface as a 404 with the ID in the message (`"Systems not
98
+ // found..."`) EVEN THOUGH the system exists and the consumer
99
+ // owns it. **Documented as a kernel surface gap** — orgs with
100
+ // >500 systems may see spurious 404s on batch submissions; the
101
+ // SDK does NOT mask. Spec-diff drift pin anchors to
102
+ // `.from(schema.aiSystems)[\s\S]*?.limit(500)`.
103
+ // 2. `batchJobs` row-population on `get()` — `.limit(1)` at
104
+ // route.ts:49. Defensive only — the `where` clause already
105
+ // narrows to one row by primary-key UUID. A kernel-side
106
+ // schema change (e.g., a composite primary key or a soft-
107
+ // deleted-rows union) is the only way `.limit(1)` becomes load-
108
+ // bearing. Pin separately as belt-and-suspenders.
109
+ //
110
+ // **CLOSED ENUM on input `jobType`** — `BATCH_JOB_TYPES` is exported
111
+ // frozen so consumers can iterate (`for (const t of
112
+ // BATCH_JOB_TYPES)`) and the SDK pre-rejects unknown values
113
+ // (invariant #41). The 3 valid values are:
114
+ // - `"classify"` — run the rule-based classifier on each system.
115
+ // - `"assess"` — return each system's CURRENT risk classification
116
+ // state (read-only; no classifier re-run).
117
+ // - `"classify_and_assess"` — classify each system AND return the
118
+ // fresh classification (the `"classify"` write-path + a richer
119
+ // response). **Note**: despite the name, no separate "assess" run
120
+ // happens; the kernel only branches between (a)
121
+ // classify-then-emit and (b) emit-current-state. Verify against
122
+ // `route.ts:108-149` if rebuilding the semantics from the wire.
123
+ // Drift-pin the enum string array against the kernel's `z.enum` in
124
+ // the spec-diff round.
125
+ //
126
+ // **TWO DISTINCT STATUS ENUMS on the response** (invariant candidate
127
+ // #56 — partial-success in inline-async jobs). The wire field
128
+ // `status` lives in TWO places with DIFFERENT closed enums:
129
+ // - **Batch-job status** (top-level `response.status`): on POST,
130
+ // `"completed" | "failed"` only (kernel-computed at handler end —
131
+ // `failed === total ? "failed" : "completed"` at route.ts:170).
132
+ // On GET, the WIDER `"pending" | "processing" | "completed" |
133
+ // "failed"` enum (DB column straight pass-through). The closed
134
+ // enum is STRICTLY WIDER on GET than POST — a GET on a job
135
+ // submitted through THIS SDK never observes `"pending"`
136
+ // (already-processed inline), but a GET on a job submitted via a
137
+ // future async path (or by a non-SDK caller mid-flight) could.
138
+ // Type contract is closed at each call site; runtime is open
139
+ // (faithful courier — the P2 validator checks `typeof status ===
140
+ // "string"` only, mirroring gate's `gate: "pass" | "fail"`
141
+ // pattern).
142
+ // - **Per-row result status** (`response.results[i].status`):
143
+ // `"success" | "error"` only, in BOTH POST and GET responses.
144
+ // Discriminator for `classifications` vs `errorMessage` — use
145
+ // `row.status === "success"` (closed-enum string match), NOT
146
+ // `row.errorMessage === undefined` (prototype-pollution-unsafe
147
+ // under `Object.prototype.errorMessage` pollution; the equality
148
+ // check walks the prototype and would return false even when the
149
+ // own-property is genuinely absent).
150
+ // **Document the distinction prominently** — consumers reading
151
+ // `if (response.status === "completed")` are checking a different
152
+ // thing than `if (response.results[0].status === "success")`. Both
153
+ // drift-pinned in the spec-diff round.
154
+ //
155
+ // **`writeAuditLog` side effect on `submit()`** — every successful
156
+ // POST writes one `batch.submitted` audit log entry (route.ts:182-195;
157
+ // SAME pattern as `gate.evaluate` per invariant candidate #53). Use
158
+ // the session-17-corrected wording on the side effect's timing:
159
+ // **TIME-BLOCKING but error-tolerant** — the kernel uses `await
160
+ // writeAuditLog(...)` which awaits two DB ops inside the function
161
+ // (SELECT previous-hash + INSERT new entry, at
162
+ // `src/lib/api.ts:130-159`). The submit-call response latency
163
+ // INCLUDES the audit-log write time. Error semantics ARE
164
+ // non-blocking: `writeAuditLog` wraps its body in a try/catch that
165
+ // swallows errors and logs them, so a write FAILURE does NOT fail
166
+ // the batch submission. Audit log writes are NOT counted against
167
+ // `decisionsPerMonth` quota. **`get()` does NOT write an audit log**
168
+ // — status reads are quiet.
169
+ //
170
+ // **Symmetric prototype-pollution defense** (carry-forward of
171
+ // session-16 second-hostile-review MEDIUM #3 + session-17 build-
172
+ // round baked-in pattern) — module-load snapshot of `Object.hasOwn`
173
+ // applied to BOTH input AND response sides on BOTH methods. Without
174
+ // the response-side defense, a kernel regression that drops a
175
+ // response field combined with a hostile npm dep polluting
176
+ // `Object.prototype.<field>` would let the polluted value pass
177
+ // typeof-check via prototype walk. With the defense, missing own-
178
+ // property → describeType(undefined) → AttestryError. See build-
179
+ // round audit doc D7.
180
+ //
181
+ // **NO URIError defense on body / path** — `submit()` body uses
182
+ // `JSON.stringify`, which handles lone UTF-16 surrogates by emitting
183
+ // them as literal `\uDxxx` escapes (per JSON spec); the URIError
184
+ // defect class (invariant #32) applies only to query-string paths
185
+ // (`encodeURIComponent`). `get()`'s path segment is a UUID — the
186
+ // SDK pre-validates the format before constructing the URL, so a
187
+ // lone-surrogate or non-hex `id` is rejected synchronously
188
+ // (TypeError) before reaching `encodeURIComponent`. The kernel-side
189
+ // `isValidUuid` would also reject in the 400 fallback path if the
190
+ // SDK's pre-validation were ever bypassed (`as any`).
191
+ //
192
+ // Sync JSON request/response on BOTH methods: reuses
193
+ // `client._request` and the existing `{success:true, data}`
194
+ // envelope-unwrap (carry-forward invariant #9). NO new SDK
195
+ // primitive needed. Returns `Promise<BatchSubmitResponse>` /
196
+ // `Promise<BatchJobStatus>`.
197
+ import { AttestryError } from "../errors.js";
198
+ import { readInputField } from "./safe-input-read.js";
199
+ // Module-load snapshot of `Object.hasOwn` — defends against a
200
+ // late-loading hostile/buggy npm dependency that overrides the global
201
+ // (e.g., `Object.hasOwn = () => true`). Without the snapshot, the
202
+ // prototype-pollution defenses below would use whatever Object.hasOwn
203
+ // the dependency replaced it with at request time. Snapshotting at
204
+ // module load captures the original implementation BEFORE most
205
+ // consumer code has a chance to monkey-patch.
206
+ //
207
+ // Caveat: this is partial. If the hostile dependency is imported
208
+ // BEFORE @attestry/sdk in the consumer's load graph, the snapshot
209
+ // captures the bad version. Consumers ordering imports
210
+ // SDK-then-untrusted-deps benefit; the reverse ordering does not.
211
+ // Combined with `Object.hasOwn` itself being immune to
212
+ // `obj.hasOwnProperty = ...` overrides (per MDN), this gives a
213
+ // layered defense.
214
+ //
215
+ // Mirror of `gate.evaluate` / `check.run` / `complianceCheck.check`'s
216
+ // pattern. Used symmetrically on input AND response sides (session-16
217
+ // second-hostile-review MEDIUM #3 carry-forward — defense on both
218
+ // boundaries).
219
+ const objectHasOwn = Object.hasOwn;
220
+ // UUID format regex — RFC 4122 hyphenated form (8-4-4-4-12 hex,
221
+ // case-insensitive). Matches Zod's `z.string().uuid()` regex
222
+ // effectively. Mirror of `gate.evaluate` / `check.run`'s UUID_REGEX.
223
+ // Drift-pinned in `sdk-drift.test.ts` spec-diff round so a kernel-
224
+ // side switch to a different UUID flavor (ULID, KSUID, etc.) fires
225
+ // before consumer regressions.
226
+ const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
227
+ /**
228
+ * Closed enum for `BatchSubmitInput.jobType`. Mirrors the kernel's
229
+ * `batchSubmitSchema.jobType` at `src/app/api/v1/batch/route.ts:30`:
230
+ * `z.enum(["classify", "assess", "classify_and_assess"])`.
231
+ *
232
+ * Frozen at module load to prevent runtime mutation by hostile /
233
+ * buggy dependencies (P1 hardening — mirrors the freeze on
234
+ * `AUDIT_LOG_EXPORT_FORMATS` / `CHAT_MESSAGE_ROLES` etc.). Iterating
235
+ * `for (const t of BATCH_JOB_TYPES)` lists the 3 valid values; the
236
+ * SDK's `submit()` pre-rejects any unknown string with `TypeError`
237
+ * (invariant #41 — closed-enum SDK pre-rejection-eligible surface).
238
+ *
239
+ * Drift-pinned in `sdk-drift.test.ts` spec-diff round against the
240
+ * kernel's Zod enum to catch a kernel-side widening (e.g., adding
241
+ * `"generate_docs"` — the stale db/schema.ts:1030 comment mentions
242
+ * this value but the route's Zod schema does NOT include it; the
243
+ * comment is stale, the Zod is authoritative).
244
+ *
245
+ * **Semantics of each value**:
246
+ * - `"classify"` — run the rule-based classifier on each system
247
+ * and PERSIST the new `riskClassifications` to the system row
248
+ * (write-side effect). Per-row `classifications` contains the
249
+ * fresh classification.
250
+ * - `"assess"` — emit each system's CURRENT `riskClassifications`
251
+ * state from the DB (read-only; no write side effect, despite
252
+ * `WRITE_ASSESSMENTS` being a valid auth permission). Per-row
253
+ * `classifications` contains whatever was already on the row
254
+ * (may be `null` if no prior classification).
255
+ * - `"classify_and_assess"` — same as `"classify"` (the kernel
256
+ * branches `classify || classify_and_assess` together at
257
+ * route.ts:112). The two-name distinction is purely semantic
258
+ * for the consumer — both write the new classification and
259
+ * emit it.
260
+ */
261
+ export const BATCH_JOB_TYPES = Object.freeze([
262
+ "classify",
263
+ "assess",
264
+ "classify_and_assess",
265
+ ]);
266
+ /**
267
+ * Closed enum for `BatchJobStatus.status` (the GET response's wider
268
+ * batch-job-level status). Mirrors the kernel's
269
+ * `schema.batchJobs.status` DB column comment at
270
+ * `src/lib/db/schema.ts:1031`: `'pending' | 'processing' | 'completed' |
271
+ * 'failed'`.
272
+ *
273
+ * **Wider than the POST response's `status`** — `submit()` returns
274
+ * `status: "completed" | "failed"` only (computed at handler end from
275
+ * `failed === total`). A `get()` call against a job submitted by the
276
+ * SDK observes only `"completed" | "failed"` in practice (the job
277
+ * processed inline before the row was committed), but a `get()`
278
+ * against a job submitted via a future async path OR an out-of-band
279
+ * caller mid-flight could observe `"pending"` / `"processing"`.
280
+ *
281
+ * Frozen at module load (P1 hardening — same rationale as
282
+ * `BATCH_JOB_TYPES`). Iterating `for (const s of BATCH_JOB_STATUSES)`
283
+ * lists all 4 values. **The DB column has no kernel-side enum
284
+ * constraint** — the SDK exposes the closed union at the type level
285
+ * but the runtime validator checks `typeof status === "string"` only
286
+ * (faithful courier — same asymmetry as gate's `gate: "pass" |
287
+ * "fail"` pattern).
288
+ *
289
+ * Drift-pinned in the spec-diff round (sdk-drift.test.ts —
290
+ * "BATCH_JOB_STATUSES in SDK matches the kernel db/schema.ts
291
+ * comment + .default() literal") via TWO assertions inside the
292
+ * same `it()`:
293
+ * - **Assertion 1**: the schema's `.default("pending")` literal
294
+ * at `src/lib/db/schema.ts:1031` (machine-checkable). Fires
295
+ * on a default-change (e.g., from `"pending"` to `"queued"`).
296
+ * - **Assertion 2**: the schema column COMMENT listing all 4
297
+ * values (documentation source-of-truth). Fires on a
298
+ * widening (e.g., a new `"cancelled"` status added to the
299
+ * comment) OR a comment removal.
300
+ * The pin's failure message distinguishes which assertion fires.
301
+ */
302
+ export const BATCH_JOB_STATUSES = Object.freeze([
303
+ "pending",
304
+ "processing",
305
+ "completed",
306
+ "failed",
307
+ ]);
308
+ /**
309
+ * `batch` resource — sibling to `IncidentsResource`,
310
+ * `DecisionsResource`, `ChatResource`, `AuditLogResource`,
311
+ * `RegulatoryChangesResource`, `ComplianceCheckResource`,
312
+ * `CheckResource`, `GateResource`.
313
+ *
314
+ * Multi-method resource (mirror of `ChatResource`'s `send` +
315
+ * `stream`). Wraps TWO kernel routes:
316
+ * - `POST /api/v1/batch` via `submit(input, options?)`
317
+ * - `GET /api/v1/batch/<UUID>` via `get(id, options?)`
318
+ *
319
+ * **First SDK resource with asymmetric auth between methods on the
320
+ * same resource** (invariant candidate #54). `submit()` requires a
321
+ * key with `CLASSIFY` or `WRITE_ASSESSMENTS` AND enterprise plan;
322
+ * `get()` requires only `READ_ASSESSMENTS`. See per-method JSDoc.
323
+ */
324
+ export class BatchResource {
325
+ client;
326
+ constructor(client) {
327
+ this.client = client;
328
+ }
329
+ /**
330
+ * Submit a batch job — classify and/or read the current
331
+ * classification state for 1-50 systems in one call. Returns a
332
+ * `BatchSubmitResponse` with a per-row `results` envelope
333
+ * describing each system's outcome.
334
+ *
335
+ * **Partial-success contract**: the call resolves successfully
336
+ * (no throw) even when every row failed. Inspect
337
+ * `response.failedSystems` (or iterate `response.results` filtering
338
+ * `row.status === "error"`) to detect per-row errors. Top-level
339
+ * failures (auth, plan, rate limit, Zod, cross-org systemId,
340
+ * internal) DO throw `AttestryAPIError`. Mirror of
341
+ * `decisions.bulk`'s contract.
342
+ *
343
+ * **Multi-permission UNION auth scope (WRITE-side)**: kernel uses
344
+ * `requireApiKeyWithPermission(req, CLASSIFY, WRITE_ASSESSMENTS)`
345
+ * — OR semantics (`Array.some()` at `permissions.ts:53-55`).
346
+ * **First SDK route to use a WRITE-side union pair** (every prior
347
+ * SDK union has been READ-side). A key with EITHER permission
348
+ * (or `ADMIN`, or null/empty permissions for backwards-compat)
349
+ * succeeds. **HTTP 401** for no/invalid API key; **HTTP 403** for
350
+ * an authenticated key that has NEITHER required permission. Pin
351
+ * BOTH branches separately. Invariant #45 / #54.
352
+ *
353
+ * **NEW plan-guard 403 surface** (invariant candidate #55).
354
+ * **BEFORE Zod body parsing**, the kernel calls
355
+ * `requirePlan(org, "hasBatchProcessing")` at route.ts:67. A
356
+ * free-tier (or trial-expired non-enterprise) org hits the plan
357
+ * gate FIRST, regardless of body validity or systemIds. The
358
+ * kernel emits `PlanLimitError` which the route catches at line
359
+ * 216 and surfaces as **403** with a literal message of the form
360
+ * `'The "hasBatchProcessing" feature is not available on your
361
+ * current plan (<plan>). Please upgrade to access this feature.'`
362
+ * Distinct from the permission-403 message
363
+ * `'API key lacks required permission. Required: ... Key has: ...'`
364
+ * — consumers regex-match the message contents if they need to
365
+ * distinguish "upgrade your plan" from "grant more permissions to
366
+ * your key". Both surface uniformly as `AttestryAPIError(403)`
367
+ * (no SDK-side discriminator helper today).
368
+ *
369
+ * **Asymmetric cross-org / not-found error code (404 with
370
+ * EMBEDDED IDs)**: the kernel verifies every requested system
371
+ * belongs to the caller's org and collapses cross-org OR missing
372
+ * to 404. **NEW shape vs gate's literal 404** — the message
373
+ * embeds the comma-joined invalid UUIDs:
374
+ * `'Systems not found or not in your organization: <id1>, <id2>, ...'`.
375
+ * The SDK does NOT parse the embedded IDs — faithful courier;
376
+ * consumers can regex-match if they want to surface specific IDs
377
+ * to users.
378
+ *
379
+ * **TWO silent kernel-side truncations** (invariant candidate
380
+ * #50):
381
+ * 1. `orgSystems` row-population — `.limit(500)` at route.ts:76.
382
+ * The kernel reads up to 500 systems from the caller's org
383
+ * to verify membership. Orgs with >500 systems may see
384
+ * spurious 404s on batch submissions referencing systems
385
+ * outside the first 500 rows. **Documented kernel surface
386
+ * gap**; the SDK does NOT mask. Pin anchored to
387
+ * `.from(schema.aiSystems)[\s\S]*?.limit(500)` in the spec-
388
+ * diff round.
389
+ * 2. (GET-side `.limit(1)` is documented under `get()` below.)
390
+ *
391
+ * **`writeAuditLog` side effect** — every successful `submit()`
392
+ * call writes one `batch.submitted` entry to the org's audit log
393
+ * (route.ts:182-195). Properties of the write:
394
+ * - Org-scoped, hash-chained (per `writeAuditLog` at
395
+ * `src/lib/api.ts:125-`).
396
+ * - **Time-blocking** but error-tolerant: the kernel uses
397
+ * `await writeAuditLog(...)`, which awaits two DB ops (SELECT
398
+ * previous-hash + INSERT new entry). The submit-call response
399
+ * latency INCLUDES the audit-log write time. Error semantics
400
+ * ARE non-blocking: `writeAuditLog` wraps its body in a
401
+ * try/catch that swallows errors and logs them, so a write
402
+ * FAILURE does NOT fail the submit request.
403
+ * - NOT counted against `decisionsPerMonth` quota.
404
+ *
405
+ * **Closed-enum input `jobType`** — pre-rejected SDK-side if not
406
+ * one of `BATCH_JOB_TYPES`. Use `BATCH_JOB_TYPES` to iterate or
407
+ * narrow at call sites.
408
+ *
409
+ * **Per-row discriminator** — use `row.status === "success"` (NOT
410
+ * `row.errorMessage === undefined`, which is prototype-pollution
411
+ * unsafe). See `BatchSystemResult` JSDoc for the full rationale.
412
+ *
413
+ * Errors — ordered by kernel firing precedence (rate-limit →
414
+ * auth → org-load (404 if missing) → plan-guard → Zod body
415
+ * validation → DB membership check → inline processing →
416
+ * internal). A request with multiple problems surfaces ONLY the
417
+ * highest-precedence one. For example: a request with bad auth
418
+ * AND a free-tier org surfaces 401, not 403 (plan-guard fires
419
+ * AFTER auth). A request whose org row was deleted between key
420
+ * issuance and request time surfaces a 404 BEFORE the plan-
421
+ * guard 403 fires (rare in practice). A request with valid auth
422
+ * + valid org + free-tier + a malformed body surfaces 403
423
+ * (plan-guard fires BEFORE Zod body validation).
424
+ *
425
+ * - `AttestryAPIError` (status 429) — rate limit FIRES FIRST
426
+ * (auto-retried by default — invariant #18; per-IP rate-limit
427
+ * key `v1-batch:${ip}`; tighter limiter than apiLimiter —
428
+ * `assessmentLimiter` at 30 req/min vs apiLimiter's 60).
429
+ * - `AttestryAPIError` (status 401) — no API key OR invalid key.
430
+ * - `AttestryAPIError` (status 403, permission branch) — key
431
+ * has NEITHER `CLASSIFY` nor `WRITE_ASSESSMENTS`.
432
+ * - `AttestryAPIError` (status 404, org-not-found branch) — the
433
+ * caller's org row was deleted (route.ts:66: `if (!org)
434
+ * return errorResponse("Organization not found", 404)`).
435
+ * **Distinct from the systems-not-found 404 below** — same
436
+ * status code, different message. Rare in practice (orgs
437
+ * aren't deleted while keys are active).
438
+ * - `AttestryAPIError` (status 403, plan-gate branch) — the
439
+ * org's effective plan doesn't have `hasBatchProcessing`.
440
+ * Distinct wording from the permission-403 (above) — consumers
441
+ * regex-match to distinguish.
442
+ * - `AttestryAPIError` (status 422) — Zod schema rejection
443
+ * (kernel's `BodyParseError` surface — `parseBody(request,
444
+ * batchSubmitSchema)` failed). `apiErr.details` carries the
445
+ * full kernel error body verbatim (the transport does NOT
446
+ * strip the `{success:false, ...}` envelope on error
447
+ * responses — only the `{success:true, data}` envelope on
448
+ * success). The wire shape is: `{success: false, error:
449
+ * "Validation failed.", details: Array<{path: string;
450
+ * message: string}>}` — `error` is the literal string
451
+ * "Validation failed." (with trailing period), `details` is
452
+ * an array (NOT a keyed map) of `{path, message}` pairs
453
+ * derived from Zod's `result.error.errors`. Consumers reading
454
+ * field-by-field errors should iterate `apiErr.details.details`
455
+ * (the kernel's `details` array nested under the SDK's parsed-
456
+ * body wrapper). **The SDK pre-validates all closed-spec
457
+ * rules** (jobType enum membership, systemIds array length
458
+ * [1, 50] + per-element UUID format, frameworks array length
459
+ * ≤20 + per-element string length [1, 100]) AND the runtime
460
+ * checks always run regardless of TypeScript types — `as any`
461
+ * casts do NOT bypass them. So 422 reaches consumers ONLY
462
+ * via kernel rule changes the SDK hasn't synced to. Invariant
463
+ * #51.
464
+ * - `AttestryAPIError` (status 404, systems-not-found branch) —
465
+ * one or more `systemIds[i]` are not in the caller's org (or
466
+ * don't exist). The kernel collapses cross-org and genuine-
467
+ * missing to 404 with the literal message
468
+ * `"Systems not found or not in your organization: <id1>, <id2>, ..."`.
469
+ * **Embedded IDs** — the SDK does NOT parse the offending UUIDs
470
+ * out of the message; consumers can regex-match if needed.
471
+ * - `AttestryAPIError` (status 500) — internal kernel error
472
+ * (scrubbed message via `internalErrorResponse`).
473
+ * - `AttestryError` ("request aborted by caller") — caller-
474
+ * supplied `options.signal` fired (pre-aborted or mid-flight).
475
+ * - `AttestryError` (P2 hardening) — kernel response failed
476
+ * SDK-side shape validation (not an object, wrong type on
477
+ * any of the 10 response fields).
478
+ * - `AttestryAPIError` (P3 hardening) — kernel response had a
479
+ * wrong Content-Type (transport-level guard before body
480
+ * parsing).
481
+ * - `TypeError` — input failed SDK-side validation (null /
482
+ * array / non-object input, missing jobType, unknown
483
+ * jobType, missing systemIds, non-array systemIds, empty
484
+ * systemIds, oversize systemIds, non-string systemIds
485
+ * element, non-UUID systemIds element, non-object config,
486
+ * non-array config.frameworks, oversize config.frameworks,
487
+ * non-string config.frameworks element, oversize/empty
488
+ * config.frameworks element). **THROWN SYNCHRONOUSLY** (no
489
+ * fetch issued; the function does NOT return a promise in
490
+ * this case). Distinct from `AttestryAPIError` /
491
+ * `AttestryError` above, which reject through the returned
492
+ * promise. Consumers using `await client.batch.submit(...)`
493
+ * see both surfaces uniformly; consumers wrapping the call
494
+ * in a non-awaiting context (e.g., `client.batch.submit(...)
495
+ * .then(...)`) must catch the synchronous throw with a
496
+ * surrounding try/catch — the `.then()` chain alone does
497
+ * NOT catch synchronous TypeErrors.
498
+ *
499
+ * **Notably ABSENT**:
500
+ * - **No 400** on POST — all input validation is Zod (422).
501
+ * The GET method DOES have a 400 (malformed `id` path
502
+ * parameter).
503
+ * - **No 413** — body size limit not explicit.
504
+ * - **No 402** — read-shaped from a quota perspective (despite
505
+ * writing per-system classifications, doesn't count against
506
+ * `decisionsPerMonth`).
507
+ *
508
+ * **SDK-side validation** (synchronous `TypeError`, no fetch
509
+ * issued):
510
+ * - `input` itself: required; must be a non-null, non-array
511
+ * object.
512
+ * - `input.jobType`: required own-property; must be a string;
513
+ * must be one of `BATCH_JOB_TYPES`.
514
+ * - `input.systemIds`: required own-property; must be an Array;
515
+ * length [1, 50] inclusive; each element a non-empty string
516
+ * matching `UUID_REGEX`. Snapshot via `Array.from` for TOCTOU
517
+ * defense.
518
+ * - `input.config` (when own-property present, value not
519
+ * undefined): must be a non-null non-array object.
520
+ * - `input.config.frameworks` (when own-property present, value
521
+ * not undefined): must be an array of ≤20 strings, each of
522
+ * length 1-100. Snapshot via `Array.from` for TOCTOU defense.
523
+ *
524
+ * **Response-shape validation** (P2 hardening — symmetric defense
525
+ * on response side, mirror of session-16 second-hostile-review
526
+ * MEDIUM #3 carry-forward):
527
+ * - Rejects with `AttestryError` if the kernel response isn't a
528
+ * non-null, non-array object.
529
+ * - Rejects if `id` / `jobType` / `status` / `createdAt` /
530
+ * `completedAt` aren't strings.
531
+ * - Rejects if `startedAt` isn't a string or null.
532
+ * - Rejects if `totalSystems` / `processedSystems` /
533
+ * `failedSystems` aren't numbers.
534
+ * - Rejects if `results` isn't an array.
535
+ * - Per-row shape (open-spec — `BatchSystemResult`) is faithful-
536
+ * courier — NOT validated.
537
+ * - Each response field read goes through the module-load
538
+ * `objectHasOwn` snapshot (symmetric to input-side defense).
539
+ *
540
+ * **Transport-shape validation** (P3 hardening):
541
+ * - Rejects with `AttestryAPIError` if the kernel responds with
542
+ * a non-`application/json` Content-Type.
543
+ *
544
+ * @example Submit a classify job for 3 systems
545
+ * ```ts
546
+ * const result = await client.batch.submit({
547
+ * jobType: "classify",
548
+ * systemIds: [
549
+ * "11111111-1111-1111-1111-111111111111",
550
+ * "22222222-2222-2222-2222-222222222222",
551
+ * "33333333-3333-3333-3333-333333333333",
552
+ * ],
553
+ * });
554
+ * console.log(`Processed ${result.processedSystems}/${result.totalSystems}`);
555
+ * for (const row of result.results) {
556
+ * if (row.status === "success") {
557
+ * console.log(`OK ${row.systemId}:`, row.classifications);
558
+ * } else {
559
+ * // CRITICAL: branch on `row.status === "error"` — NOT
560
+ * // `row.errorMessage === undefined` (prototype-pollution
561
+ * // unsafe).
562
+ * console.error(`FAIL ${row.systemId}: ${row.errorMessage}`);
563
+ * }
564
+ * }
565
+ * ```
566
+ *
567
+ * @example Submit with framework filter (round-trip only today)
568
+ * ```ts
569
+ * const job = await client.batch.submit({
570
+ * jobType: "classify_and_assess",
571
+ * systemIds: ["11111111-1111-1111-1111-111111111111"],
572
+ * config: { frameworks: ["EU_AI_ACT", "ISO_42001"] },
573
+ * });
574
+ * // config.frameworks is persisted on the row but has no effect on
575
+ * // the current inline classification path.
576
+ * ```
577
+ */
578
+ submit(input, options) {
579
+ // Top-level shape — input is REQUIRED. typeof null === "object"
580
+ // and typeof [] === "object", so guard both explicitly.
581
+ if (input === null ||
582
+ typeof input !== "object" ||
583
+ Array.isArray(input)) {
584
+ throw new TypeError("batch.submit: `input` must be a non-null object with `jobType` + `systemIds`");
585
+ }
586
+ // Snapshot each field's value EXACTLY ONCE up front via the
587
+ // own-property indexer. Three motivations (same as gate.evaluate):
588
+ // 1. Prototype-pollution defense (generalization of #48).
589
+ // Pollution of `Object.prototype.<field>` does NOT trick
590
+ // the SDK into silently sending the polluted value when
591
+ // the user passes an object without that own property.
592
+ // Uses the module-load `objectHasOwn` snapshot so a late-
593
+ // loading dep overriding `Object.hasOwn` doesn't defeat
594
+ // the defense.
595
+ // 2. TOCTOU defense: a Proxy or getter-defining input could
596
+ // yield DIFFERENT values across multiple reads. Snapshot-
597
+ // then-validate collapses validate-then-send to a single
598
+ // read per field; the validated value is provably the
599
+ // value sent.
600
+ // 3. Explicit empty / missing fields are treated as omission
601
+ // — `objectHasOwn` correctly returns false on missing keys.
602
+ const hasJobType = objectHasOwn(input, "jobType");
603
+ const jobTypeRaw = hasJobType
604
+ ? readInputField(input, "jobType", "batch.submit")
605
+ : undefined;
606
+ const hasSystemIds = objectHasOwn(input, "systemIds");
607
+ const systemIdsRaw = hasSystemIds
608
+ ? readInputField(input, "systemIds", "batch.submit")
609
+ : undefined;
610
+ const hasConfig = objectHasOwn(input, "config");
611
+ const configRaw = hasConfig
612
+ ? readInputField(input, "config", "batch.submit")
613
+ : undefined;
614
+ // jobType — REQUIRED, closed-enum string membership.
615
+ if (!hasJobType || jobTypeRaw === undefined) {
616
+ throw new TypeError("batch.submit: `jobType` is required");
617
+ }
618
+ if (typeof jobTypeRaw !== "string") {
619
+ throw new TypeError(`batch.submit: \`jobType\` must be a string ` +
620
+ `(got ${describeType(jobTypeRaw)})`);
621
+ }
622
+ // Closed-enum SDK pre-rejection (invariant #41). The kernel's
623
+ // Zod `z.enum([...])` enforces the same; the SDK pre-rejects
624
+ // synchronously to fail fast and surface a clear error.
625
+ if (!BATCH_JOB_TYPES.includes(jobTypeRaw)) {
626
+ throw new TypeError(`batch.submit: \`jobType\` must be one of ` +
627
+ `${JSON.stringify(BATCH_JOB_TYPES)} (got ${JSON.stringify(jobTypeRaw)})`);
628
+ }
629
+ const validatedJobType = jobTypeRaw;
630
+ // systemIds — REQUIRED array of 1-50 UUIDs. Snapshot via
631
+ // Array.from up front so a Proxy whose `.length` or `[i]`
632
+ // changes between reads can't slip past validation. Per-element
633
+ // pre-validation matches Zod's `.array(z.string().uuid())
634
+ // .min(1).max(50)` exactly.
635
+ if (!hasSystemIds || systemIdsRaw === undefined) {
636
+ throw new TypeError("batch.submit: `systemIds` is required");
637
+ }
638
+ if (!Array.isArray(systemIdsRaw)) {
639
+ throw new TypeError(`batch.submit: \`systemIds\` must be an array ` +
640
+ `(got ${describeType(systemIdsRaw)})`);
641
+ }
642
+ const systemIdsSnapshot = Array.from(systemIdsRaw);
643
+ // .min(1) — distinct from gate/check's frameworks (which allowed
644
+ // empty). Empty batches are rejected at the Zod level; SDK pre-
645
+ // rejects for symmetry.
646
+ if (systemIdsSnapshot.length < 1) {
647
+ throw new TypeError("batch.submit: `systemIds` must contain at least 1 entry " +
648
+ "(empty arrays rejected — Zod `.min(1, \"At least one system ID is required\")`)");
649
+ }
650
+ if (systemIdsSnapshot.length > 50) {
651
+ throw new TypeError(`batch.submit: \`systemIds\` array exceeds the kernel's max ` +
652
+ `length of 50 (got ${systemIdsSnapshot.length})`);
653
+ }
654
+ for (let i = 0; i < systemIdsSnapshot.length; i++) {
655
+ const elem = systemIdsSnapshot[i];
656
+ if (typeof elem !== "string") {
657
+ throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be a string ` +
658
+ `(got ${describeType(elem)})`);
659
+ }
660
+ if (elem.length === 0) {
661
+ throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be a non-empty string`);
662
+ }
663
+ if (!UUID_REGEX.test(elem)) {
664
+ throw new TypeError(`batch.submit: \`systemIds[${i}]\` must be an RFC 4122 hyphenated UUID ` +
665
+ `(matched regex: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-` +
666
+ `[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, got ${JSON.stringify(elem)})`);
667
+ }
668
+ }
669
+ const validatedSystemIds = systemIdsSnapshot;
670
+ // config — OPTIONAL. When provided, must be a non-null non-
671
+ // array object with optional `frameworks` matching gate/check's
672
+ // exact shape.
673
+ let validatedConfig;
674
+ if (hasConfig && configRaw !== undefined) {
675
+ if (configRaw === null ||
676
+ typeof configRaw !== "object" ||
677
+ Array.isArray(configRaw)) {
678
+ throw new TypeError(`batch.submit: \`config\` must be a non-null object when provided ` +
679
+ `(got ${describeType(configRaw)})`);
680
+ }
681
+ const hasFrameworks = objectHasOwn(configRaw, "frameworks");
682
+ const frameworksRaw = hasFrameworks
683
+ ? configRaw.frameworks
684
+ : undefined;
685
+ let validatedFrameworks;
686
+ if (hasFrameworks && frameworksRaw !== undefined) {
687
+ if (!Array.isArray(frameworksRaw)) {
688
+ throw new TypeError(`batch.submit: \`config.frameworks\` must be an array when provided ` +
689
+ `(got ${describeType(frameworksRaw)})`);
690
+ }
691
+ const fwSnapshot = Array.from(frameworksRaw);
692
+ if (fwSnapshot.length > 20) {
693
+ throw new TypeError(`batch.submit: \`config.frameworks\` array exceeds the kernel's max ` +
694
+ `length of 20 (got ${fwSnapshot.length})`);
695
+ }
696
+ for (let i = 0; i < fwSnapshot.length; i++) {
697
+ const elem = fwSnapshot[i];
698
+ if (typeof elem !== "string") {
699
+ throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` must be a string ` +
700
+ `(got ${describeType(elem)})`);
701
+ }
702
+ if (elem.length === 0) {
703
+ throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` must be a non-empty string`);
704
+ }
705
+ if (elem.length > 100) {
706
+ throw new TypeError(`batch.submit: \`config.frameworks[${i}]\` exceeds the kernel's max ` +
707
+ `length of 100 chars (got ${elem.length})`);
708
+ }
709
+ }
710
+ validatedFrameworks = fwSnapshot;
711
+ }
712
+ validatedConfig = {};
713
+ if (validatedFrameworks !== undefined) {
714
+ validatedConfig.frameworks = validatedFrameworks;
715
+ }
716
+ }
717
+ // Construct the body. Omit `config` if the consumer didn't
718
+ // provide it (kernel applies its own default — none today;
719
+ // `config` is `.optional()` with no `.default()`). Omitting an
720
+ // optional field is preferred over emitting `null` so the
721
+ // kernel sees the field as absent.
722
+ const body = {
723
+ jobType: validatedJobType,
724
+ systemIds: validatedSystemIds,
725
+ };
726
+ if (validatedConfig !== undefined) {
727
+ body.config = validatedConfig;
728
+ }
729
+ return this.client
730
+ ._request({
731
+ method: "POST",
732
+ path: "/api/v1/batch",
733
+ body,
734
+ options,
735
+ })
736
+ .then((result) => validateBatchSubmitResponse(result));
737
+ }
738
+ /**
739
+ * Retrieve a batch job's status and results by UUID. Returns a
740
+ * `BatchJobStatus` with the wider 4-value `status` enum (NOT the
741
+ * narrower `"completed" | "failed"` of POST) plus the original
742
+ * `config` (round-tripped from submission).
743
+ *
744
+ * **Single-permission auth scope (DIFFERENT from `submit()`)** —
745
+ * kernel uses `requireApiKeyWithPermission(req, READ_ASSESSMENTS)`
746
+ * with ONLY ONE required permission, NOT a union. Status reads
747
+ * don't need `CLASSIFY` or `WRITE_ASSESSMENTS`. **First SDK
748
+ * resource with asymmetric auth between methods on the same
749
+ * resource** (invariant candidate #54). Pin BOTH 401 (no/invalid
750
+ * key) AND 403 (key lacks `READ_ASSESSMENTS`) branches.
751
+ *
752
+ * **NO plan-guard surface on `get()`** — `requirePlan` is invoked
753
+ * only in `submit()`. A free-tier org can `get()` a job that was
754
+ * previously submitted (e.g., on a higher plan that has since
755
+ * downgraded). The submission would have been gated; the read
756
+ * isn't.
757
+ *
758
+ * **400 surface on malformed UUID path parameter** — the kernel's
759
+ * `isValidUuid(id)` check at route.ts:36 returns false →
760
+ * `errorResponse("Invalid batch job ID format", 400)`. The SDK
761
+ * pre-validates the UUID format synchronously (`TypeError`) — so
762
+ * the 400 reaches consumers only via `as any` casts or a kernel-
763
+ * side switch to a different UUID flavor (ULID, KSUID, etc.).
764
+ *
765
+ * **404 surface (literal string)** — the kernel's `where(id +
766
+ * orgId)` query returns zero rows → `errorResponse("Batch job not
767
+ * found", 404)`. **NEW shape** vs `submit()`'s 404 with embedded
768
+ * IDs — the `get()` 404 is a LITERAL string with no variable
769
+ * data. Cross-org `id` collapses to the same 404 (the `eq(orgId,
770
+ * apiKeyUser.orgId)` clause silently filters out matching IDs in
771
+ * other orgs).
772
+ *
773
+ * **No `writeAuditLog` side effect on `get()`** — status reads
774
+ * are quiet. Asymmetric with `submit()`'s `batch.submitted`
775
+ * write.
776
+ *
777
+ * **Defensive `.limit(1)` on the batchJobs query** (route.ts:49)
778
+ * — the `where` clause already narrows to one row by primary-key
779
+ * UUID, so this cap is belt-and-suspenders against a hypothetical
780
+ * future schema change (composite primary key, soft-deleted-rows
781
+ * union). Pin separately as a kernel surface gap. Invariant
782
+ * candidate #50.
783
+ *
784
+ * Errors — ordered by kernel firing precedence (rate-limit →
785
+ * auth → UUID format → DB lookup → internal):
786
+ * - `AttestryAPIError` (status 429) — rate limit (auto-retried;
787
+ * per-IP key `v1-batch-status:${ip}`; uses the standard
788
+ * `apiLimiter` at 60 req/min — looser than `submit()`'s 30/
789
+ * min `assessmentLimiter`).
790
+ * - `AttestryAPIError` (status 401) — no API key OR invalid key.
791
+ * - `AttestryAPIError` (status 403) — authenticated key lacks
792
+ * `READ_ASSESSMENTS` permission (single-permission check,
793
+ * NOT a union). Pin separately from `submit()`'s 403.
794
+ * - `AttestryAPIError` (status 400) — kernel's `isValidUuid(id)`
795
+ * returned false. **The SDK pre-validates UUID format**, so
796
+ * this 400 reaches consumers ONLY via `as any` casts or
797
+ * kernel-side UUID flavor changes.
798
+ * - `AttestryAPIError` (status 404) — batch job not found OR
799
+ * cross-org `id` (kernel collapses to "Batch job not found";
800
+ * literal string with NO embedded variable data — distinct
801
+ * from `submit()`'s 404 shape).
802
+ * - `AttestryAPIError` (status 500) — internal kernel error
803
+ * (scrubbed message).
804
+ * - `AttestryError` ("request aborted by caller") — caller-
805
+ * supplied `options.signal` fired.
806
+ * - `AttestryError` (P2 hardening) — kernel response failed
807
+ * SDK-side shape validation (11 fields).
808
+ * - `AttestryAPIError` (P3 hardening) — wrong Content-Type.
809
+ * - `TypeError` — input failed SDK-side validation (missing
810
+ * `id`, non-string `id`, empty `id`, malformed UUID `id`).
811
+ * **THROWN SYNCHRONOUSLY** (no fetch issued; not via the
812
+ * returned promise). Consumers using `.then(...)` without
813
+ * a surrounding try/catch see this surface as an uncaught
814
+ * synchronous throw — same caveat as `submit()`.
815
+ *
816
+ * **SDK-side validation** (synchronous `TypeError`, no fetch
817
+ * issued):
818
+ * - `id`: required; must be a non-empty string matching
819
+ * `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive).
820
+ *
821
+ * **Response-shape validation** (P2 hardening — 11 fields, all
822
+ * always-present; symmetric defense on response side via the
823
+ * module-load `objectHasOwn` snapshot):
824
+ * - Rejects with `AttestryError` if the response isn't a non-
825
+ * null, non-array object.
826
+ * - Rejects if `id` / `jobType` / `status` / `createdAt` aren't
827
+ * strings.
828
+ * - Rejects if `totalSystems` / `processedSystems` /
829
+ * `failedSystems` aren't numbers.
830
+ * - Rejects if `results` isn't `null` OR an array.
831
+ * - Rejects if `config` isn't `null` OR a non-array object.
832
+ * - Rejects if `startedAt` / `completedAt` aren't `null` OR
833
+ * a string.
834
+ * - Per-row shape (open-spec `BatchSystemResult`) is faithful-
835
+ * courier — NOT validated.
836
+ * - `config.frameworks` shape is faithful-courier — NOT
837
+ * validated.
838
+ *
839
+ * **NO URIError defense on the `id` path segment** — the SDK
840
+ * pre-validates the UUID format (synchronous `TypeError`) BEFORE
841
+ * constructing the URL. A lone-surrogate or non-hex `id` is
842
+ * rejected before any `encodeURIComponent`-style call could fire.
843
+ * Hex-only UUIDs are guaranteed-safe for path concatenation.
844
+ *
845
+ * @example Poll a job's status
846
+ * ```ts
847
+ * const job = await client.batch.get("11111111-1111-1111-1111-111111111111");
848
+ * if (job.status === "completed") {
849
+ * console.log(`Processed ${job.processedSystems}/${job.totalSystems}`);
850
+ * } else if (job.status === "failed") {
851
+ * console.error("Batch failed entirely");
852
+ * } else {
853
+ * // "pending" / "processing" — still in flight
854
+ * console.log(`Job is ${job.status}`);
855
+ * }
856
+ * ```
857
+ */
858
+ get(id, options) {
859
+ if (typeof id !== "string" || id.length === 0) {
860
+ throw new TypeError("batch.get: `id` must be a non-empty string");
861
+ }
862
+ // UUID format pre-validation (D7 — SDK matches kernel's
863
+ // `isValidUuid` check at route.ts:36). Mirror of gate /
864
+ // check's UUID_REGEX. The kernel's 400 ("Invalid batch job ID
865
+ // format") is reachable only via `as any` cast or future
866
+ // kernel-side UUID flavor changes.
867
+ if (!UUID_REGEX.test(id)) {
868
+ throw new TypeError("batch.get: `id` must be an RFC 4122 hyphenated UUID " +
869
+ "(matched regex: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-" +
870
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, got " +
871
+ JSON.stringify(id) +
872
+ ")");
873
+ }
874
+ return this.client
875
+ ._request({
876
+ method: "GET",
877
+ path: `/api/v1/batch/${id}`,
878
+ options,
879
+ })
880
+ .then((result) => validateBatchJobStatusResponse(result));
881
+ }
882
+ }
883
+ /**
884
+ * P2 hardening: validate the POST response's 10 always-present
885
+ * fields. Symmetric prototype-pollution defense — read EACH field
886
+ * via the module-load `objectHasOwn` snapshot so a hostile npm dep
887
+ * polluting `Object.prototype.<field>` cannot mask a kernel
888
+ * regression that drops the field (per session-16 second-hostile-
889
+ * review MEDIUM #3 carry-forward — defense applied on both input
890
+ * AND response boundaries).
891
+ *
892
+ * Returns the validated `result` (typed `BatchSubmitResponse`) on
893
+ * success; throws `AttestryError` on any shape violation. Extracted
894
+ * as a free function so the resource method body stays focused on
895
+ * input validation + request construction.
896
+ */
897
+ function validateBatchSubmitResponse(result) {
898
+ if (result === null ||
899
+ typeof result !== "object" ||
900
+ Array.isArray(result)) {
901
+ throw new AttestryError(`batch.submit: expected an object response from the kernel ` +
902
+ `(got ${describeType(result)})`);
903
+ }
904
+ const obj = result;
905
+ const id = objectHasOwn(obj, "id") ? obj.id : undefined;
906
+ if (typeof id !== "string") {
907
+ throw new AttestryError(`batch.submit: expected response.id to be a string ` +
908
+ `(got ${describeType(id)})`);
909
+ }
910
+ const jobType = objectHasOwn(obj, "jobType") ? obj.jobType : undefined;
911
+ if (typeof jobType !== "string") {
912
+ throw new AttestryError(`batch.submit: expected response.jobType to be a string ` +
913
+ `(got ${describeType(jobType)})`);
914
+ }
915
+ const status = objectHasOwn(obj, "status") ? obj.status : undefined;
916
+ if (typeof status !== "string") {
917
+ throw new AttestryError(`batch.submit: expected response.status to be a string ` +
918
+ `(got ${describeType(status)})`);
919
+ }
920
+ const totalSystems = objectHasOwn(obj, "totalSystems")
921
+ ? obj.totalSystems
922
+ : undefined;
923
+ if (typeof totalSystems !== "number") {
924
+ throw new AttestryError(`batch.submit: expected response.totalSystems to be a number ` +
925
+ `(got ${describeType(totalSystems)})`);
926
+ }
927
+ const processedSystems = objectHasOwn(obj, "processedSystems")
928
+ ? obj.processedSystems
929
+ : undefined;
930
+ if (typeof processedSystems !== "number") {
931
+ throw new AttestryError(`batch.submit: expected response.processedSystems to be a number ` +
932
+ `(got ${describeType(processedSystems)})`);
933
+ }
934
+ const failedSystems = objectHasOwn(obj, "failedSystems")
935
+ ? obj.failedSystems
936
+ : undefined;
937
+ if (typeof failedSystems !== "number") {
938
+ throw new AttestryError(`batch.submit: expected response.failedSystems to be a number ` +
939
+ `(got ${describeType(failedSystems)})`);
940
+ }
941
+ const results = objectHasOwn(obj, "results") ? obj.results : undefined;
942
+ if (!Array.isArray(results)) {
943
+ throw new AttestryError(`batch.submit: expected response.results to be an array ` +
944
+ `(got ${describeType(results)})`);
945
+ }
946
+ const createdAt = objectHasOwn(obj, "createdAt")
947
+ ? obj.createdAt
948
+ : undefined;
949
+ if (typeof createdAt !== "string") {
950
+ throw new AttestryError(`batch.submit: expected response.createdAt to be a string ` +
951
+ `(got ${describeType(createdAt)})`);
952
+ }
953
+ const startedAt = objectHasOwn(obj, "startedAt")
954
+ ? obj.startedAt
955
+ : undefined;
956
+ if (startedAt !== null && typeof startedAt !== "string") {
957
+ throw new AttestryError(`batch.submit: expected response.startedAt to be a string or null ` +
958
+ `(got ${describeType(startedAt)})`);
959
+ }
960
+ const completedAt = objectHasOwn(obj, "completedAt")
961
+ ? obj.completedAt
962
+ : undefined;
963
+ if (typeof completedAt !== "string") {
964
+ throw new AttestryError(`batch.submit: expected response.completedAt to be a string ` +
965
+ `(got ${describeType(completedAt)})`);
966
+ }
967
+ return result;
968
+ }
969
+ /**
970
+ * P2 hardening: validate the GET response's 11 always-present
971
+ * fields. Same symmetric defense as `validateBatchSubmitResponse`.
972
+ *
973
+ * `results` and `config` are NULLABLE (DB jsonb columns); the
974
+ * validator accepts `null` OR the typed shape. `startedAt` and
975
+ * `completedAt` are BOTH nullable on GET (in contrast to POST where
976
+ * `completedAt` is always a string).
977
+ */
978
+ function validateBatchJobStatusResponse(result) {
979
+ if (result === null ||
980
+ typeof result !== "object" ||
981
+ Array.isArray(result)) {
982
+ throw new AttestryError(`batch.get: expected an object response from the kernel ` +
983
+ `(got ${describeType(result)})`);
984
+ }
985
+ const obj = result;
986
+ const id = objectHasOwn(obj, "id") ? obj.id : undefined;
987
+ if (typeof id !== "string") {
988
+ throw new AttestryError(`batch.get: expected response.id to be a string ` +
989
+ `(got ${describeType(id)})`);
990
+ }
991
+ const jobType = objectHasOwn(obj, "jobType") ? obj.jobType : undefined;
992
+ if (typeof jobType !== "string") {
993
+ throw new AttestryError(`batch.get: expected response.jobType to be a string ` +
994
+ `(got ${describeType(jobType)})`);
995
+ }
996
+ const status = objectHasOwn(obj, "status") ? obj.status : undefined;
997
+ if (typeof status !== "string") {
998
+ throw new AttestryError(`batch.get: expected response.status to be a string ` +
999
+ `(got ${describeType(status)})`);
1000
+ }
1001
+ const totalSystems = objectHasOwn(obj, "totalSystems")
1002
+ ? obj.totalSystems
1003
+ : undefined;
1004
+ if (typeof totalSystems !== "number") {
1005
+ throw new AttestryError(`batch.get: expected response.totalSystems to be a number ` +
1006
+ `(got ${describeType(totalSystems)})`);
1007
+ }
1008
+ const processedSystems = objectHasOwn(obj, "processedSystems")
1009
+ ? obj.processedSystems
1010
+ : undefined;
1011
+ if (typeof processedSystems !== "number") {
1012
+ throw new AttestryError(`batch.get: expected response.processedSystems to be a number ` +
1013
+ `(got ${describeType(processedSystems)})`);
1014
+ }
1015
+ const failedSystems = objectHasOwn(obj, "failedSystems")
1016
+ ? obj.failedSystems
1017
+ : undefined;
1018
+ if (typeof failedSystems !== "number") {
1019
+ throw new AttestryError(`batch.get: expected response.failedSystems to be a number ` +
1020
+ `(got ${describeType(failedSystems)})`);
1021
+ }
1022
+ const results = objectHasOwn(obj, "results") ? obj.results : undefined;
1023
+ if (results !== null && !Array.isArray(results)) {
1024
+ throw new AttestryError(`batch.get: expected response.results to be an array or null ` +
1025
+ `(got ${describeType(results)})`);
1026
+ }
1027
+ const config = objectHasOwn(obj, "config") ? obj.config : undefined;
1028
+ if (config !== null &&
1029
+ (typeof config !== "object" || Array.isArray(config))) {
1030
+ throw new AttestryError(`batch.get: expected response.config to be an object or null ` +
1031
+ `(got ${describeType(config)})`);
1032
+ }
1033
+ const createdAt = objectHasOwn(obj, "createdAt")
1034
+ ? obj.createdAt
1035
+ : undefined;
1036
+ if (typeof createdAt !== "string") {
1037
+ throw new AttestryError(`batch.get: expected response.createdAt to be a string ` +
1038
+ `(got ${describeType(createdAt)})`);
1039
+ }
1040
+ const startedAt = objectHasOwn(obj, "startedAt")
1041
+ ? obj.startedAt
1042
+ : undefined;
1043
+ if (startedAt !== null && typeof startedAt !== "string") {
1044
+ throw new AttestryError(`batch.get: expected response.startedAt to be a string or null ` +
1045
+ `(got ${describeType(startedAt)})`);
1046
+ }
1047
+ const completedAt = objectHasOwn(obj, "completedAt")
1048
+ ? obj.completedAt
1049
+ : undefined;
1050
+ if (completedAt !== null && typeof completedAt !== "string") {
1051
+ throw new AttestryError(`batch.get: expected response.completedAt to be a string or null ` +
1052
+ `(got ${describeType(completedAt)})`);
1053
+ }
1054
+ return result;
1055
+ }
1056
+ /**
1057
+ * Human-readable type description for error messages. Distinguishes
1058
+ * `null` and `array` from generic `object`. Duplicated in
1059
+ * `decisions.ts`, `incidents.ts`, `regulatory-changes.ts`,
1060
+ * `compliance-check.ts`, `check.ts`, `gate.ts` per project pattern
1061
+ * (small helper, leaf-resource modules, no shared module yet).
1062
+ *
1063
+ * Every branch is reachable in this file through the multiple call
1064
+ * sites (top-level shape, each field type guard, systemIds element
1065
+ * non-string, frameworks element non-string, config non-object).
1066
+ */
1067
+ function describeType(value) {
1068
+ if (value === null)
1069
+ return "null";
1070
+ if (Array.isArray(value))
1071
+ return "array";
1072
+ return typeof value;
1073
+ }
1074
+ //# sourceMappingURL=batch.js.map