@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
package/README.md ADDED
@@ -0,0 +1,1269 @@
1
+ # @attestry/sdk
2
+
3
+ Official TypeScript SDK for the [Attestry](https://attestry.ai) compliance kernel. Submit AI incidents, fetch decision records, subscribe to live decision streams, and chat with the Reggie compliance copilot from server-side TypeScript / JavaScript code.
4
+
5
+ > **Status — v0.5.7 (preview).** Eleven resources shipped: `incidents` (create / list / update / search), `decisions` (ingest / bulk / retrieve / list / SSE stream / NDJSON export / chain verify — **decisions surface complete**, 7 methods), `chat` (send + iterator), `auditLog` (export — multi-format SIEM streaming with auto-pagination; verifyChain — org-wide audit-log hash-chain integrity verdict; 2 methods today), `regulatoryChanges` (list — read-only feed of regulatory updates filtered by framework / severity / status / date-range), `complianceCheck` (check — per-system or per-org compliance summary with active-attestations + framework coverage), `check` (run — flat per-system CI/CD compliance check with framework filter, first SDK route to pre-validate every Zod closed-spec rule synchronously), `gate` (evaluate — pass/fail CI/CD deployment gate with structured gap output + score-threshold + missing-assessment policy), `batch` (submit + get — bulk classification/assessment for up to 50 systems with per-row partial-success envelope; **first SDK resource with asymmetric auth between methods**, **first SDK route with a plan-guard 403 surface distinct from the permission-403**), `shipGate` (check — CI/CD ship-gate verdict on whether a build is gated by an in-flight approval-chain execution; 4-shape variadic response with snake_case `approvers_pending` wire field), `abacPolicies` (list / create / retrieve / update / delete — attribute-based access-control (ABAC) policy management; the SDK's FIRST 5-method CRUD cluster, and the FIRST SDK method on the HTTP `DELETE` verb). API is stable inside its surface; new resources are additive. Automatic retry on 429 is on by default.
6
+ >
7
+ > **0.5.7 — `abacPolicies` CRUD cluster completed** (additive, no breaking changes). The `abacPolicies` resource — attribute-based access-control policy management — reaches its full 5-method surface: `list` / `create` (shipped 0.5.6) plus `retrieve` / `update` / `delete` (this release). **The SDK's FIRST 5-method CRUD cluster** — prior multi-method resources either grew an existing class over many sessions (`decisions` reached 7) or shipped a smaller surface. **FIRST SDK method on the HTTP `DELETE` verb** (`abacPolicies.delete`) and **SECOND on the HTTP `PATCH` verb** (`abacPolicies.update`; `incidents.update` is the first). **The three id-path methods** (`retrieve` / `update` / `delete`) pre-validate `id` against a strict RFC 4122 UUID regex and interpolate it into the request path RAW — mirror of `batch.get`, with **NO `encodeURIComponent` and NO `URIError` defense**: a string matching the UUID regex is ASCII hex + hyphens, URL-safe verbatim and incapable of forming a lone surrogate, so a malformed `id` is pre-rejected synchronously (`TypeError`) before any URL is built (asymmetric with `decisions.retrieve`, whose free-form id needs `encodePathSegment`). **`abacPolicies.update` is the richest method of the cluster** — a **6-arm `instanceof` catch block (the LARGEST on the SDK)**, the three-way 422 fan-out inherited from `.create` (`BodyParseError` → `Array<{path, message}>` / a defensive DEAD `ZodError` arm → `ZodIssue[]` / `AbacPolicyValidationError` → `{errors: string[]}`) PLUS HTTP 409 (name conflict) PLUS HTTP 404 (`AbacPolicyNotFoundError`, an id-embedded message). **Empty-patch pre-validation** — `.update` is a partial update with every field optional; the SDK pre-rejects an empty patch (`update(id, {})`, an all-`undefined` patch, or a patch carrying only unknown keys) synchronously with a `TypeError`, mirroring the kernel `updateAbacPolicySchema`'s `.refine()`. `.delete` returns the **deleted row** (the policy as it existed immediately before deletion — NOT `void` / NOT a `{deleted:true}` envelope); `.update` returns the **updated row** (the `after` state). All 5 methods are **dual-auth admin scope** — HTTP 401 for no/invalid/expired key, HTTP 403 for a valid key lacking `ADMIN` (both branches distinct — pin separately). `.create` / `.update` / `.delete` each write one `abac_policy.*` audit-log entry; `.list` / `.retrieve` are quiet reads. Symmetric prototype-pollution defense on input AND response sides (`Object.hasOwn` module-load snapshot).
8
+ >
9
+ > **0.5.6 — `shipGate` resource added** (additive, no breaking changes). Single-method resource wrapping `POST /api/v1/ship-gate/check` — multi-approver workflow gate that asks "is an in-flight approval chain blocking THIS build?". **Distinct from `gate.evaluate`** — that method is a synchronous compliance-score gate (pass/fail on assessment scores); `shipGate.check` has the `gated → released/rejected/timed_out` state machine bound to an approval-chain execution. **Variadic 4-shape response** with `gated: boolean` as ALWAYS-PRESENT anchor and 5 OPTIONAL own-property fields (`reason`, `approvers_pending`, `state`, `executionId`, `chainId`): Shape A `{gated: false}` 1-field (no gate exists — default-permissive opt-in); Shape B 4-field (released); Shape C 6-field (rejected/timed_out with empty `approvers_pending`); Shape D 6-field (gated awaiting approvers, populated `approvers_pending`). Discriminate via `result.gated === true` (closed-enum boolean, pollution-safe anchor), NOT `reason === undefined`. **First SDK wire field with SNAKE_CASE naming** — `approvers_pending` (asymmetric with the rest of the SDK's camelCase response surface; master plan spec contract line 5369). **Fourth SDK route to pre-validate every Zod closed-spec rule synchronously** (after `check.run`, `gate.evaluate`, and `batch.submit`) — UUID format on `systemId` + length 1-256 on `attestationId` (matches kernel `MAX_ATTESTATION_ID_LENGTH = 256`). **Multi-permission UNION auth** with **READ_SYSTEMS FIRST** (asymmetric with `check.run` and `gate.evaluate` which list `READ_ASSESSMENTS` first). **TWO distinct cascade-gap surfaces**: execution-missing → HTTP 404 (named `ShipGateExecutionNotFoundError`); chain-missing → HTTP 500 (plain `Error`, scrubbed by `internalErrorResponse`). `writeAuditLog` side effect — every call writes one `ship_gate.checked` entry. Kernel-side 15s `maxDuration` (same as `gate.evaluate`; tighter than `auditLog.verifyChain`'s 30s). Symmetric prototype-pollution defense on input AND response sides (`Object.hasOwn` snapshot).
10
+ >
11
+ > **0.5.5 — `auditLog.verifyChain` added** (additive, no breaking changes). Sibling method to `auditLog.export` on the same resource class — wraps `GET /api/v1/audit-chain/verify` for org-wide audit-log hash-chain integrity verification. **Distinct from `decisions.verifyChain` (per-system)** — this verifier operates on the entire org's audit log; different responsibility, different kernel route, different consumer audience (compliance auditors). **CRITICAL contract** (carry-forward invariant #12): does NOT throw on `valid: false` — the kernel returns 200 with `valid: false` on tampered chains; the SDK resolves the Promise with the verdict body. **First SDK route using `requireApiKey` DIRECT (no permission scoping)** — any valid api-key in the org succeeds; no 403 path. **Asymmetric with `auditLog.export`** (which gates on ADMIN role). **`brokenAt` is OPTIONAL** — the kernel uses a conditional spread, so the field is an OWN-PROPERTY only on broken chains; consumers detect broken-chain via `result.valid === false` (closed-enum boolean discriminator), NOT `result.brokenAt === undefined` (prototype-pollution-unsafe). **Silent kernel-side truncation at 5000 entries** — orgs with >5000 audit log entries see only the OLDEST 5000 verified per call (documented kernel surface gap). NO `writeAuditLog` side effect (the verifier is quiet — writing while verifying would be ironic). NO input → no `TypeError` from SDK boundary; the method takes only `options?: RequestOptions`. Symmetric prototype-pollution defense on the response side (input boundary is empty).
12
+ >
13
+ > **0.5.4 — `batch` resource added** (additive, no breaking changes). Multi-method resource wrapping `POST /api/v1/batch` (submit) and `GET /api/v1/batch/<UUID>` (get). **First SDK resource with asymmetric auth between methods on the same resource** — `submit()` requires `CLASSIFY` OR `WRITE_ASSESSMENTS` (UNION, the FIRST WRITE-side union pair on the SDK); `get()` requires only `READ_ASSESSMENTS` (single permission). **First SDK route exposing a plan-guard 403 surface** distinct from the permission-403 (`requirePlan(org, "hasBatchProcessing")` fires BEFORE Zod body parsing on `submit()`); the kernel's `PlanLimitError` wording is `'The "hasBatchProcessing" feature is not available on your current plan (<plan>). Please upgrade to access this feature.'` — distinct from the permission-403's `'API key lacks required permission. Required: classify or write:assessments. Key has: ...'`. SDK surfaces both uniformly as `AttestryAPIError(403)`; consumers regex-match `apiErr.message` to distinguish "upgrade your plan" from "grant more permissions to your key" (no SDK-side discriminator helper today). Pre-validates every Zod closed-spec rule synchronously across THREE fields (`jobType` closed-enum 3-string membership, `systemIds` array length [1, 50] + per-element UUID format, `config.frameworks` array length ≤20 + per-element string length [1, 100]) — third SDK route to pre-validate after `check.run` and `gate.evaluate`. Partial-success contract: `submit()` resolves successfully (no throw) even when every row failed; consumers branch on per-row `status === "success"` (closed-enum string match — NOT `errorMessage === undefined` which is pollution-unsafe). **TWO distinct status enums on response wire-shape family**: top-level batch-job `status` (`"completed" | "failed"` on POST, wider 4-enum `"pending" | "processing" | "completed" | "failed"` on GET) vs per-row `results[i].status` (`"success" | "error"` on both). Asymmetric 404 shapes: POST embeds invalid UUIDs in the message (`Systems not found or not in your organization: <id>, <id>...`); GET is a literal string (`Batch job not found`). 400 surface on GET for malformed UUID path param (SDK pre-validates synchronously — kernel 400 reachable only via `as any` casts). `writeAuditLog` side effect on `submit()` writes one `batch.submitted` entry per call (time-blocking but error-tolerant — kernel awaits two DB ops inside writeAuditLog; response latency INCLUDES the write time; error semantics ARE non-blocking — write failure does NOT fail the request). Symmetric prototype-pollution defense on input AND response sides (`Object.hasOwn` snapshot, mirrors session-16 second-hostile-review MEDIUM #3).
14
+ >
15
+ > **0.5.3 — `gate` resource added** (additive, no breaking changes). Wraps `POST /api/v1/gate` with a Zod-validated body (`systemId` UUID + optional `minScore` int 0-100 default 70 + optional `frameworks` filter + optional `failOnMissingAssessment` boolean default true), multi-permission union auth (READ_ASSESSMENTS or READ_SYSTEMS — same as `check.run`), cross-org systemId collapsed to 404 (LONGER kernel string than `check.run`: `"System not found or access denied"`), and TWO silent kernel-side truncations (assessments at 10 — TIGHTER than `check.run`'s 100 — and remediationTasks at 100). SDK pre-validates every Zod closed-spec rule synchronously across FOUR fields (UUID format, minScore int + range, failOnMissingAssessment boolean, frameworks string + array bounds) — most extensive pre-validation surface to date; 422 only reaches consumers via kernel-side rule changes the SDK hasn't synced to. Response is a STRING-ENUM `gate: "pass" | "fail"` (NOT a boolean) over THREE emit paths (normal pass/fail + fail-on-missing + pass-on-missing); `score` is `number | null` (NOT defaulted to 0 — **asymmetric with `check.run`** where score=0 was the no-assessment default; gate preserves the null distinction at the type level). Every call writes one `gate.checked` audit log entry (new invariant candidate #53 — SDK documents the side effect). Symmetric prototype-pollution defense on input AND response sides (`Object.hasOwn` snapshot, mirrors `check.run`'s session-16 second-hostile-review MEDIUM #3 defense).
16
+ >
17
+ > **0.5.2 — `check` resource added** (additive, no breaking changes). Wraps `POST /api/v1/check` with a Zod-validated body (`systemId` UUID + optional `frameworks` filter), multi-permission union auth (READ_ASSESSMENTS or READ_SYSTEMS), cross-org systemId collapsed to 404 ("System not found", mirror of `decisions.retrieve`), and THREE silent kernel-side truncations (issues at 20, assessments at 100, attestations at 50) — each documented in JSDoc + the resource section below as separate kernel surface gaps. SDK pre-validates every Zod closed-spec rule synchronously (UUID format, framework string length 1-100, array length cap 20) so 422 only reaches consumers via kernel-side rule changes the SDK hasn't synced to — the runtime checks always run regardless of TypeScript types (`as any` casts do NOT bypass them). `score` defaults to **0 (not null)** when no completed assessment exists — consumers MUST check `lastAssessedAt === null` to distinguish "scored zero" from "no completed assessment yet". Includes prototype-pollution defense on BOTH input field presence AND response field reads (`Object.hasOwn` snapshot — generalization of the XOR-only input-side defense added in 0.5.1, now also applied symmetrically to the P2 response validators per session-16 second-hostile-review MEDIUM #3).
18
+ >
19
+ > **0.5.1 — `complianceCheck` resource added** (additive, no breaking changes). Wraps `GET /api/v1/compliance-check` with XOR systemId-or-orgName input mode, multi-permission union auth (READ_SYSTEMS or READ_ASSESSMENTS), asymmetric cross-org error codes (404 systemId / 403 orgName), and silent kernel-side `.limit(100)` truncation on the orgName branch (documented in JSDoc + the resource section below — faithful courier, not auto-paginated). Includes a defense-in-depth fix against prototype pollution on the XOR check (`Object.hasOwn` instead of `in`).
20
+ >
21
+ > **0.5.0 hardening release** (P1+P2+P3): closed-enum exports are now `Object.freeze`-immutable (prevents hostile/buggy npm dependencies from mutating SDK validation arrays); list-shaped sync responses validate `Array.isArray` + `nextCursor` shape at the SDK boundary (kernel regressions to scalar/null surface as `AttestryError` instead of cryptic consumer crashes); sync `request<T>` enforces `Content-Type: application/json` on 2xx responses (proxy/LB-injected HTML error pages now throw `AttestryAPIError` instead of soft-failing). The content-type guard is the only consumer-visible behavior change — wrong-content-type responses that previously soft-failed now throw.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install @attestry/sdk
27
+ ```
28
+
29
+ Requires Node 18+ (uses the global `fetch`). Browser support is intentionally NOT in v0 — server-side use only.
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { AttestryClient } from "@attestry/sdk";
35
+
36
+ const client = new AttestryClient({
37
+ apiKey: process.env.ATTESTRY_API_KEY!,
38
+ });
39
+
40
+ // Submit an AI incident.
41
+ const incident = await client.incidents.create({
42
+ incidentType: "prompt_injection",
43
+ severity: "high",
44
+ description: "Customer-facing chatbot leaked an internal system prompt.",
45
+ frameworksAffected: ["eu_ai_act", "nist_ai_rmf"],
46
+ optInShare: true,
47
+ });
48
+
49
+ // Append a decision record to the system's hash chain.
50
+ const record = await client.decisions.ingest({
51
+ systemId,
52
+ inputDigest: "sha256:abc...",
53
+ frameworkClaims: [
54
+ { framework: "eu_ai_act", article: "Art.13", claim: "human oversight provided" },
55
+ ],
56
+ humanOversightState: "approved",
57
+ policyOutcome: "permitted",
58
+ // Pass an idempotencyKey to make 429-retries safe under network failure.
59
+ idempotencyKey: "decision-2026-05-06-trace-789",
60
+ });
61
+
62
+ // Append a batch of records (1-500). Partial-success envelope: the
63
+ // call resolves even when some records fail. Inspect `result.failed[]`
64
+ // for per-record errors; `code` distinguishes recovery paths
65
+ // (e.g., retry idempotency_unique_violation via decisions.ingest).
66
+ const result = await client.decisions.bulk({
67
+ items: [
68
+ { systemId, inputDigest: "sha256:abc...", idempotencyKey: "trace-001" },
69
+ { systemId, inputDigest: "sha256:def...", idempotencyKey: "trace-002" },
70
+ ],
71
+ });
72
+ console.log(`${result.totalInserted}/${result.totalSubmitted} succeeded`);
73
+
74
+ // Search the cross-tenant corpus for similar patterns.
75
+ const { clusters } = await client.incidents.search({
76
+ query: "system prompt leak",
77
+ limit: 10,
78
+ });
79
+
80
+ // Subscribe to live decision events as they're appended.
81
+ for await (const event of client.decisions.stream({ systemId })) {
82
+ console.log(event.id, event.sequenceNumber, event.recordHash);
83
+ }
84
+
85
+ // Export the entire chain as NDJSON (records + a Merkle-root trailer).
86
+ // The trailer is the LAST frame and commits the export to a single
87
+ // hash over per-record `recordHash` leaves.
88
+ for await (const frame of client.decisions.export({ systemId })) {
89
+ if ("type" in frame && frame.type === "ExportTrailer") {
90
+ console.log(`exported ${frame.recordCount} records, root=${frame.merkleRoot}`);
91
+ } else {
92
+ process(frame); // DecisionListItem shape
93
+ }
94
+ }
95
+
96
+ // Verify a system's hash chain integrity. Resolves with the verdict
97
+ // body even when chainValid:false — the kernel's answer to "is the
98
+ // chain tampered?" is itself a successful response. Branch on
99
+ // verdict.chainValid.
100
+ const verdict = await client.decisions.verifyChain(systemId);
101
+ if (!verdict.chainValid) {
102
+ // Two arrays distinguish the failure mode:
103
+ // tamperedRecordIds = direct content tampering (security signal)
104
+ // brokenRecordIds = sequence gap (ops signal — record missing)
105
+ console.error("chain integrity failure", {
106
+ tampered: verdict.tamperedRecordIds,
107
+ broken: verdict.brokenRecordIds,
108
+ verifiedUpTo: verdict.lastVerifiedSequence,
109
+ });
110
+ }
111
+
112
+ // Stream the org's audit-log to your SIEM. Default jsonl format yields
113
+ // AuditLogRecord rows; `format: "ecs"` yields Elastic Common Schema
114
+ // events; `format: "cef"` yields ArcSight CEF v0 lines as raw strings.
115
+ // Auto-paginates by default (walks all history newest-first).
116
+ //
117
+ // Dual-auth admin — the api-key must carry the ADMIN permission;
118
+ // 401 for no/invalid/expired key, 403 for a valid key lacking ADMIN.
119
+ for await (const row of client.auditLog.export()) {
120
+ if (row.action === "api_key_revoked") notifySecurity(row);
121
+ }
122
+
123
+ // List recent regulatory updates filtered by framework / severity.
124
+ // Returns a Promise<RegulatoryChange[]> sorted DESC by publishedAt.
125
+ // IMPORTANT: when `status` is omitted, the kernel filters dismissed
126
+ // rows OUT (default-excludes-dismissed). Pass status: "dismissed" to
127
+ // retrieve only dismissed rows.
128
+ const recentCritical = await client.regulatoryChanges.list({
129
+ framework: "EU_AI_ACT",
130
+ severity: "critical",
131
+ from: "2026-04-01T00:00:00Z",
132
+ limit: 50,
133
+ });
134
+ for (const change of recentCritical) {
135
+ console.log(change.framework, change.severity, change.title);
136
+ }
137
+ ```
138
+
139
+ ## Configuration
140
+
141
+ ```ts
142
+ new AttestryClient({
143
+ apiKey: "sk_live_…", // required
144
+ baseUrl: "https://app.attestry.ai", // optional — defaults to prod
145
+ timeoutMs: 30_000, // optional — defaults to 30s (NOT applied to streams)
146
+ fetch: customFetch, // optional — defaults to globalThis.fetch
147
+ retry: { maxRetries: 3 }, // optional — see "Automatic retry" below
148
+ });
149
+ ```
150
+
151
+ | Option | Default | Notes |
152
+ |---|---|---|
153
+ | `apiKey` | required | API key from the Attestry org settings page. Sent as `x-api-key`. |
154
+ | `baseUrl` | `https://app.attestry.ai` | Override for self-hosted, EU residency, or local dev. Trailing slashes are stripped. |
155
+ | `timeoutMs` | `30_000` | Per-request timeout for JSON requests (not streams). Set `0` to disable. |
156
+ | `fetch` | `globalThis.fetch` | Inject a custom fetch (testing, retries, observability). Must match the standard `fetch` signature. |
157
+ | `retry` | `{maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 30_000, honorRetryAfter: true}` | Automatic retry on 429. See "Automatic retry" section below. Set `{maxRetries: 0}` to disable. |
158
+
159
+ Construction is fail-fast: a missing API key, missing fetch, or an invalid `timeoutMs` / `retry` config throws `AttestryError` synchronously.
160
+
161
+ ## Errors
162
+
163
+ Two error classes:
164
+
165
+ ```ts
166
+ import { AttestryClient, AttestryError, AttestryAPIError } from "@attestry/sdk";
167
+
168
+ try {
169
+ await client.incidents.create({ /* … */ });
170
+ } catch (err) {
171
+ if (err instanceof AttestryAPIError) {
172
+ // The API returned a non-2xx response.
173
+ console.error(`API ${err.status}: ${err.message}`, err.details);
174
+ } else if (err instanceof AttestryError) {
175
+ // Network failure, timeout, or aborted request — the call did NOT
176
+ // reach the API.
177
+ console.error("transport error:", err.message);
178
+ } else {
179
+ throw err;
180
+ }
181
+ }
182
+ ```
183
+
184
+ `AttestryAPIError extends AttestryError extends Error`, so a single `instanceof AttestryError` catches both layers.
185
+
186
+ ## Cancellation
187
+
188
+ ```ts
189
+ const ac = new AbortController();
190
+ setTimeout(() => ac.abort(), 5_000);
191
+
192
+ try {
193
+ await client.incidents.search({ query: "long-running" }, { signal: ac.signal });
194
+ } catch (err) {
195
+ if (err instanceof AttestryError && err.message === "request aborted by caller") {
196
+ // user cancelled
197
+ }
198
+ }
199
+ ```
200
+
201
+ The caller's `AbortSignal` is composed with the SDK's internal timeout signal (for JSON requests); aborting either one cancels the request. For streams, `signal` is the only cancellation hook — there's no internal timeout.
202
+
203
+ `signal.abort()` mid-retry-backoff also interrupts the wait immediately — the SDK rejects with `AttestryError("request aborted by caller")` rather than completing the backoff and retrying.
204
+
205
+ ## Automatic retry
206
+
207
+ The SDK automatically retries HTTP 429 (`Too Many Requests`) responses with exponential backoff and full jitter.
208
+
209
+ **Default config:** `{maxRetries: 3, initialDelayMs: 1_000, maxDelayMs: 30_000, honorRetryAfter: true}`.
210
+ That's up to 4 total attempts (1 initial + 3 retries), starting at ~1s and doubling each time, capped at 30s. The server-supplied `Retry-After` header (RFC 7231 — both delta-seconds and HTTP-date forms) takes precedence when present, also capped at `maxDelayMs`.
211
+
212
+ ```ts
213
+ // Disable client-wide:
214
+ const client = new AttestryClient({
215
+ apiKey,
216
+ retry: { maxRetries: 0 },
217
+ });
218
+
219
+ // Tighten for a latency-sensitive call:
220
+ await client.incidents.search(query, {
221
+ retry: { maxRetries: 1, initialDelayMs: 200, maxDelayMs: 1_000 },
222
+ });
223
+
224
+ // One-off "do not retry":
225
+ await client.incidents.create(input, { retry: { maxRetries: 0 } });
226
+ ```
227
+
228
+ | Field | Default | Notes |
229
+ |---|---|---|
230
+ | `maxRetries` | `3` | 0 disables. Capped at 100 (config DoS guard). |
231
+ | `initialDelayMs` | `1_000` | Base for exponential schedule. |
232
+ | `maxDelayMs` | `30_000` | Cap on both exponential and `Retry-After`. |
233
+ | `honorRetryAfter` | `true` | When false, the SDK ignores the server hint and uses pure exponential. |
234
+
235
+ **Why only 429?** 429 means the server rejected the request before processing — by definition safe to retry. Other transient statuses (502/503/504) MAY be safe but require HTTP-level idempotency-key support (planned). The SDK does not retry network errors either — fetch failures (DNS, ECONNREFUSED) bubble as `AttestryError` for the caller to handle.
236
+
237
+ **Streams:** the initial fetch retries on 429. Once events have been delivered, mid-stream errors throw to the caller — auto-retrying mid-stream would risk lost or duplicated events. Caller resumes by passing the last seen `event.eventId` back as `lastEventId`.
238
+
239
+ ## Resources
240
+
241
+ ### `client.incidents`
242
+
243
+ | Method | Wraps | Returns |
244
+ |---|---|---|
245
+ | `create(input, options?)` | `POST /api/v1/incidents` | `Incident` |
246
+ | `list(input?, options?)` | `GET /api/v1/incidents` | `{ items, nextCursor? }` |
247
+ | `update(id, input, options?)` | `PATCH /api/v1/incidents/:id` | `Incident` |
248
+ | `search(input, options?)` | `POST /api/ai/incidents/search` | `{ clusters, count, truncated }` |
249
+
250
+ See `src/resources/incidents.ts` for the full input/output type shapes.
251
+
252
+ ### `client.decisions`
253
+
254
+ | Method | Wraps | Returns |
255
+ |---|---|---|
256
+ | `ingest(input, options?)` | `POST /api/v1/decisions` | `DecisionRecord` |
257
+ | `bulk(input, options?)` | `POST /api/v1/decisions/bulk` | `BulkIngestResult` (partial-success envelope) |
258
+ | `retrieve(id, options?)` | `GET /api/v1/decisions/:id` | `DecisionRecord` |
259
+ | `list(input?, options?)` | `GET /api/v1/decisions` | `{ items: DecisionListItem[], nextCursor: string \| null }` |
260
+ | `stream(input?, options?)` | `GET /api/v1/decisions/stream` (SSE) | `AsyncIterable<DecisionStreamEvent>` |
261
+ | `export(input, options?)` | `GET /api/v1/decisions/export` (NDJSON) | `AsyncIterable<DecisionExportFrame>` |
262
+ | `verifyChain(systemId, options?)` | `GET /api/v1/decisions/verify-chain/:systemId` | `ChainVerificationResult` (200 with chainValid:true OR chainValid:false) |
263
+
264
+ `ingest()` appends a record to the org's append-only hash chain. Pass an `idempotencyKey` for at-least-once delivery semantics — server dedupes on `(orgId, idempotencyKey)`. Different payload with the same key surfaces as `AttestryAPIError` with `status === 409`. When the org exhausts its `decisionsPerMonth` plan quota, the SDK throws `AttestryAPIError(402)` with structured `details: {feature, currentPlan, upgradeRequired}` so dashboards can route to the upgrade flow. Sub-shapes (`FrameworkClaim`, `ToolInvocation`, `DelegationEntry`, `ZkProof`) are exported for typed input building.
265
+
266
+ `bulk()` appends 1-500 records in a single request. **Critical contract:** the call resolves successfully even when every record failed — partial success is the entire point of the endpoint. Inspect `result.totalFailed` and `result.failed[]` for per-record errors; the `code` field distinguishes recovery paths (`idempotency_conflict`, `payload_too_large`, `chain_head_missing`, `system_not_found`, `ijson_validation_failed`, `idempotency_unique_violation`, `chunk_failed`). Top-level failures (auth, rate limit, plan limit, oversize batch) DO throw `AttestryAPIError`. The plan-limit (402) check counts the FULL batch wholesale against the `decisionsPerMonth` quota — partial quota fills are not allowed. For at-least-once retry semantics, give every item its own `idempotencyKey`; failed items with `code === "idempotency_unique_violation"` should be retried individually via `decisions.ingest` to invoke per-record race recovery.
267
+
268
+ `list()` is keyset-paginated. Pass `response.nextCursor` back as `input.cursor` to fetch the next page; iterate until `nextCursor === null`. The slim `DecisionListItem` type omits heavy fields (`canonicalPayload`, `clientSignature`, etc.) — call `decisions.retrieve(id)` for the full record. Filters: `systemId`, `from` / `to` (ISO datetimes), `framework` / `article` (jsonb-contains), `tool`, `includeTombstoned`, `limit` (1-200, default 50).
269
+
270
+
271
+ `stream()` is an async-iterator over Server-Sent Events. Errors **throw** from the iterator (long-lived subscription semantics); use `try / catch` around the for-await loop:
272
+
273
+ ```ts
274
+ let lastEventId: string | undefined;
275
+ try {
276
+ for await (const event of client.decisions.stream({ systemId, lastEventId })) {
277
+ process(event);
278
+ lastEventId = event.eventId; // keep for reconnection
279
+ }
280
+ } catch (err) {
281
+ if (err instanceof AttestryAPIError && err.status === 401) {
282
+ // re-auth
283
+ } else if (err instanceof AttestryError) {
284
+ // network / abort / parser error — wait + reconnect with lastEventId
285
+ }
286
+ }
287
+ ```
288
+
289
+ The SDK does **not** auto-reconnect — caller controls reconnect timing using `lastEventId`. Heartbeat frames are silently consumed; consumers see only real events.
290
+
291
+ `export()` streams a system's entire decision chain as NDJSON (`application/x-ndjson` — one JSON line per record), then a final trailer frame committing the batch to a single Merkle root over the per-record `recordHash` leaves. Records first (in `sequenceNumber` ascending order), then exactly one trailer:
292
+
293
+ ```ts
294
+ for await (const frame of client.decisions.export({ systemId })) {
295
+ if ("type" in frame && frame.type === "ExportTrailer") {
296
+ // Final commit — verify Merkle root client-side post-Prompt-1.
297
+ console.log(frame.recordCount, frame.merkleRoot, frame.signing);
298
+ } else {
299
+ // Per-record line — DecisionListItem shape (interchangeable with `list()` rows).
300
+ process(frame);
301
+ }
302
+ }
303
+ ```
304
+
305
+ The trailer's **`signing` field is today the literal string `"unsigned-prompt-1-blocked"`** — Prompt 1's Ed25519 signing isn't shipped yet, so the trailer is unsigned and the field carries that fact explicitly. Once Prompt 1 lands, the field will be replaced by a structured `eddsa-jcs-2022` proof. The SDK types the field as `string` (not a literal-union) for forward-compat with that transition; the runtime literal value is drift-pinned kernel-side. The SDK does **not** verify the Merkle root or signature — caller is responsible (off-the-shelf libraries: `ed25519-verify`, `merkle-tree`).
306
+
307
+ **Empty exports** still emit a trailer — when the systemId has zero records (or doesn't exist / belongs to another org), the iterator yields a single frame with `recordCount: 0`, `sequenceFrom: null`, `sequenceTo: null`, and the deterministic empty-export merkleRoot (`sha256:` + hex of `sha256("ATTESTRY-EMPTY-EXPORT")`). Consumers detect "no data" via the trailer rather than a zero-frame iterator.
308
+
309
+ **Missing trailer** is treated as a mid-stream failure. If the iterator exhausts without seeing a trailer (kernel committed to 200 then hit a DB error during pagination — can't return as 4xx), the SDK throws `AttestryError("decisions.export: stream ended without trailer — connection dropped or server failed mid-stream")`. Caller can branch on this to distinguish "kernel-completed export" from "kernel-aborted export".
310
+
311
+ The export endpoint runs up to 5 minutes server-side. The SDK does not arm an internal timeout for streams; cancel via `options.signal` if needed. `includeTombstoned: false` is forwarded literally — no kernel `z.coerce.boolean()` workaround required (the kernel session-6 `stringBoolean` fix accepts `"false"` correctly; this asymmetry from `decisions.list` — which still omits `false` as defense-in-depth — is deliberate).
312
+
313
+ `verifyChain()` replays a system's hash chain server-side and reports an integrity verdict. **Critical contract:** the kernel returns HTTP 200 with `chainValid: false` when tampering is detected — the SDK resolves the Promise with the verdict body, it does **NOT** throw. Mirror of `decisions.bulk`'s partial-success contract: the customer asked the chain-integrity question, the kernel answered, and the SDK is a faithful courier. Top-level structural failures (auth, rate limit, system-not-found, ChainTooLong) DO throw `AttestryAPIError`. The result distinguishes failure modes via two arrays — `tamperedRecordIds` (direct content tampering, security signal) and `brokenRecordIds` (sequence gap, ops signal); both can be non-empty simultaneously and the kernel fires `chain.tampered` / `chain.broken` / `chain.verified` webhooks fire-and-forget out-of-band (the SDK does NOT see them; subscribe via the `webhooks` resource for delivery). Chains over 50K records 413 with `err.details?.details?.hint` referencing `decisions.export` for offline verification — fall back to `decisions.export()` on that signal. (The double-`details` reflects the transport's error-body wrap: it stores the full parsed body under `AttestryAPIError.details`, and the kernel's own structured `details` payload nests inside.) `lastVerifiedAt` is a wire ISO-string (NOT a Date instance); parse via `new Date(value)` if needed.
314
+
315
+ ### `client.chat`
316
+
317
+ | Method | Wraps | Returns |
318
+ |---|---|---|
319
+ | `send(input, options?)` | `POST /api/ai/chat` | `{ message, agent }` |
320
+ | `stream(input, options?)` | `POST /api/ai/chat` (sync, iterator-shaped) | `AsyncIterable<ChatStreamChunk>` |
321
+
322
+ `chat.stream()` yields zero-or-more `{type: 'text', delta}` chunks then exactly one terminator (`{type: 'done'}` on success or `{type: 'error', message}` on failure). Errors do NOT throw — request/response semantics. Forward-compat for true SSE if `/api/ai/chat` migrates.
323
+
324
+ ### `client.auditLog`
325
+
326
+ | Method | Wraps | Returns |
327
+ |---|---|---|
328
+ | `export(input?, options?)` | `GET /api/v1/audit-log/export` (NDJSON or text/plain) | `AsyncIterable<AuditLogRecord \| unknown \| string>` (format-discriminated) |
329
+ | `verifyChain(options?)` | `GET /api/v1/audit-chain/verify` | `Promise<AuditChainVerificationResult>` |
330
+
331
+ `auditLog.export()` streams the org's audit-log rows as line-oriented frames in one of three wire formats — `jsonl` (default; structured `AuditLogRecord` shape), `ecs` (Elastic Common Schema 8.x events), or `cef` (ArcSight CEF v0 lines). The iterator's yield type is format-discriminated via overload signatures: `format: "jsonl"` → `AuditLogRecord`; `format: "ecs"` → `unknown` (consumers parse their own ECS schema); `format: "cef"` → `string` (raw CEF line, no JSON.parse).
332
+
333
+ **Dual-auth admin scope.** The kernel route gates on `requireSessionOrApiKey(request, { sessionRoles: ["admin"], apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })` — the identical dual-auth pattern the `abacPolicies` cluster uses. The SDK's transport always sends `x-api-key`, so the api-key path is the only one reachable from SDK consumers: **HTTP 401** for no / invalid / expired api-key, **HTTP 403** for a valid api-key whose permissions do NOT include `ADMIN`. Pin BOTH branches separately. (Corrected — session-22 hostile review #2: the prior "HTTP 401 for both" claim mis-read the kernel test, which MOCKS `AuthError(401)` and never exercises the real `requireSessionOrApiKey` middleware; the middleware returns 403 for the insufficient-permission case.)
334
+
335
+ **Auto-paginates by default.** The kernel emits `x-attestry-next-cursor` in the response headers when more pages exist; the iterator transparently fetches the next page. Pass `autoPaginate: false` to yield only the first page (rare — most consumers want the full history walked transparently). The next-cursor is NOT exposed through the iterator protocol; consumers needing manual cursor control track the last `(timestamp, id)` themselves and pass it as `cursor` on the next call.
336
+
337
+ Rows arrive DESC by `(timestamp, id)` — newest first. Order is preserved across page boundaries.
338
+
339
+ **Cursor format.** Compound `<ISO-8601-UTC>:<UUID>` (preferred — strict tuple ordering across same-timestamp rows) OR bare ISO-8601 UTC (legacy fallback — may skip same-microsecond rows). The SDK forwards `cursor` verbatim; the kernel's regex is the format authority.
340
+
341
+ **`limit` semantics.** Defaults to 1000 server-side. Max 5000; the kernel silently clamps. The SDK rejects `NaN` / `Infinity` / `<= 0` / non-integer as `TypeError` synchronously (more strict than the kernel's silent coerce-to-1000 — fail-loud-and-synchronous; build-round D4). Limits over 5000 are forwarded verbatim — the kernel's `MAX_LIMIT` is the authority, leaving room for future raises without an SDK bump.
342
+
343
+ **No body trailer.** Different from `decisions.export`: audit-log/export does NOT emit a Merkle-root trailer; the cursor lives in headers, the empty page is a valid stop signal. The SDK does NOT throw "stream ended without trailer" — that check is intentionally absent (asymmetric with `decisions.export` per build-round D8).
344
+
345
+ ```ts
346
+ // Walk all admin events (auto-paginate)
347
+ for await (const row of client.auditLog.export()) {
348
+ if (row.action === "api_key_revoked") audit(row);
349
+ }
350
+
351
+ // ECS for SIEM ingest (Elastic / Datadog / Logstash):
352
+ for await (const event of client.auditLog.export({ format: "ecs" })) {
353
+ await elasticIngest(event); // event: unknown — parse via your own ECS schema
354
+ }
355
+
356
+ // CEF for ArcSight / QRadar:
357
+ for await (const line of client.auditLog.export({ format: "cef" })) {
358
+ await arcsightForward(line); // line: string starting with "CEF:0|Attestry|..."
359
+ }
360
+ ```
361
+
362
+ #### `auditLog.verifyChain(options?)` — org-wide audit-log hash-chain integrity
363
+
364
+ `auditLog.verifyChain()` verifies the integrity of the org's audit-log hash chain. Returns an `AuditChainVerificationResult` describing whether the chain is intact, and (when broken) the UUID of the entry where verification failed. Takes NO input — auth-derived org binding is the only scope.
365
+
366
+ **Distinct from `decisions.verifyChain` (per-system).** That method verifies a single system's decision chain; `auditLog.verifyChain` verifies the entire ORG's audit log. Different responsibility, different kernel route, different consumer audience (compliance auditors). The two complement each other.
367
+
368
+ **CRITICAL contract — does NOT throw on `valid: false`.** The kernel returns HTTP 200 with `valid: false` on a tampered chain; the SDK resolves the Promise with the verdict body. Top-level structural failures (auth, rate limit, internal) throw `AttestryAPIError`. Mirror of `decisions.verifyChain`'s same contract (carry-forward invariant #12 — the verdict is the answer, not an error).
369
+
370
+ **API-key auth scope — no permission filter.** The kernel route uses `requireApiKey(request)` directly — NO permission scoping. **Any valid api-key for the org succeeds; the 403 path is unreachable.** Asymmetric with `auditLog.export` (which gates on ADMIN role) and with `decisions.verifyChain` (which uses `requireSessionOrApiKey`). The route is open to ALL keys in the org.
371
+
372
+ **`brokenAt` is OPTIONAL.** The kernel uses a conditional spread `...(result.brokenAtId ? { brokenAt: result.brokenAtId } : {})`, so the field is an OWN-PROPERTY of the response ONLY on broken chains. On a valid chain it's omitted entirely. **Consumers MUST detect broken-chain via `result.valid === false`** (closed-enum boolean discriminator), NOT `result.brokenAt === undefined` (prototype-pollution-unsafe — under `Object.prototype.brokenAt = "fake-uuid"` pollution, the equality check walks the prototype and reads the polluted value).
373
+
374
+ **Silent kernel-side truncation at 5000 entries.** The kernel's audit-log fetch is capped at 5000 entries (`route.ts:51`: `.limit(5000)`). For orgs with more than 5000 audit-log entries, only the OLDEST 5000 are verified per call. The kernel does NOT emit a "truncated" flag — `totalEntries` equals the number of rows fetched, NOT the org's full audit-log row count. Documented kernel surface gap; the SDK does NOT mask. Consumers with high-volume audit logs should be aware that the verifier sees a stale window.
375
+
376
+ **NO `writeAuditLog` side effect.** The verifier is quiet — writing to the audit log while verifying it would be ironic; the kernel team avoided this. Asymmetric with `gate.evaluate` / `batch.submit` (both write audit entries).
377
+
378
+ **Response shape** (`AuditChainVerificationResult`): 5 always-present fields plus 1 optional own-property:
379
+
380
+ | Field | Type | Notes |
381
+ |---|---|---|
382
+ | `valid` | `boolean` | `true` iff chain intact. Empty logs verify as `true` (vacuous). |
383
+ | `entriesVerified` | `number` | Count verified before first broken link; equals `totalEntries` on valid chain. |
384
+ | `totalEntries` | `number` | Total entries fetched. Capped at 5000 by silent kernel truncation. |
385
+ | `firstEntry` | `string \| null` | ISO-8601 UTC of oldest entry. `null` on empty log. ALWAYS present on the wire. |
386
+ | `lastEntry` | `string \| null` | ISO-8601 UTC of newest entry. `null` on empty log. ALWAYS present on the wire. |
387
+ | `brokenAt` | `string` (optional own-property) | UUID of the broken entry. **Omitted from the wire on valid chains** — own-property ONLY on broken chains. TypeScript reads as `string \| undefined` due to the optional marker; the wire shape is absent-or-string (JSON has no `undefined`). |
388
+
389
+ **No 400 / 402 / 403 / 404 / 413 / 422 / `TypeError` surfaces.** This method has no input (no `TypeError`), no body (no 422), no permission filter (no 403), implicit org from auth (no 404), no quota (no 402), and silent truncation instead of 413. Only 401 (auth), 429 (rate limit), 500 (internal), `AttestryError` (abort / P2 response shape), and `AttestryAPIError` (P3 content-type) surface.
390
+
391
+ ```ts
392
+ // Detect a tampered audit log
393
+ const verdict = await client.auditLog.verifyChain();
394
+ if (!verdict.valid) {
395
+ // brokenAt is an OWN-PROPERTY only on broken chains. TypeScript
396
+ // narrows it to `string | undefined`; check before forwarding.
397
+ if (verdict.brokenAt) {
398
+ await notifySecurity({
399
+ entryId: verdict.brokenAt,
400
+ verifiedUpTo: verdict.entriesVerified,
401
+ totalEntries: verdict.totalEntries,
402
+ });
403
+ }
404
+ }
405
+ console.log(`Verified ${verdict.entriesVerified}/${verdict.totalEntries} entries`);
406
+
407
+ // Schedule periodic verification (cron job)
408
+ try {
409
+ const verdict = await client.auditLog.verifyChain();
410
+ if (!verdict.valid && verdict.brokenAt) {
411
+ await pageOncall({ brokenAt: verdict.brokenAt });
412
+ }
413
+ } catch (err) {
414
+ if (err instanceof AttestryAPIError && err.status === 429) {
415
+ // Back off — verifier is rate-limited per IP via `audit-chain-verify:${ip}`.
416
+ return;
417
+ }
418
+ throw err;
419
+ }
420
+ ```
421
+
422
+ ### `client.regulatoryChanges`
423
+
424
+ | Method | Wraps | Returns |
425
+ |---|---|---|
426
+ | `list(input?, options?)` | `GET /api/v1/regulatory-changes` | `Promise<RegulatoryChange[]>` |
427
+
428
+ `regulatoryChanges.list()` returns the org's regulatory-change feed — a read-only list of regulatory updates ingested by the kernel (EU AI Act amendments, US federal-register notices, state legislative updates, etc.) filtered by `framework` / `severity` / `status` / `from` / `to` / `limit`. Rows arrive DESC by `publishedAt`. Sync JSON list response — no pagination cursor. Returns `Promise<RegulatoryChange[]>`.
429
+
430
+ **Default-excludes-dismissed (the non-obvious gotcha).** When `status` is **omitted** from the input, the kernel filters dismissed rows OUT (`WHERE status != 'dismissed'`). To retrieve dismissed rows, pass `status: "dismissed"` (returns ONLY dismissed rows). To retrieve `"new"` / `"reviewed"` / `"actioned"` rows, pass that exact status. There is currently NO way to retrieve "everything including dismissed" via this endpoint — the kernel route hardcodes the exclusion at the default branch.
431
+
432
+ **READ_SYSTEMS auth scope.** Returns HTTP **401** for no/invalid API key (the `requireApiKey` branch) and HTTP **403** for an authenticated key that lacks the `READ_SYSTEMS` permission (the `requireApiKeyWithPermission` branch). `auditLog.export` (ADMIN-only dual-auth) surfaces the SAME 401-vs-403 split — the auth models differ, the status surface does not (corrected session-22 hostile review #2). Consumers must distinguish 401 (re-authenticate) from 403 (request a different API key) at the call site.
433
+
434
+ **Closed enums.** `severity` (`"critical"` / `"high"` / `"medium"` / `"low"`) and `status` (`"new"` / `"reviewed"` / `"actioned"` / `"dismissed"`) are pre-validated SDK-side as `TypeError` synchronously — kernel additions require an SDK release. Both arrays are exported as `REGULATORY_CHANGE_SEVERITIES` and `REGULATORY_CHANGE_STATUSES` and drift-pinned kernel-side. `framework` is an open string (forward-compat for new framework codes added kernel-side without an SDK bump). `from` / `to` are date-strings passed verbatim to the kernel's `new Date(...)` parser; the SDK does NOT pre-validate ISO-8601 (kernel's parser is lenient).
435
+
436
+ **Limit semantics.** Defaults to 200 server-side (max 200 — the kernel returns 400 for out-of-range). The SDK rejects `NaN` / `Infinity` / `<= 0` / non-integer as `TypeError` synchronously; values `> 200` are forwarded verbatim — kernel's authority. (Kernel's `MAX_LIMIT` is 200 here, NOT 5000 like `auditLog.export` — read carefully.)
437
+
438
+ **Wire shape.** `RegulatoryChange` is a 21-field row mirroring the kernel's `regulatoryChanges` Drizzle table verbatim — the route returns raw rows (no `rowToWireJson` mapper). `severity` and `status` are typed as `string` for forward-compat with kernel-side enum additions; `affectedRequirements`, `aiAnalysis`, and `statusTransitions` are typed as `unknown` (jsonb fields with comment-only shape hints; consumers parse via their own validators). Nullable timestamp fields (`effectiveDate`, `publishedAt`, `ingestedAt`, `notifiedAt`) round-trip as `null`.
439
+
440
+ ```ts
441
+ // Most recent 200 non-dismissed rows (kernel default).
442
+ const changes = await client.regulatoryChanges.list();
443
+
444
+ // Filter to critical EU AI Act updates from the last 30 days.
445
+ const critical = await client.regulatoryChanges.list({
446
+ framework: "EU_AI_ACT",
447
+ severity: "critical",
448
+ from: "2026-04-07T00:00:00Z",
449
+ limit: 50,
450
+ });
451
+
452
+ // Retrieve only dismissed rows (pass status explicitly — default omits them).
453
+ const dismissed = await client.regulatoryChanges.list({ status: "dismissed" });
454
+ ```
455
+
456
+ ### `client.complianceCheck`
457
+
458
+ | Method | Wraps | Returns |
459
+ |---|---|---|
460
+ | `check(input, options?)` | `GET /api/v1/compliance-check` | `Promise<ComplianceCheckResponse>` |
461
+
462
+ `complianceCheck.check()` returns a per-system compliance summary for either a single system (by UUID) or every system in an org (by org name, capped at 100). The response combines active-attestation counts, the latest completed assessment's `overallScore`, and a framework-coverage breakdown (applicable vs assessed). Sync JSON request/response — no pagination, no streaming. Returns `Promise<ComplianceCheckResponse>` shaped as `{systems: ComplianceCheckResult[], checkedAt: ISO-string}`.
463
+
464
+ **XOR input mode (read carefully).** Exactly one of `systemId` OR `orgName` must be provided. The kernel is **not** strict XOR — when both are provided, kernel silently picks `systemId` and ignores `orgName`. The SDK is **stricter** than the kernel and synchronously throws `TypeError` when both are provided. This is a deliberate design choice: kernel quirks are unstable across revisions; surfacing the conflict at the SDK boundary makes consumer code stable. The TypeScript type (`ComplianceCheckInput`) is a discriminated union that prevents typed callers from passing both at compile time.
465
+
466
+ **Multi-permission union auth scope.** The kernel uses `requireApiKeyWithPermission(req, READ_SYSTEMS, READ_ASSESSMENTS)` which is **OR** semantics — a key with EITHER permission (or `ADMIN`, or null/empty permissions for backwards-compat) succeeds. Returns HTTP **401** for no/invalid API key (the `requireApiKey` branch) and HTTP **403** only for an authenticated key that has NEITHER required permission (the `requireApiKeyWithPermission` branch). `auditLog.export` (ADMIN-only dual-auth) surfaces the SAME 401-vs-403 split — the auth models differ, the status surface does not (corrected session-22 hostile review #2).
467
+
468
+ **Asymmetric cross-org error codes (read carefully).** Cross-org `systemId` returns **404** ("System not found") — the kernel collapses cross-org to 404 to avoid leaking "this UUID exists but belongs to another org" (mirror of `decisions.retrieve`). Cross-org `orgName` returns **403** ("Access denied") — the kernel intentionally surfaces "the org exists but you can't see its systems". Consumers writing defensive error-handling logic must distinguish: a 404 on the systemId path may be "not your org" OR "genuine missing UUID"; a 403 on the orgName path is unambiguously "the org exists but you don't own it".
469
+
470
+ **Silent `.limit(100)` on the orgName path.** If the org has more than 100 systems, the response is silently truncated to the first 100 — NO `total` field, NO `hasMore` cursor, NO warning. The SDK does not mask this (faithful courier — the kernel decided 100 is enough). Consumers managing >100-system orgs should switch to `systemId`-per-row.
471
+
472
+ **Implicit threshold of 70 on `compliant`.** The `compliant` boolean is computed as `activeAttestations > 0 && (overallScore === null || overallScore >= 70)`. Two qualifying clauses: (1) at least one currently-active (non-expired) attestation exists; (2) either no scored assessment yet (counts as not-failing) OR the latest completed assessment's `overallScore >= 70`. Consumers wanting a different bar can apply it post-hoc via the `score` field.
473
+
474
+ **Wire shape.** `ComplianceCheckResult` has 7 fields (`systemId`, `systemName`, `compliant`, `score`, `frameworkCoverage`, `activeAttestations`, `lastAssessedAt`). `frameworkCoverage` is a 3-field nested object (`applicable: string[]`, `assessed: string[]`, `coveragePct: number`). `score` and `lastAssessedAt` are nullable. `coveragePct` is `Math.round((assessed.size / applicable.length) * 100)` when `applicable.length > 0`, else `0` — note the kernel does NOT clamp 0..100, so a system assessed against frameworks outside its applicable list can yield a `coveragePct > 100`.
475
+
476
+ ```ts
477
+ // Compliance check by system UUID.
478
+ const single = await client.complianceCheck.check({
479
+ systemId: "11111111-1111-1111-1111-111111111111",
480
+ });
481
+ console.log(single.systems[0].compliant, single.systems[0].score);
482
+
483
+ // Compliance check by org name (capped at 100 systems — silently).
484
+ const org = await client.complianceCheck.check({
485
+ orgName: "Acme Corp",
486
+ });
487
+ console.log(`${org.systems.length} systems checked at ${org.checkedAt}`);
488
+ ```
489
+
490
+ ### `client.check`
491
+
492
+ | Method | Wraps | Returns |
493
+ |---|---|---|
494
+ | `run(input, options?)` | `POST /api/v1/check` | `Promise<CheckResponse>` |
495
+
496
+ `check.run()` returns a flat per-system CI/CD compliance summary suitable for blocking a deploy on missing attestations or low assessment scores. The response combines a `compliant` boolean (computed kernel-side at the implicit threshold of 70 — see below), the latest completed assessment's `overallScore`, an up-to-20 issues array derived from that assessment's gaps, an active-attestations count, and timestamp metadata. Sync JSON request/response — no pagination, no streaming. Returns `Promise<CheckResponse>` with 6 top-level fields: `compliant`, `score`, `issues`, `activeAttestations`, `lastAssessedAt`, `checkedAt`. Method name `run` (not `check`) avoids the awkward `client.check.check` collision.
497
+
498
+ **Method name — `client.check.run(input)`.** The resource is named `check` (matches the kernel route `/api/v1/check`); the method is `run`. Mirrors `chat.send` / `decisions.ingest` / `auditLog.export` verb-method convention.
499
+
500
+ **Multi-permission union auth scope.** The kernel uses `requireApiKeyWithPermission(req, READ_ASSESSMENTS, READ_SYSTEMS)` which is **OR** semantics — a key with EITHER permission (or `ADMIN`, or null/empty permissions for backwards-compat) succeeds. Returns HTTP **401** for no/invalid API key and HTTP **403** only for an authenticated key that has NEITHER required permission. Same shape as `complianceCheck.check` (arguments in the opposite order, but `Array.some()` doesn't care). `auditLog.export` (ADMIN-only dual-auth) surfaces the SAME 401-vs-403 split — the auth models differ, the status surface does not (corrected session-22 hostile review #2).
501
+
502
+ **Cross-org systemId collapses to 404.** The kernel's `and(eq id, eq orgId)` followed by `errorResponse("System not found", 404)` collapses cross-org systemId to 404 — mirror of `decisions.retrieve` and `complianceCheck.check`'s systemId branch. Consumers writing defensive error-handling logic must recognize: a 404 may be "not your org" OR "genuine missing UUID". No 403-via-orgName twin here (no orgName input mode).
503
+
504
+ **First SDK route to pre-validate every Zod closed-spec rule synchronously.** The kernel uses `parseBody(request, checkSchema)` where `checkSchema = z.object({systemId: z.string().uuid(), frameworks: z.array(z.string().min(1).max(100)).max(20).optional()})`. Other Zod-bodied SDK routes (e.g., `incidents.create`) pass input through without SDK-side validation, so a 422 from Zod is the consumer-visible surface there; in `check.run` the SDK pre-validates each Zod closed-spec rule (UUID format, string length 1-100, array length cap 20) so 422 only reaches consumers through a kernel-side rule change the SDK hasn't synced to (the SDK's runtime checks always run regardless of TypeScript types — `as any` casts do NOT bypass them). The SDK-side error is a synchronous `TypeError` with a specific message naming the violating field; the kernel-side 422 fallback body is `{success: false, error: "Validation failed.", details: Array<{path: string; message: string}>}` (the field errors live at the `details` ARRAY, NOT a `fieldErrors` keyed map; consumers reading field-by-field errors iterate `apiErr.details.details`). Consumers writing defensive error-handling code should expect the SDK-side TypeError as the normal path.
505
+
506
+ **THREE silent kernel-side truncations** (each separately load-bearing — the SDK does NOT mask any of them, faithful courier):
507
+ - `issues` — `gaps.slice(0, 20)` at `route.ts:90`. If the latest completed assessment has >20 gaps, the 21st+ are invisible (no `total`, no `hasMore`, no truncation flag).
508
+ - `assessments` row-population — `.limit(100)` at `route.ts:62`. The kernel reads up to 100 assessments and sorts in JS to find the latest completed. If a system has >100 assessment rows, the "latest completed" may be MISSED (positions 100+ are silently dropped pre-sort).
509
+ - `attestations` row-population — `.limit(50)` at `route.ts:100`. The kernel reads up to 50 attestation rows and counts active ones. If a system has >50 attestations, the `activeAttestations` count may be UNDERCOUNTED.
510
+
511
+ **`score` defaults to 0 (not null) — kernel surface gap.** Asymmetric with `complianceCheck.check` (which used `null` for "no data"). The kernel emits `score: 0` whenever no completed assessment exists OR the latest's `scores.overallScore` field is missing / non-numeric. **Consumers cannot distinguish "scored zero / fails compliance" from "no completed assessment yet" via `score` alone** — they MUST check `lastAssessedAt === null` to differentiate. The SDK does NOT mask this; documented prominently in JSDoc + this section.
512
+
513
+ **`compliant` threshold of 70 — stricter than `complianceCheck.check`.** Computed kernel-side as `activeAttestations > 0 && overallScore >= 70 && issues.length === 0` (three conjuncts). Because `score` defaults to 0 (not null), a system with no completed assessment and active attestations still has `compliant: false` here — different from `complianceCheck.check` which treated null-score as "not failing". Consumers wanting different semantics should inspect `score`, `lastAssessedAt`, and `activeAttestations` directly.
514
+
515
+ **`frameworks` filter is OR-overlap (NOT AND-all-required).** When `frameworks` is supplied, the kernel filters assessments to those whose `assessment.frameworks` array **intersects** the filter (at least one in common — `aFrameworks.some(...)` at `route.ts:67-71`). Consumers expecting "match systems covered by ALL these frameworks" will be surprised. Omitting `frameworks` (or passing an empty array) considers all assessments.
516
+
517
+ ```ts
518
+ // Basic CI/CD check.
519
+ const result = await client.check.run({
520
+ systemId: "11111111-1111-1111-1111-111111111111",
521
+ });
522
+ if (result.compliant) {
523
+ console.log("OK to deploy — score:", result.score);
524
+ } else if (result.lastAssessedAt === null) {
525
+ // CRITICAL: score=0 + lastAssessedAt=null means "no completed
526
+ // assessment yet" — NOT "failed with score zero". Treat as
527
+ // pre-launch, not as a failing grade.
528
+ console.warn("No completed assessment yet — gate may need a baseline run");
529
+ } else {
530
+ console.warn("Compliance gaps:", result.issues);
531
+ console.warn("Score:", result.score, "(threshold = 70)");
532
+ }
533
+
534
+ // Filtered by frameworks (OR-overlap, not AND-all-required).
535
+ const euOnly = await client.check.run({
536
+ systemId: "11111111-1111-1111-1111-111111111111",
537
+ frameworks: ["EU_AI_ACT", "ISO_42001"],
538
+ });
539
+ ```
540
+
541
+ ### `client.gate`
542
+
543
+ | Method | Wraps | Returns |
544
+ |---|---|---|
545
+ | `evaluate(input, options?)` | `POST /api/v1/gate` | `Promise<GateResponse>` |
546
+
547
+ `gate.evaluate()` returns a pass/fail verdict for CI/CD deployment gates, with a structured list of unresolved compliance gaps suitable for build logs. Designed for pipeline integration (curl-from-CI / GitHub Actions / GitLab CI). Sync JSON request/response — no pagination, no streaming. Method name `evaluate` (not `run` / `check`) matches the verb-method convention AND the pass/fail evaluation semantics naturally; `check` would clash with `complianceCheck.check` and `check.run`.
548
+
549
+ **Three emit paths.** The response shape varies by whether a `relevantAssessment` was found (kernel route.ts:88-98) and the value of `failOnMissingAssessment`:
550
+ - **Path 1 — normal pass/fail (`relevantAssessment` found)**: 14 fields. `score: number`; emit-only fields (`assessmentId`, `assessmentDate`, `gapCount`, `criticalGaps`, `highGaps`) all present.
551
+ - **Path 2 — fail-on-missing**: `failOnMissingAssessment=true` (the default) AND `relevantAssessment` is falsy. 9 fields. `gate: "fail"`, `score: null`, `gaps: []`. Emit-only fields ABSENT (own-property false).
552
+ - **Path 3 — pass-on-missing**: `failOnMissingAssessment=false` AND `relevantAssessment` is falsy. 9 fields. `gate: "pass"`, `score: null`, `gaps: []`. Emit-only fields ABSENT.
553
+
554
+ **`relevantAssessment` is falsy in TWO distinct cases**: (a) NO completed assessment exists within the 10 most-recent assessment rows (silent `.limit(10)` truncation — see below), OR (b) — with `frameworks` specified — no completed assessment within those 10 rows matches ANY framework via substring + case-insensitive comparison. A consumer setting `frameworks: ["UNMATCHED_FRAMEWORK"]` on a system with multiple completed assessments would fall into Paths 2/3 and see the literal `reason` string "No completed assessment found for this system." — even though completed assessments DO exist (they just don't match the filter). Consumers should NOT use Paths 2/3 alone to conclude "this system has never had a completed assessment".
555
+
556
+ The SDK exposes a single `GateResponse` type with the 5 emit-only fields marked optional (`?:`). The recommended discriminator is `score === null` (Paths 2 + 3) — mirrors `check.run`'s `lastAssessedAt === null` disambiguation pattern. `Object.hasOwn(response, "assessmentId") === false` is an equivalent own-property-only alternative that is ALSO safe under prototype pollution. **Do NOT use `response.assessmentId === undefined`** — a hostile/buggy dep polluting `Object.prototype.assessmentId` makes the `=== undefined` check return false (reads via prototype walk) even in Paths 2 + 3, silently misclassifying them as Path 1.
557
+
558
+ **`gate` is a STRING ENUM, NOT a boolean.** The kernel emits the literal strings `"pass"` and `"fail"` (route.ts:114, 127, 181). Type-narrowing via equality check: `if (result.gate === "pass") { ... }`. Consumers comparing against `true`/`false` see `false` (string-vs-boolean comparison).
559
+
560
+ **Type contract is closed; runtime is open (faithful courier).** The SDK's TypeScript type is `gate: "pass" | "fail"` (closed union), but the P2 runtime validator checks `typeof gate === "string"` only — it does NOT reject unknown string values. If a future kernel emits `gate: "warn"` / `gate: "skip"` / etc. before the SDK is bumped, the value round-trips at runtime (typed as the closed union at compile time, but holding the new string at runtime). Consumers using exhaustive type-narrowing (`if (gate === "pass") ... else /* TS: "fail" */`) would misclassify an unknown value as the `"fail"` branch. Kernel-side `gate` emit-sites are drift-pinned via the wire-shape build-round pin, so a kernel extension surfaces in the drift suite before consumer regressions.
561
+
562
+ **Method name — `client.gate.evaluate(input)`.** The resource is named `gate` (matches the kernel route `/api/v1/gate`); the method is `evaluate`. Mirrors `chat.send` / `decisions.ingest` / `auditLog.export` / `check.run` verb-method convention.
563
+
564
+ **Multi-permission union auth scope.** The kernel uses `requireApiKeyWithPermission(req, READ_ASSESSMENTS, READ_SYSTEMS)` which is **OR** semantics — a key with EITHER permission (or `ADMIN`, or null/empty permissions for backwards-compat) succeeds. Returns HTTP **401** for no/invalid API key and HTTP **403** only for an authenticated key that has NEITHER required permission. **Same shape as `check.run`** (argument order identical — both list `READ_ASSESSMENTS` first).
565
+
566
+ **Cross-org systemId collapses to 404.** The kernel's `and(eq id, eq orgId)` followed by `errorResponse("System not found or access denied", 404)` collapses cross-org systemId to 404 — partial mirror of `check.run` (note: gate emits a **LONGER literal string** `"System not found or access denied"` vs check.run's `"System not found"`). Consumers writing defensive error-handling logic must recognize: a 404 may be "not your org" OR "genuine missing UUID".
567
+
568
+ **SECOND SDK route to pre-validate every Zod closed-spec rule synchronously** (after `check.run`). FOUR pre-validated fields — most extensive pre-validation surface in the SDK to date:
569
+ - `systemId`: RFC 4122 hyphenated UUID format (`/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-...-[0-9a-fA-F]{12}$/`, case-insensitive).
570
+ - `minScore`: integer in `[0, 100]` inclusive — `typeof === "number"` + `Number.isInteger` (already rejects NaN / ±Infinity) + bounds check.
571
+ - `failOnMissingAssessment`: `typeof === "boolean"` (rejects truthy/falsy non-booleans like `1` / `"true"` / `null`).
572
+ - `frameworks`: array, ≤20 elements, each string of length 1-100. Snapshotted via `Array.from` for TOCTOU defense.
573
+
574
+ The SDK's runtime checks always run regardless of TypeScript types — `as any` casts do NOT bypass them. So the kernel's 422 surface only reaches consumers via kernel-side rule changes the SDK hasn't synced to. SDK-side error is a synchronous `TypeError` naming the violating field; kernel-side 422 fallback body is `{success: false, error: "Validation failed.", details: Array<{path: string; message: string}>}` (the field errors live at the `details` ARRAY, NOT a `fieldErrors` keyed map; consumers reading field-by-field errors iterate `apiErr.details.details`).
575
+
576
+ **TWO silent kernel-side truncations** (each separately load-bearing — the SDK does NOT mask either, faithful courier):
577
+ - `assessments` row-population — `.limit(10)` at `route.ts:85`. **TIGHTER than `check.run`'s `.limit(100)`** — gate is strictly less defensive against many-assessment systems. The kernel reads up to 10 assessment rows by `completedAt` DESC and finds the "relevant" completed one via `.find()` over that subset. A system with the most-recent completed assessment in position 11+ would be misclassified as "no assessment found" (falling into Paths 2 or 3).
578
+ - `remediationTasks` row-population — `.limit(100)` at `route.ts:154`. If the relevant assessment has >100 unresolved remediation tasks, the 101st+ are invisible. The cap applies BEFORE the filter-to-unresolved step (`status !== "resolved" && status !== "wont_fix"`), so the final `gaps.length` may be less than 100 even at the cap.
579
+
580
+ **`score` is `null` in no-assessment paths (NOT 0)** — **asymmetric with `check.run`** which used `0` as the default. Gate's `null` preserves the distinction at the type level and is more consumer-friendly for the CI/CD pipeline use case. Consumers should use `score === null` (NOT `score === 0`) to detect Paths 2 + 3. In Path 1, a system that legitimately scored 0 has `score: 0` (NOT null) — distinct from the no-assessment branches.
581
+
582
+ **In Path 1, `score: 0` is AMBIGUOUS.** The value can mean either (a) the assessment legitimately scored zero, OR (b) the assessment row had a missing / non-numeric `scores.overall` (kernel collapses to 0 via `typeof === "number" ? value : 0` at `route.ts:141`). Consumers CANNOT distinguish these from the wire response alone — both cases emit `score: 0` with all 14 Path-1 fields present. A CI/CD pipeline treating `gate: "fail" && score === 0` as a "broken assessment data" signal would silently miss case (a). Faithful courier; the SDK does NOT mask the kernel's collapse.
583
+
584
+ **`frameworks` filter is substring + case-insensitive** (kernel uses `aFrameworks.some((af) => af.toLowerCase().includes(f.toLowerCase()))` at `route.ts:94-96`). **Asymmetric with `check.run`'s OR-overlap exact-equality.** Consumer passing `["GDPR"]` matches an assessment with frameworks `["EU_GDPR_2024"]`, `["gdpr_compliance_v2"]`, etc. — looser semantics than `check.run`. Omitting `frameworks` (or passing an empty array) considers all assessments.
585
+
586
+ **Side effect — `gate.evaluate()` writes one `gate.checked` audit log entry per call** (route.ts:104-111 for the no-assessment paths, route.ts:165-178 for the normal path). **NEW for a read-shaped SDK route** (invariant candidate #53). Properties of the write:
587
+ - Org-scoped, hash-chained (per `writeAuditLog`).
588
+ - **Time-blocking** but error-tolerant: the kernel uses `await writeAuditLog(...)` which awaits two DB ops (SELECT previous-hash + INSERT new entry). The gate response latency INCLUDES the audit-log write time — a slow audit-log DB will delay every `gate.evaluate()` response. Error semantics ARE non-blocking: `writeAuditLog` wraps its body in a try/catch that swallows + logs errors, so a write FAILURE does NOT fail the gate request.
589
+ - NOT counted against `decisionsPerMonth` quota.
590
+
591
+ Consumers should know each `gate.evaluate(...)` call leaves an auditable trail. Compliance use case, not a bug.
592
+
593
+ **Two non-obvious defaults applied kernel-side when fields are omitted** (carry-forward #44, non-obvious-default-filter pattern; invariant candidate #52 — closed-default field pre-validation):
594
+ - `minScore` defaults to **70** (Zod `.default(70)`). Consumers who omit this field get the implicit threshold of 70.
595
+ - `failOnMissingAssessment` defaults to **true** (Zod `.default(true)`). Consumers who omit this get strict behavior (no assessment = fail).
596
+
597
+ The SDK omits these fields from the request body when the consumer omits them, so the kernel applies its defaults.
598
+
599
+ **`GateGap` shape.** Path 1's `gaps` array contains `GateGap` rows from `schema.remediationTasks` filtered to `status !== "resolved" && status !== "wont_fix"`:
600
+
601
+ ```ts
602
+ interface GateGap {
603
+ requirementKey: string; // foreign key to the framework requirement
604
+ title: string;
605
+ priority: string; // open-spec; kernel aggregates "critical" and "high"
606
+ status: string; // open-spec; filtered to NOT "resolved" / "wont_fix"
607
+ }
608
+ ```
609
+
610
+ `priority` and `status` are open-spec strings (kernel does NOT enforce closed enums on the underlying `remediationTasks` columns). The kernel's `criticalGaps` / `highGaps` count fields match the literal strings `"critical"` / `"high"` for aggregation; consumers using custom priority taxonomies won't see those aggregated.
611
+
612
+ ```ts
613
+ // Basic gate evaluation (defaults: minScore=70, failOnMissingAssessment=true).
614
+ const result = await client.gate.evaluate({
615
+ systemId: "11111111-1111-1111-1111-111111111111",
616
+ });
617
+ if (result.gate === "pass") {
618
+ console.log("OK to deploy — score:", result.score);
619
+ } else if (result.score === null) {
620
+ // CRITICAL: score=null means "no completed assessment yet" — NOT
621
+ // "failed with score zero". Use score === null (not score === 0)
622
+ // to detect Paths 2 + 3.
623
+ console.warn("No completed assessment — failing strict-mode gate");
624
+ } else {
625
+ // Path 1 fail: emit-only fields are present at runtime, but typed
626
+ // as optional. Use `??` so the example compiles without `!` / `as`.
627
+ console.warn(
628
+ `Score ${result.score} below threshold ${result.minScore};`,
629
+ `${result.gapCount ?? 0} unresolved gaps (${result.criticalGaps ?? 0} critical)`,
630
+ );
631
+ // gaps is a structured GateGap[] — iterate for build-log output.
632
+ for (const gap of result.gaps) {
633
+ console.warn(` - [${gap.priority}] ${gap.title} (${gap.requirementKey})`);
634
+ }
635
+ }
636
+
637
+ // Strict threshold + framework filter (substring + case-insensitive,
638
+ // NOT OR-overlap exact-equality like check.run).
639
+ const euOnly = await client.gate.evaluate({
640
+ systemId: "11111111-1111-1111-1111-111111111111",
641
+ minScore: 85,
642
+ frameworks: ["EU_AI_ACT", "ISO_42001"],
643
+ });
644
+
645
+ // Pre-launch / staging — allow missing assessments.
646
+ const lenient = await client.gate.evaluate({
647
+ systemId: "11111111-1111-1111-1111-111111111111",
648
+ failOnMissingAssessment: false,
649
+ });
650
+ // `lenient.gate === "pass"` even without a completed assessment.
651
+ ```
652
+
653
+ ### `client.batch`
654
+
655
+ | Method | Endpoint | Returns |
656
+ |---|---|---|
657
+ | `submit(input, options?)` | `POST /api/v1/batch` | `Promise<BatchSubmitResponse>` |
658
+ | `get(id, options?)` | `GET /api/v1/batch/<UUID>` | `Promise<BatchJobStatus>` |
659
+
660
+ `batch.submit()` submits up to 50 systems for inline classification and/or current-state assessment, returning a per-system success/error envelope. `batch.get(id)` retrieves a batch job's status and results by UUID. **First SDK resource with asymmetric auth between methods on the same resource** — `submit()` requires a key with `CLASSIFY` or `WRITE_ASSESSMENTS` (UNION); `get()` requires only `READ_ASSESSMENTS` (single permission). Multi-method resource (sibling to `chat.send` + `chat.stream`).
661
+
662
+ **Multi-permission UNION auth on `submit()` — FIRST WRITE-side union pair on the SDK.** Kernel uses `requireApiKeyWithPermission(req, CLASSIFY, WRITE_ASSESSMENTS)` with `Array.some()` semantics (an API key with EITHER permission succeeds). Every prior SDK union has been READ-side — batch is the first WRITE-side union pair. HTTP **401** for no/invalid API key; HTTP **403** for an authenticated key that has NEITHER permission.
663
+
664
+ **NEW plan-guard 403 surface on `submit()` — distinct from the permission-403.** The kernel calls `requirePlan(org, "hasBatchProcessing")` BEFORE Zod body parsing — a free-tier (or trial-expired non-enterprise) org hits the plan gate FIRST, regardless of body validity. The kernel emits `PlanLimitError` → **403** with the literal wording:
665
+
666
+ ```
667
+ The "hasBatchProcessing" feature is not available on your current plan (<plan>). Please upgrade to access this feature.
668
+ ```
669
+
670
+ This is **distinct from the permission-403's wording**:
671
+
672
+ ```
673
+ API key lacks required permission. Required: classify or write:assessments. Key has: <perms>.
674
+ ```
675
+
676
+ The SDK surfaces both uniformly as `AttestryAPIError(403)`. Consumers who need to distinguish "upgrade your plan" from "grant more permissions to your key" should regex-match `apiErr.message`:
677
+
678
+ ```ts
679
+ try {
680
+ await client.batch.submit({ jobType: "classify", systemIds: [...] });
681
+ } catch (err) {
682
+ if (err instanceof AttestryAPIError && err.status === 403) {
683
+ // Prefer matching the TEMPLATE STEM (kernel-stable) over the
684
+ // FEATURE KEY ("hasBatchProcessing" — internal kernel name that
685
+ // the kernel team may rename). The stem is less likely to drift.
686
+ if (/feature is not available on your current plan/.test(err.message)) {
687
+ // Plan-gate — show "Upgrade to enterprise" CTA
688
+ } else if (/API key lacks required permission/.test(err.message)) {
689
+ // Permission denial — show "Generate a new API key with CLASSIFY" CTA
690
+ }
691
+ }
692
+ throw err;
693
+ }
694
+ ```
695
+
696
+ A future kernel version adding structured error metadata would unlock a clean discriminator field on `apiErr.details`.
697
+
698
+ **Single-permission auth on `get()` — DIFFERENT from `submit()`.** Kernel uses `requireApiKeyWithPermission(req, READ_ASSESSMENTS)` with ONLY ONE required permission, NOT a union. Status reads don't need `CLASSIFY` or `WRITE_ASSESSMENTS`. **No plan-guard surface** on `get()` — a free-tier org can `get()` a job submitted earlier on a higher plan that has since downgraded. The submission would have been gated; the read isn't.
699
+
700
+ **Closed-enum `jobType` — SDK pre-rejects unknown values.** Three valid values via the `BATCH_JOB_TYPES` frozen array:
701
+
702
+ - `"classify"` — run the rule-based classifier on each system and **persist** the new `riskClassifications` (write-side effect). Per-row `classifications` contains the fresh classification.
703
+ - `"assess"` — emit each system's CURRENT `riskClassifications` from the DB (read-only; no write side effect, despite `WRITE_ASSESSMENTS` being a valid auth permission). Per-row `classifications` contains whatever was already on the row (may be `null` if no prior classification).
704
+ - `"classify_and_assess"` — same as `"classify"` (the kernel branches `classify || classify_and_assess` together). The two-name distinction is purely semantic for the consumer — both write the new classification and emit it.
705
+
706
+ > **Forward-compat caveat**: closed-enum SDK pre-rejection (invariant #41) means a deployed SDK against a future kernel that has widened the enum (e.g., added `"verify"`) will reject the new value synchronously with `TypeError` listing the OLD set — even though the kernel would accept it. **To consume a new enum value, upgrade `@attestry/sdk` to a version that includes it.** The drift suite catches the kernel widening at SDK CI build time, so the SDK team is informed; consumers using the old SDK with a newer kernel are not.
707
+
708
+ **`systemIds` bounds — 1 to 50, each UUID.** The `.min(1)` is **new** vs `gate.evaluate`'s `frameworks` (which allowed empty). The SDK pre-validates empty arrays + oversize arrays + per-element UUID format synchronously. **`Array.from` snapshot** for TOCTOU defense.
709
+
710
+ **`config.frameworks` — round-trip-only today.** The kernel persists `config` to the row but does NOT use `config.frameworks` in the current inline classification path (`classifySystem()` doesn't take a frameworks filter). The field is forward-compat for future job types. Consumers passing `config.frameworks` today see it round-tripped on `get()` but with no visible effect.
711
+
712
+ **Partial-success contract — `submit()` resolves successfully even when every row failed.** Inspect `response.failedSystems` (or iterate `response.results` filtering `row.status === "error"`) to detect per-row errors. Top-level failures (auth, plan, rate limit, Zod, cross-org systemId, internal) DO throw `AttestryAPIError`. Mirror of `decisions.bulk`'s contract.
713
+
714
+ **Per-row discriminator: `row.status === "success"` (closed-enum string match).** Do **NOT** use `row.errorMessage === undefined` or `row.classifications === undefined` as the discriminator — under `Object.prototype.errorMessage = <value>` pollution, the equality check walks the prototype and reads the polluted value, returning false even when the own-property is genuinely absent. The `status` field is the pollution-safe discriminator.
715
+
716
+ **TWO DISTINCT STATUS ENUMS on the response wire-shape family.** Top-level `response.status` is the **batch-job** status:
717
+ - POST emits **`"completed" | "failed"` only** (kernel-computed at handler end — `failed === total ? "failed" : "completed"`).
718
+ - GET emits the **WIDER 4-value enum** `"pending" | "processing" | "completed" | "failed"` (DB column pass-through). SDK-submitted jobs always observe `"completed" | "failed"` in practice (already-processed inline), but a GET on a job submitted via a future async path could observe `"pending"` / `"processing"`.
719
+
720
+ Per-row `response.results[i].status` is the **per-system** status — `"success" | "error"` only, in BOTH POST and GET responses. **Consumers reading `if (response.status === "completed")` are checking a different thing than `if (response.results[0].status === "success")`.**
721
+
722
+ **Asymmetric 404 shapes between methods.** `submit()` emits 404 with **EMBEDDED variable data** — the comma-joined invalid UUIDs in the message string:
723
+
724
+ ```
725
+ Systems not found or not in your organization: 22222222-2222-2222-2222-222222222222, 33333333-3333-3333-3333-333333333333
726
+ ```
727
+
728
+ `get()` emits 404 as a **LITERAL string** with no embedded data:
729
+
730
+ ```
731
+ Batch job not found
732
+ ```
733
+
734
+ The SDK does NOT parse the embedded UUIDs out of `submit()`'s 404 (faithful courier); consumers can regex-match if needed.
735
+
736
+ **400 surface on `get()` only.** Kernel `isValidUuid(id)` returns false → 400 `"Invalid batch job ID format"`. The SDK pre-validates UUID format synchronously (`TypeError`) — so the 400 reaches consumers only via `as any` casts or a kernel-side switch to a different UUID flavor.
737
+
738
+ **Side effect — `batch.submit()` writes one `batch.submitted` audit log entry per call** (not counted against `decisionsPerMonth` quota). Org-scoped, hash-chained. **Time-blocking** but error-tolerant: the kernel uses `await writeAuditLog(...)`, which awaits two DB ops (SELECT previous-hash + INSERT new entry). The submit-call response latency **INCLUDES** the audit-log write time. **Error semantics ARE non-blocking**: `writeAuditLog` wraps its body in a try/catch that swallows and logs errors, so a write FAILURE does NOT fail the submit request. `batch.get()` has NO audit-log write — status reads are quiet.
739
+
740
+ **Two silent kernel-side truncations (faithful courier).** Documented as kernel surface gaps:
741
+ - `submit()` `.limit(500)` on the org-systems verification query — orgs with >500 systems may see spurious 404s on batch submissions referencing systems outside the first 500 rows.
742
+ - `get()` `.limit(1)` on the batchJobs query — defensive only; the `where` clause already narrows to one row by primary key UUID.
743
+
744
+ ```ts
745
+ // Submit a classify job for 3 systems.
746
+ const result = await client.batch.submit({
747
+ jobType: "classify",
748
+ systemIds: [
749
+ "11111111-1111-1111-1111-111111111111",
750
+ "22222222-2222-2222-2222-222222222222",
751
+ "33333333-3333-3333-3333-333333333333",
752
+ ],
753
+ });
754
+ console.log(`Processed ${result.processedSystems}/${result.totalSystems} systems`);
755
+ for (const row of result.results) {
756
+ if (row.status === "success") {
757
+ // CRITICAL: branch on `row.status === "success"` (closed-enum
758
+ // string match) — NOT `row.errorMessage === undefined` (which
759
+ // is prototype-pollution unsafe).
760
+ console.log(`OK ${row.systemId}:`, row.classifications);
761
+ } else {
762
+ console.error(`FAIL ${row.systemId}: ${row.errorMessage}`);
763
+ }
764
+ }
765
+
766
+ // Retrieve a job's status.
767
+ const job = await client.batch.get(result.id);
768
+ if (job.status === "completed") {
769
+ console.log(`Job complete — ${job.processedSystems}/${job.totalSystems}`);
770
+ } else if (job.status === "failed") {
771
+ console.error("Batch failed entirely");
772
+ } else {
773
+ // "pending" / "processing" — still in flight
774
+ console.log(`Job is ${job.status}`);
775
+ }
776
+
777
+ // Submit with framework filter (round-trip only today — kernel does NOT
778
+ // use config.frameworks in the inline classification path).
779
+ const futureProofed = await client.batch.submit({
780
+ jobType: "classify_and_assess",
781
+ systemIds: ["11111111-1111-1111-1111-111111111111"],
782
+ config: { frameworks: ["EU_AI_ACT", "ISO_42001"] },
783
+ });
784
+ // futureProofed.results contains the classifications;
785
+ // `futureProofed.config` is NOT in the response (POST omits it
786
+ // because it's already in the request body). On GET, `config` IS
787
+ // echoed back so callers retrieving by ID can see what was used.
788
+ ```
789
+
790
+ ### `client.shipGate`
791
+
792
+ | Method | Wraps | Returns |
793
+ |---|---|---|
794
+ | `check(input, options?)` | `POST /api/v1/ship-gate/check` | `Promise<ShipGateCheckResponse>` |
795
+
796
+ `shipGate.check()` returns a 4-shape verdict describing whether a CI/CD build is gated by an in-flight approval-chain execution. Designed for pipeline integration (GitHub Actions / GitLab CI / Buildkite). Sync JSON request/response — no pagination, no streaming.
797
+
798
+ **Distinct from `gate.evaluate`.** That method is a synchronous compliance-score gate (pass/fail on assessment scores). `shipGate.check()` is a multi-approver workflow gate that asks "is an in-flight approval chain blocking THIS build?". Different lifecycle (gate.evaluate has no state; shipGate has the `gated → released/rejected/timed_out` state machine bound to an approval-chain execution). Different consumer audience (gate.evaluate for CI score gates; shipGate.check for human-approver gates).
799
+
800
+ **Variadic four-shape response.** The response has `gated: boolean` as ALWAYS-PRESENT anchor and 5 OPTIONAL own-property fields. Discriminate via `gated === true` (closed-enum boolean — pollution-safe), NOT `reason === undefined` (prototype-pollution-unsafe — reads via prototype walk):
801
+
802
+ - **Shape A — no gate exists**: `{ gated: false }` (1 field). Default-permissive short-circuit — no `ship_gates` row for this `(systemId, attestationId)` tuple. The gate is opt-in; consumers who never create a gate never block a build.
803
+ - **Shape B — released**: `{ gated: false, state: "released", executionId, chainId }` (4 fields). The approval chain approved the deployment.
804
+ - **Shape C — rejected / timed_out**: `{ gated: true, reason: "rejected" | "timed_out", approvers_pending: [], state, executionId, chainId }` (6 fields). The approval chain went terminal in a build-blocking state. `approvers_pending` is always `[]` (nobody is pending on a closed chain).
805
+ - **Shape D — gated awaiting approvers**: `{ gated: true, reason: "awaiting_approvers", approvers_pending: [<UUIDs>], state: "gated", executionId, chainId }` (6 fields). The approval chain is in-flight; `approvers_pending` lists the userIds still owed a decision (pool-order, post-decided filtering).
806
+
807
+ **Wire-shape vs TypeScript-narrowed.** The optional own-property fields (`reason`, `approvers_pending`, `state`, `executionId`, `chainId`) are ABSENT from the JSON wire on shapes that don't emit them (JSON has no `undefined`); TypeScript reads them as `<type> | undefined` due to the `?:` marker. For own-property detection inside the SDK, the response validator uses a module-load `Object.hasOwn` snapshot (pollution-safe at the validator boundary). For CONSUMER-side detection, branch on `result.gated === true | false` first — that's the only ALWAYS-present pollution-safe boolean. `Object.hasOwn(result, "state")` on the CONSUMER side relies on the live global, which is itself subject to override by a hostile dep (only the SDK's internal snapshot is hardened).
808
+
809
+ **`approvers_pending` is SNAKE_CASE on the wire.** Asymmetric with the rest of the SDK's camelCase response surface — the kernel emits the literal field name `approvers_pending` (master plan spec contract line 5369). Consumers must use the snake_case spelling to read the field.
810
+
811
+ **Type contract is closed; runtime is open (faithful courier).** The SDK's TypeScript types are `ShipGateReasonCode = "awaiting_approvers" | "rejected" | "timed_out"` (3 values) and `ShipGateState = "gated" | "released" | "rejected" | "timed_out"` (4 values), but the P2 runtime validator checks `typeof === "string"` only — it does NOT reject unknown string values. If a future kernel emits a new reason / state value before the SDK is bumped, the value round-trips at runtime. Mirror of `gate.evaluate`'s `gate: "pass" | "fail"` pattern.
812
+
813
+ **Method name — `client.shipGate.check(input)`.** The resource is named `shipGate` (matches the kernel route `/api/v1/ship-gate/`); the method is `check` (matches the kernel endpoint `/check`). Chosen over `run` / `evaluate` because the kernel endpoint is named `/check` and `check.run` already occupies the `.run` verb at the SDK level.
814
+
815
+ **Multi-permission union auth scope.** The kernel uses `requireApiKeyWithPermission(req, READ_SYSTEMS, READ_ASSESSMENTS)` with `Array.some()` semantics (a key with EITHER permission succeeds). Returns HTTP **401** for no/invalid API key and HTTP **403** for an authenticated key that has NEITHER permission. **NOTE — argument order is READ_SYSTEMS FIRST** (asymmetric with `check.run` and `gate.evaluate` which list `READ_ASSESSMENTS` first). `Array.some()` is order-insensitive at runtime, but a kernel-side error message would echo the order declared.
816
+
817
+ **Fourth SDK route to pre-validate every Zod closed-spec rule synchronously** (after `check.run`, `gate.evaluate`, and `batch.submit`). Two pre-validated fields:
818
+ - `systemId`: RFC 4122 hyphenated UUID format (`/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-...-[0-9a-fA-F]{12}$/`, case-insensitive).
819
+ - `attestationId`: non-empty string of length 1-256 (matches kernel constant `MAX_ATTESTATION_ID_LENGTH = 256` at `src/lib/workflow/ship-gates.ts:106`; drift-pinned).
820
+
821
+ The SDK's runtime checks always run regardless of TypeScript types — `as any` casts do NOT bypass them. So the kernel's 422 surface only reaches consumers via kernel-side rule changes the SDK hasn't synced to. SDK-side error is a synchronous `TypeError` naming the violating field; kernel-side 422 fallback body is `{success: false, error: "Validation failed.", details: Array<{path: string; message: string}>}` (the field errors live at the `details` ARRAY, NOT a `fieldErrors` keyed map; consumers reading field-by-field errors iterate `apiErr.details.details`).
822
+
823
+ **Documented kernel-side cascade-gap surfaces — TWO distinct paths.**
824
+ 1. **Execution-missing → HTTP 404** (named-error path). The kernel maps `ShipGateExecutionNotFoundError` to 404 at route.ts:97-99. Thrown only when a `ship_gates` row references an `executionId` whose row is missing in `approval_chain_executions`.
825
+ 2. **Chain-missing → HTTP 500 (scrubbed)** (plain-Error path). A SEPARATE defensive branch in `checkShipGate` throws a plain `Error` (NOT a named ship-gate class) when a ship_gate → execution → chain reference is broken on the LAST hop. The route's catch block falls through to `internalErrorResponse → 500` with the scrubbed message "An internal error occurred. Please try again later." The caller cannot distinguish this cascade-gap from any other internal error via the HTTP status alone.
826
+
827
+ Both branches are unreachable in normal operation (RESTRICT FK + filter-by-orgId); both documented as "only reachable via direct DB intervention or a cascade-behavior gap". Faithful courier: the SDK surfaces whichever status the kernel chose. SIEM consumers running cascade-gap-404 filters should know the second branch hides as 500 (NOT 404).
828
+
829
+ **`writeAuditLog` side effect — every `shipGate.check()` call writes one audit-log entry** with `action: "ship_gate.checked"` and `resourceType: "ship_gate"` (route.ts:73-87; both strings drift-pinned). SIEM / observability consumers keying off either field for filter setup should depend on both staying stable. Properties of the write:
830
+ - Org-scoped, hash-chained.
831
+ - **Time-blocking** but error-tolerant: the kernel uses `await writeAuditLog(...)` which awaits two DB ops (SELECT previous-hash + INSERT new entry). The check response latency INCLUDES the audit-log write time — a slow audit-log DB will delay every `shipGate.check()` response. Error semantics ARE non-blocking: a write FAILURE does NOT fail the check request.
832
+ - NOT counted against `decisionsPerMonth` quota.
833
+
834
+ Invariant candidate #53 carry-forward (matches `gate.evaluate`'s pattern).
835
+
836
+ **Kernel-side 15-second timeout** (`maxDuration = 15`). **Same as `gate.evaluate`'s 15s; tighter than `auditLog.verifyChain`'s 30s.** Ship-gate's transaction has a SELECT FOR UPDATE + up to 4 follow-up reads + an optional UPDATE on the reconcile path, and the kernel team budgeted 15s as sufficient for the worst case. The SDK does NOT enforce a client-side timeout (consumers manage via `options.signal`); CI pipeline timeouts should budget relative to this cap.
837
+
838
+ **Reconciliation-on-read inside transaction.** When the linked `approval_chain_executions` row has gone terminal but the `ship_gates` row still says `gated`, the kernel advances the gate to the corresponding terminal state inside `SELECT … FOR UPDATE`. The SDK does NOT observe the reconciliation step — only the post-reconciliation shape. A consumer calling `check()` twice in quick succession on a chain that just completed sees the gated-state shape on call 1 (if reconciliation hadn't fired yet) and the terminal shape on call 2. Faithful courier; documented kernel behavior.
839
+
840
+ ```ts
841
+ // Basic ship-gate check (typical CI usage)
842
+ const verdict = await client.shipGate.check({
843
+ systemId: "11111111-1111-1111-1111-111111111111",
844
+ attestationId: "build-1234",
845
+ });
846
+ if (verdict.gated) {
847
+ // Shape C or D — build must block.
848
+ if (verdict.reason === "awaiting_approvers") {
849
+ // Shape D — list pending approvers in PR comment. Fall back
850
+ // gracefully if the array is missing (forward-compat) or empty
851
+ // (kernel emitted no UUIDs for some reason).
852
+ const approvers = verdict.approvers_pending?.join(", ") || "(unknown)";
853
+ console.error(`Awaiting approval from ${approvers}`);
854
+ } else {
855
+ // Shape C — rejected or timed_out.
856
+ console.error(`Build blocked: ${verdict.reason}`);
857
+ }
858
+ process.exit(1);
859
+ }
860
+ // Shape A (no gate) or Shape B (released) — build proceeds.
861
+ console.log("OK to deploy.");
862
+
863
+ // Discriminate Shape A vs Shape B (no-gate vs released)
864
+ const verdict2 = await client.shipGate.check({
865
+ systemId: "11111111-1111-1111-1111-111111111111",
866
+ attestationId: "build-1234",
867
+ });
868
+ if (!verdict2.gated) {
869
+ if (verdict2.state === "released") {
870
+ console.log(`Approved (execution: ${verdict2.executionId})`);
871
+ } else {
872
+ // No state field → Shape A (no gate exists).
873
+ console.log("No gate configured for this build.");
874
+ }
875
+ }
876
+ ```
877
+
878
+ ### `client.abacPolicies`
879
+
880
+ | Method | Wraps | Returns |
881
+ |---|---|---|
882
+ | `list(options?)` | `GET /api/v1/abac-policies` | `Promise<AbacPoliciesListResponse>` |
883
+ | `create(input, options?)` | `POST /api/v1/abac-policies` | `Promise<AbacPolicy>` |
884
+ | `retrieve(id, options?)` | `GET /api/v1/abac-policies/[id]` | `Promise<AbacPolicy>` |
885
+ | `update(id, input, options?)` | `PATCH /api/v1/abac-policies/[id]` | `Promise<AbacPolicy>` |
886
+ | `delete(id, options?)` | `DELETE /api/v1/abac-policies/[id]` | `Promise<AbacPolicy>` |
887
+
888
+ `abacPolicies.list()` returns up to 200 ABAC policies for the caller's org, ordered by `priority` ASC. Eighth non-decisions resource on the SDK; the first method of the five-method `abacPolicies` CRUD cluster (`list` / `create` / `retrieve` / `update` / `delete`).
889
+
890
+ **Dual-auth admin scope.** The kernel route uses `requireSessionOrApiKey(request, { sessionRoles: ["admin"], apiKeyPermissions: [API_KEY_PERMISSIONS.ADMIN] })`. The dual-auth helper routes by request header presence: an `x-api-key` header (even empty-string) takes the api-key path; absent header takes the session path. The SDK's transport always sends `x-api-key`, so the api-key path is the only one reachable from SDK consumers. NOT the first SDK use of dual-auth — `auditLog.export` (session 12) and `decisions.verifyChain` (session 19) already use it. The novelty here is that this is the first SDK CRUD cluster under dual-auth admin.
891
+
892
+ **Status-code surface — 401 AND 403 distinguished.** The kernel returns HTTP **401** for: no `x-api-key` header, empty `x-api-key` header (`""`), invalid key (no matching `apiKeys` row), expired key. The kernel returns HTTP **403** for: a valid api-key in the org whose `permissions` column does NOT include `ADMIN` (error message: `"API key lacks required permission. Required: admin. Key has: ..."`). Pin BOTH branches separately. Verified by reading the dual-auth middleware end-to-end (`src/lib/middleware/auth.ts:96-110` + `src/lib/middleware/permissions.ts:35-66`). Established invariant: **dual-auth admin routes surface BOTH 401 AND 403** — `auditLog.export` shares this exact surface. (Corrected session-22 hostile review #2: the prior "`auditLog.export` returns 401 for both" framing of carry-forward invariant #42 mis-read the kernel test's mocked `AuthError(401)`; the real `requireSessionOrApiKey` middleware returns 403 for the insufficient-permission case.)
893
+
894
+ **No pagination.** `count` is `items.length` (NOT a total org count beyond the materialized page). Server-side `listAbacPolicies` caps at `MAX_POLICIES_PER_ORG_FETCH = 200` (`src/lib/auth/abac-policies.ts:113`). Orgs with >200 policies see only the LOWEST 200 by priority ASC. Documented kernel surface gap — invariant #50 (silent kernel-side truncation enumeration). The SDK does NOT auto-paginate (no cursor anchor exists to follow).
895
+
896
+ **No `writeAuditLog` side effect** — `.list()` is quiet (asymmetric with `gate.evaluate` / `batch.submit` / `shipGate.check` which all write entries). `.create()` writes an `abac_policy.create` entry; `.update()` / `.delete()` write `abac_policy.update` / `abac_policy.delete` entries; `.retrieve()` is also quiet.
897
+
898
+ **`condition` field is a recursive AST** mirroring the kernel grammar (8 leaf ops + 3 compound ops):
899
+ - Leaf ops: `eq` / `ne` / `in` / `notIn` / `exists` / `notExists` / `attrEq` / `attrNe`.
900
+ - Compound ops: `and` / `or` / `not`.
901
+ - Attribute paths are rooted at `principal.<...>` or `resource.<...>` only (server-side `SAFE_PATH_RE` rejects `__proto__` / `constructor` / `prototype`).
902
+ - Server-side validation enforces depth ≤ 8, clauses ≤ 32 per compound, values ≤ 64 per list, total nodes ≤ 1000 per tree. The SDK does NOT re-validate the AST after the kernel returns it (faithful courier on the response side; `.create()` defers condition validation to the server canonical validator).
903
+
904
+ **Wire-shape note — dates are ISO-8601 strings.** The kernel's TypeScript declares `createdAt: Date` / `updatedAt: Date` (Drizzle `timestamp` column), but `NextResponse.json` serializes Dates via `JSON.stringify` → ISO-8601 string. The SDK type is `string` to reflect the wire reality. Both sides drift-pinned independently.
905
+
906
+ **`description` and `createdByUserId` are `string | null`** (NOT `undefined`). The kernel uses `?? null` coalesce server-side; both fields are ALWAYS present on the wire with value `null` when unset.
907
+
908
+ **Response shape** (`AbacPoliciesListResponse`):
909
+
910
+ | Field | Type | Notes |
911
+ |---|---|---|
912
+ | `items` | `AbacPolicy[]` | Up to 200 policies; ordered by `priority` ASC. Empty array on no-policies. |
913
+ | `count` | `number` | Equals `items.length`; NOT a total org count. |
914
+
915
+ **Per-row shape** (`AbacPolicy`):
916
+
917
+ | Field | Type | Notes |
918
+ |---|---|---|
919
+ | `id` | `string` | UUID of the policy row. |
920
+ | `orgId` | `string` | Always the caller's `orgId`. |
921
+ | `name` | `string` | UNIQUE per `(orgId, name)` server-side. |
922
+ | `description` | `string \| null` | Always own-present; `null` when unset. |
923
+ | `resource` | `AbacPolicyResource` | Closed-enum (10 values). |
924
+ | `action` | `AbacPolicyAction` | Closed-enum (5 values). |
925
+ | `effect` | `AbacPolicyEffect` | `"allow"` or `"deny"`. |
926
+ | `condition` | `AbacCondition` | Recursive AST. |
927
+ | `priority` | `number` | Integer [0, 1000]. |
928
+ | `enabled` | `boolean` | Per-policy enable flag. |
929
+ | `createdByUserId` | `string \| null` | UUID; `null` for fixture rows or when creator was deleted. |
930
+ | `createdAt` | `string` | ISO-8601. |
931
+ | `updatedAt` | `string` | ISO-8601. |
932
+
933
+ ```ts
934
+ // List all ABAC policies for the caller's org
935
+ const { items, count } = await client.abacPolicies.list();
936
+ console.log(`${count} policies in this org:`);
937
+ for (const policy of items) {
938
+ console.log(` ${policy.priority} ${policy.effect} ${policy.action} ${policy.resource}: ${policy.name}`);
939
+ }
940
+
941
+ // Inspect a policy's condition AST
942
+ const ownerOnly = items.find((p) => p.name === "owner-only");
943
+ if (ownerOnly && ownerOnly.condition.op === "attrEq") {
944
+ console.log(`Compares ${ownerOnly.condition.left} === ${ownerOnly.condition.right}`);
945
+ }
946
+
947
+ // Branch on compound conditions
948
+ function describe(c: AbacCondition): string {
949
+ switch (c.op) {
950
+ case "and": return `(${c.clauses.map(describe).join(" AND ")})`;
951
+ case "or": return `(${c.clauses.map(describe).join(" OR ")})`;
952
+ case "not": return `NOT (${describe(c.clause)})`;
953
+ case "eq": return `${c.attr} == ${JSON.stringify(c.value)}`;
954
+ case "ne": return `${c.attr} != ${JSON.stringify(c.value)}`;
955
+ case "in": return `${c.attr} IN [${c.values.join(", ")}]`;
956
+ case "notIn": return `${c.attr} NOT IN [${c.values.join(", ")}]`;
957
+ case "exists": return `${c.attr} EXISTS`;
958
+ case "notExists": return `${c.attr} NOT EXISTS`;
959
+ case "attrEq": return `${c.left} == ${c.right}`;
960
+ case "attrNe": return `${c.left} != ${c.right}`;
961
+ }
962
+ }
963
+ ```
964
+
965
+ #### `abacPolicies.create(input, options?)` — create a new ABAC policy
966
+
967
+ `abacPolicies.create()` creates a new ABAC policy in the caller's org and returns the inserted row (HTTP 201).
968
+
969
+ **FIRST SDK route with HTTP 201 success status.** Distinct from the rest of the SDK's 200-OK pattern; the transport unwraps the `{success:true, data}` envelope on any 2xx response so consumers receive the created row directly.
970
+
971
+ **FIRST SDK route with HTTP 409 Conflict.** The `(orgId, name)` unique constraint trips `AbacPolicyNameConflictError` at the DB layer; the kernel maps to 409 with `An ABAC policy named "<name>" already exists in this organization.`. Branch on `err.status === 409` to render a specific "name taken" UX.
972
+
973
+ **FIRST SDK route with three-way 422 fan-out — distinct wire shapes per error class:**
974
+
975
+ 1. **`BodyParseError`** (most common — Zod schema rejection via `parseBody`): `{ success: false, error: "Validation failed.", details: Array<{ path: string, message: string }> }`.
976
+ 2. **`ZodError`** (DEFENSIVE — DEAD on happy path; `parseBody` catches Zod and converts to `BodyParseError`. Arm exists as defense-in-depth): `{ success: false, error: "Validation failed.", details: ZodIssue[] }` (richer — includes `code`, `expected`, `received`).
977
+ 3. **`AbacPolicyValidationError`** (REACHABLE — server-side canonical AST validation): `{ success: false, error: "ABAC policy validation failed: <messages>", details: { errors: string[] } }`. Raised when the condition AST violates depth / clause / value-list / total-node budgets or has unknown ops / malformed attr paths.
978
+
979
+ SDK surfaces all three uniformly as `AttestryAPIError(422)`. Consumers inspect `err.details.details` to discriminate:
980
+
981
+ ```ts
982
+ try {
983
+ await client.abacPolicies.create({...});
984
+ } catch (err) {
985
+ if (err instanceof AttestryAPIError && err.status === 422) {
986
+ // err.details is the FULL parsed wire body; the kernel's inner
987
+ // details field nests one level deep.
988
+ const wireBody = err.details as { details: unknown };
989
+ const inner = wireBody.details;
990
+ if (Array.isArray(inner)) {
991
+ // BodyParseError / ZodError — iterate {path, message} entries.
992
+ } else if (inner && typeof inner === "object" &&
993
+ Array.isArray((inner as {errors?: unknown}).errors)) {
994
+ // AbacPolicyValidationError — iterate AST error strings.
995
+ }
996
+ }
997
+ }
998
+ ```
999
+
1000
+ **FIRST SDK route with PARTIAL Zod pre-validation.** The SDK pre-validates 7 closed-spec fields synchronously (name length, description length-or-null, resource/action/effect closed-enums, priority int+range, enabled boolean) but defers the recursive `condition` AST validation to the kernel's canonical validator. **Fifth SDK route to pre-validate Zod closed-spec rules** (after `check.run`, `gate.evaluate`, `batch.submit`, `shipGate.check`). 422 reaches consumers ONLY via (a) kernel-side rule changes the SDK hasn't synced to, OR (b) condition AST violations.
1001
+
1002
+ **Default-applied fields** (per invariant #52 — SDK OMITS from body when consumer omits; kernel applies its default):
1003
+ - `effect` defaults to `"allow"`.
1004
+ - `priority` defaults to `100`.
1005
+ - `enabled` defaults to `true`.
1006
+ - `description` defaults to `null`.
1007
+
1008
+ Pass these explicitly to override. Pass `null` for description to set it explicitly; the SDK preserves the difference between "omitted" and "explicitly null".
1009
+
1010
+ **`writeAuditLog` side effect — every successful `.create()` call writes one audit-log entry** with `action: "abac_policy.create"` and `resourceType: "abac_policy"`. Audit log is NOT written on failed create (Zod / canonical validation / name conflict all surface BEFORE writeAuditLog).
1011
+
1012
+ **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as `.list()` and `auditLog.export`; looser than `gate.evaluate` / `shipGate.check`'s 15s.
1013
+
1014
+ **Status-code surface — 401 AND 403 distinguished** (same dual-auth admin surface as `.list()`). Pin BOTH branches.
1015
+
1016
+ **Input shape** (`AbacPolicyCreateInput`):
1017
+
1018
+ | Field | Type | Required | Notes |
1019
+ |---|---|---|---|
1020
+ | `name` | `string` | yes | 1-128 chars. UNIQUE per `(orgId, name)`. |
1021
+ | `resource` | `AbacPolicyResource` | yes | Closed-enum (10 values). |
1022
+ | `action` | `AbacPolicyAction` | yes | Closed-enum (5 values). |
1023
+ | `condition` | `AbacCondition` | yes | Recursive AST (validated server-side). |
1024
+ | `description` | `string \| null` | optional | Max 2000 chars. Pass `null` to set explicitly. |
1025
+ | `effect` | `AbacPolicyEffect` | optional | `"allow"` or `"deny"`. Defaults to `"allow"`. |
1026
+ | `priority` | `number` | optional | Integer [0, 1000]. Defaults to `100`. |
1027
+ | `enabled` | `boolean` | optional | Defaults to `true`. |
1028
+
1029
+ ```ts
1030
+ // Create a simple "owner can edit own assessments" policy
1031
+ const policy = await client.abacPolicies.create({
1032
+ name: "owner-can-edit-own",
1033
+ description: "Owners can edit their own assessments.",
1034
+ resource: "assessments",
1035
+ action: "update",
1036
+ effect: "allow",
1037
+ condition: {
1038
+ op: "attrEq",
1039
+ left: "principal.id",
1040
+ right: "resource.ownerId",
1041
+ },
1042
+ priority: 100,
1043
+ enabled: true,
1044
+ });
1045
+ console.log(`Created policy ${policy.id}`);
1046
+
1047
+ // Catch name-conflict (HTTP 409)
1048
+ try {
1049
+ await client.abacPolicies.create({...});
1050
+ } catch (err) {
1051
+ if (err instanceof AttestryAPIError && err.status === 409) {
1052
+ // Show "name taken" UX
1053
+ }
1054
+ }
1055
+
1056
+ // Create with all defaults (kernel applies effect=allow, priority=100,
1057
+ // enabled=true; SDK omits these fields from the body)
1058
+ const minimal = await client.abacPolicies.create({
1059
+ name: "deny-archived-systems-delete",
1060
+ resource: "systems",
1061
+ action: "delete",
1062
+ condition: {
1063
+ op: "and",
1064
+ clauses: [
1065
+ { op: "eq", attr: "resource.archived", value: true },
1066
+ { op: "ne", attr: "principal.role", value: "admin" },
1067
+ ],
1068
+ },
1069
+ });
1070
+ ```
1071
+
1072
+ #### `abacPolicies.retrieve(id, options?)` — retrieve one ABAC policy
1073
+
1074
+ `abacPolicies.retrieve()` fetches one ABAC policy by id from the caller's org and returns the policy row (HTTP 200). `id` is a path parameter — `GET /api/v1/abac-policies/<id>`.
1075
+
1076
+ **FIRST `abacPolicies` method with a UUID path segment.** `.list()` / `.create()` hit the collection path with no segment; `.retrieve()` / `.update()` / `.delete()` take an `id` path parameter.
1077
+
1078
+ **UUID pre-validation.** The SDK pre-validates `id` against `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive) synchronously — a missing / non-string / empty / non-UUID `id` throws `TypeError` BEFORE any fetch is issued. The kernel's own `badId` check would return HTTP 400 `"Invalid policy id."`, but the SDK pre-empts it: that 400 is reachable only via an `as any` cast or a kernel-side id-flavor change. Mirror of `batch.get`.
1079
+
1080
+ **No `encodeURIComponent` / URIError defense on the path segment.** A string matching `UUID_REGEX` is ASCII hex digits + hyphens — URL-safe verbatim, and incapable of producing a lone UTF-16 surrogate — so the validated `id` is interpolated into the path raw. Asymmetric with `decisions.retrieve`, whose free-form `id` needs `encodePathSegment` (path-traversal + URIError defenses).
1081
+
1082
+ **404 surface.** The kernel's `getAbacPolicyById(orgId, id)` returns `null` for a missing id OR a cross-org id (the `eq(orgId)` clause silently filters policies in other orgs); the GET handler maps `null` to `errorResponse("ABAC policy not found.", 404)` — an **inline literal message**. Distinct from `.update()` / `.delete()`'s 404, which is raised by `AbacPolicyNotFoundError` with the id-embedded message `"ABAC policy <id> not found in this organization."`.
1083
+
1084
+ **No `writeAuditLog` side effect** — `.retrieve()` is a quiet read (same as `.list()`).
1085
+
1086
+ **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as `.list()` and `.create()`.
1087
+
1088
+ **Status-code surface — 401 AND 403 distinguished** (same dual-auth admin surface as `.list()` / `.create()`). Pin BOTH branches.
1089
+
1090
+ Errors: `TypeError` (synchronous — invalid `id`; no fetch issued); `AttestryAPIError` with status 429 (rate limit, auto-retried; per-IP key `abac-policies-get:${ip}` against `assessmentLimiter`), 401, 403, 404, 400 (SDK-pre-empted), or 500; `AttestryError` (request aborted, or P2 response-shape failure); `AttestryAPIError` (P3 — wrong Content-Type). The response row is validated by the shared `validateAbacPolicy` (all 13 `AbacPolicy` fields, prototype-pollution-safe).
1091
+
1092
+ ```ts
1093
+ // Retrieve a policy by id
1094
+ const policy = await client.abacPolicies.retrieve(
1095
+ "550e8400-e29b-41d4-a716-446655440000",
1096
+ );
1097
+ console.log(`${policy.effect} ${policy.action} ${policy.resource}: ${policy.name}`);
1098
+
1099
+ // Handle not-found (HTTP 404)
1100
+ try {
1101
+ return await client.abacPolicies.retrieve(id);
1102
+ } catch (err) {
1103
+ if (err instanceof AttestryAPIError && err.status === 404) {
1104
+ return null; // policy doesn't exist (or belongs to another org)
1105
+ }
1106
+ throw err;
1107
+ }
1108
+ ```
1109
+
1110
+ #### `abacPolicies.update(id, input, options?)` — update one ABAC policy
1111
+
1112
+ `abacPolicies.update()` partial-updates one ABAC policy by id and returns the **updated row** — the policy as it exists AFTER the patch is applied (HTTP 200). `id` is a path parameter — `PATCH /api/v1/abac-policies/<id>`.
1113
+
1114
+ **SECOND SDK method using the HTTP `PATCH` verb** (`incidents.update` is the first). The richest method of the cluster — 6 catch arms, a three-way 422 fan-out, 409, 404, and empty-patch pre-validation.
1115
+
1116
+ **Partial update — every input field is optional.** Patch only the fields you want to change; omitted fields keep their current value. The SDK builds the request body from the present-and-not-`undefined` fields only, so an omitted field (or an explicit `field: undefined`) is left out of the body and the kernel leaves that column untouched.
1117
+
1118
+ **Empty-patch pre-validation.** The kernel's `updateAbacPolicySchema` ends in a `.refine()` rejecting a body with NO updatable field (`"PATCH body must include at least one updatable field"`). The SDK pre-rejects an empty patch — `update(id, {})`, an all-`undefined` patch, or a patch carrying ONLY unknown keys — synchronously with a `TypeError` (no fetch issued).
1119
+
1120
+ **`description: null` clears the description.** Passing `description: null` is a valid non-empty patch — the kernel persists `null`. An explicit `description: undefined` is treated as omission (the SDK preserves the "omitted" vs "explicitly null" distinction, same as `.create()`).
1121
+
1122
+ **UUID pre-validation.** Same as `.retrieve()` / `.delete()`: `id` is pre-validated against `UUID_REGEX` synchronously via the shared `assertValidPolicyId` helper — a missing / non-string / empty / non-UUID `id` throws `TypeError` before any fetch. The kernel `badId` 400 is SDK-pre-empted. No `encodeURIComponent` / URIError defense — a validated UUID is interpolated raw.
1123
+
1124
+ **Partial Zod pre-validation.** The closed-spec fields that ARE present are pre-validated synchronously (name length, description length-or-null, resource/action/effect closed-enums, priority int+bounds, enabled boolean); a present `condition` is checked only as a non-null object, deferring the recursive AST grammar to the kernel's canonical validator. Mirror of `.create()`'s partial pre-validation — but every field is optional.
1125
+
1126
+ **Three-way 422 fan-out — same distinct wire shapes as `.create()`:** `BodyParseError` (`details: Array<{path, message}>`), `ZodError` (DEFENSIVE — DEAD on the happy path; `details: ZodIssue[]`), `AbacPolicyValidationError` (canonical AST validation; `details: { errors: string[] }`). SDK surfaces all three uniformly as `AttestryAPIError(422)` — discriminate via `err.details` (see the `.create()` 422 example).
1127
+
1128
+ **HTTP 409 Conflict.** Patching `name` to a value already used by a sibling policy in the org trips the `(orgId, name)` unique constraint → `AbacPolicyNameConflictError` → 409.
1129
+
1130
+ **HTTP 404.** The kernel's `updateAbacPolicy` throws `AbacPolicyNotFoundError` when the `(id, orgId)`-scoped lookup misses (a missing id OR a cross-org id). **The message is id-embedded** — `"ABAC policy <id> not found in this organization."` — same shape as `.delete()`'s 404 (distinct from `.retrieve()`'s inline message).
1131
+
1132
+ **6 named-error catch arms — the LARGEST on the SDK**, in order: `AuthError`, `BodyParseError`, `ZodError`, `AbacPolicyValidationError`, `AbacPolicyNameConflictError`, `AbacPolicyNotFoundError`. Everything else falls to the 500 catchall.
1133
+
1134
+ **`writeAuditLog` side effect** — every successful `.update()` writes one `abac_policy.update` audit-log entry (`resourceType: "abac_policy"`); the entry records the changed field names plus a structured `before`/`after` diff. `await`-ed, error-tolerant, NOT counted against any quota. The audit log is NOT written on a failed update (404 / 409 / 422 surface before the write).
1135
+
1136
+ **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as `.list()` / `.create()` / `.retrieve()` / `.delete()`.
1137
+
1138
+ **Status-code surface — 401 AND 403 distinguished** (same dual-auth admin surface as the other four cluster methods). Pin BOTH branches.
1139
+
1140
+ **Input shape** (`AbacPolicyUpdateInput`) — every field optional; at least one required (empty-patch pre-validation):
1141
+
1142
+ | Field | Type | Notes |
1143
+ |---|---|---|
1144
+ | `name` | `string` | 1-128 chars. UNIQUE per `(orgId, name)` — a collision is HTTP 409. |
1145
+ | `description` | `string \| null` | Max 2000 chars. Pass `null` to CLEAR; `undefined` is omission. |
1146
+ | `resource` | `AbacPolicyResource` | Closed-enum (10 values). |
1147
+ | `action` | `AbacPolicyAction` | Closed-enum (5 values). |
1148
+ | `effect` | `AbacPolicyEffect` | `"allow"` or `"deny"`. |
1149
+ | `condition` | `AbacCondition` | Recursive AST (validated server-side). |
1150
+ | `priority` | `number` | Integer [0, 1000]. |
1151
+ | `enabled` | `boolean` | Per-policy enable flag. |
1152
+
1153
+ Errors: `TypeError` (synchronous — invalid `id`, `input` not a non-null object, a present field is the wrong type / out of range / an unknown closed-enum value, OR an empty patch; no fetch issued); `AttestryAPIError` with status 429 (rate limit, auto-retried; per-IP key `abac-policies-patch:${ip}` against `assessmentLimiter`), 401, 403, 404 (id-embedded message), 409, 422, 400 (SDK-pre-empted), or 500; `AttestryError` (request aborted, or P2 response-shape failure on the updated row); `AttestryAPIError` (P3 — wrong Content-Type). The updated row is validated by the shared `validateAbacPolicy` (all 13 `AbacPolicy` fields, prototype-pollution-safe).
1154
+
1155
+ ```ts
1156
+ // Patch a single field — the rest of the policy is unchanged
1157
+ const updated = await client.abacPolicies.update(
1158
+ "550e8400-e29b-41d4-a716-446655440000",
1159
+ { enabled: false },
1160
+ );
1161
+ console.log(`Policy "${updated.name}" is now ${updated.enabled ? "on" : "off"}`);
1162
+
1163
+ // Clear a description (pass null) and re-prioritize in one patch
1164
+ await client.abacPolicies.update(id, { description: null, priority: 10 });
1165
+
1166
+ // Catch a name-conflict (HTTP 409)
1167
+ try {
1168
+ await client.abacPolicies.update(id, { name: "taken-name" });
1169
+ } catch (err) {
1170
+ if (err instanceof AttestryAPIError && err.status === 409) {
1171
+ // another policy in the org already uses that name
1172
+ }
1173
+ }
1174
+ ```
1175
+
1176
+ #### `abacPolicies.delete(id, options?)` — delete one ABAC policy
1177
+
1178
+ `abacPolicies.delete()` deletes one ABAC policy by id from the caller's org and returns the **deleted row** — the policy as it existed immediately before deletion (HTTP 200). `id` is a path parameter — `DELETE /api/v1/abac-policies/<id>`.
1179
+
1180
+ **Returns the deleted row, NOT `void`.** The kernel's DELETE handler emits `successResponse(row, 200)` carrying the just-deleted `AbacPolicy`, so a caller can log / audit / render an undo affordance with the full prior state. Do NOT expect `Promise<void>` or a `{ deleted: true }` envelope — the resolved value is a complete 13-field `AbacPolicy`.
1181
+
1182
+ **FIRST SDK method using the HTTP `DELETE` verb.** Every prior SDK route is GET / POST / PATCH. The transport's `method` union already includes `"DELETE"`, so no new primitive was needed.
1183
+
1184
+ **UUID pre-validation.** Same as `.retrieve()`: the SDK pre-validates `id` against `UUID_REGEX` (RFC 4122 hyphenated, case-insensitive) synchronously via the shared `assertValidPolicyId` helper — a missing / non-string / empty / non-UUID `id` throws `TypeError` BEFORE any fetch is issued. The kernel's `badId` 400 `"Invalid policy id."` is SDK-pre-empted. **No `encodeURIComponent` / URIError defense** — a validated UUID is ASCII hex + hyphens, interpolated into the path raw (mirror of `batch.get`).
1185
+
1186
+ **404 surface.** The kernel's `deleteAbacPolicy(orgId, id)` throws `AbacPolicyNotFoundError` when the `(id, orgId)`-scoped delete matches zero rows (a missing id OR a cross-org id — the `eq(orgId)` clause scopes the delete). The DELETE handler maps it to `errorResponse(error.message, 404)`. **The message is id-embedded** — `"ABAC policy <id> not found in this organization."` — distinct from `.retrieve()`'s INLINE `"ABAC policy not found."`.
1187
+
1188
+ **`writeAuditLog` side effect — every successful `.delete()` call writes one `abac_policy.delete` audit-log entry** (`resourceType: "abac_policy"`). The entry's `details` records the deleted policy's `name` / `resource` / `action` / `effect` for forensics. The write is org-scoped + hash-chained, `await`-ed (so `.delete()` latency includes the audit write) but error-tolerant (a write failure does NOT fail the request) and is NOT counted against any quota. The audit log is NOT written on a failed delete — a 404 surfaces BEFORE the `writeAuditLog` call.
1189
+
1190
+ **Kernel-side 30-second timeout** (`maxDuration = 30`). Same as `.list()` / `.create()` / `.retrieve()`.
1191
+
1192
+ **Status-code surface — 401 AND 403 distinguished** (same dual-auth admin surface as `.list()` / `.create()` / `.retrieve()`). Pin BOTH branches.
1193
+
1194
+ Errors: `TypeError` (synchronous — invalid `id`; no fetch issued); `AttestryAPIError` with status 429 (rate limit, auto-retried; per-IP key `abac-policies-delete:${ip}` against `assessmentLimiter`), 401, 403, 404 (id-embedded message), 400 (SDK-pre-empted), or 500; `AttestryError` (request aborted, or P2 response-shape failure on the deleted row); `AttestryAPIError` (P3 — wrong Content-Type). The deleted row is validated by the shared `validateAbacPolicy` (all 13 `AbacPolicy` fields, prototype-pollution-safe).
1195
+
1196
+ ```ts
1197
+ // Delete a policy and log the prior state
1198
+ const deleted = await client.abacPolicies.delete(
1199
+ "550e8400-e29b-41d4-a716-446655440000",
1200
+ );
1201
+ console.log(`Deleted "${deleted.name}" (${deleted.effect} ${deleted.action} ${deleted.resource})`);
1202
+
1203
+ // Treat a not-found delete as idempotent success
1204
+ try {
1205
+ await client.abacPolicies.delete(id);
1206
+ } catch (err) {
1207
+ if (err instanceof AttestryAPIError && err.status === 404) {
1208
+ // already gone — fine for an idempotent caller
1209
+ } else {
1210
+ throw err;
1211
+ }
1212
+ }
1213
+ ```
1214
+
1215
+ ## Public enums
1216
+
1217
+ ```ts
1218
+ import {
1219
+ INCIDENT_TYPES,
1220
+ SEVERITIES,
1221
+ FRAMEWORK_CODES,
1222
+ CHAT_MESSAGE_ROLES,
1223
+ DECISION_STREAM_EVENT_TYPES,
1224
+ AUDIT_LOG_EXPORT_FORMATS,
1225
+ REGULATORY_CHANGE_SEVERITIES,
1226
+ REGULATORY_CHANGE_STATUSES,
1227
+ BATCH_JOB_TYPES,
1228
+ BATCH_JOB_STATUSES,
1229
+ } from "@attestry/sdk";
1230
+ import type {
1231
+ IncidentType,
1232
+ Severity,
1233
+ FrameworkCode,
1234
+ ChatMessageRole,
1235
+ DecisionStreamEventType,
1236
+ AuditLogExportFormat,
1237
+ RegulatoryChangeSeverity,
1238
+ RegulatoryChangeStatus,
1239
+ BatchJobType,
1240
+ BatchJobStatusValue,
1241
+ } from "@attestry/sdk";
1242
+
1243
+ // Wire-shape types are also re-exported (e.g., for consumers
1244
+ // writing typed helpers around the SDK output):
1245
+ import type {
1246
+ BatchSubmitInput,
1247
+ BatchSubmitResponse,
1248
+ BatchJobStatus,
1249
+ BatchSystemResult,
1250
+ BatchConfig,
1251
+ } from "@attestry/sdk";
1252
+ ```
1253
+
1254
+ These are duplicated from the kernel intentionally — the SDK's public contract must not depend on internal kernel modules. A drift-detection pin in the kernel asserts the arrays match (any divergence fails CI).
1255
+
1256
+ ## Roadmap
1257
+
1258
+ Tracked for the next release:
1259
+ - `bundles` resource (after evidence-bundle ships)
1260
+ - `webhooks` resource — BLOCKED on a kernel-side prereq: webhook routes today live at `/api/webhooks/*` with `requireAuth` (Supabase-session-only); needs dual-auth + WEBHOOK_MANAGE permission to be SDK-callable.
1261
+ - `apiKeys` resource
1262
+ - Ed25519 signing of `decisions.export` trailers (Prompt 1 — kernel prereq; today the trailer's `signing` field is the literal string `"unsigned-prompt-1-blocked"`)
1263
+ - HTTP-level idempotency-key support → retry on 5xx (kernel prereq)
1264
+ - Browser support
1265
+ - Python SDK port (Checkpoint H prereq)
1266
+
1267
+ ## License
1268
+
1269
+ Apache-2.0