@attestry/sdk
v0.7.0
Published
Official TypeScript SDK for the Attestry compliance kernel — generate and offline-verify sealed EU AI Act Annex IV technical files, submit AI incidents, fetch decision records, and run CI/CD compliance gates.
Maintainers
Readme
@attestry/sdk
Official TypeScript SDK for the Attestry 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.
Status — v0.7.0. Twelve resources shipped — headline:
annexIv(W3, the offline-verifiable EU AI Act Annex IV technical-file path):annexIv.generate(first-bind-only POST minting the sealed, cryptographically bound Annex IV PDF for a signed technical file),annexIv.downloadPdf(the single authenticated call returning the sealed PDF bytes — served from persisted storage, never re-rendered),annexIv.getBind(the bind record: hash, signature, status-list ref, sidecar), plus the bundled offline verifierverifyAnnexIvBindOffline(pure Ed25519/@noblepublic-key verification of the PDF bytes against the bind — no network, no DB, no kernel import; revocation via the signed status list when supplied). TheannexIvcluster and the offline verifier are NEW IN 0.7.0 — their first published release (npm 0.6.0 predates them entirely). The eleven pre-W3 resources: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_caseapprovers_pendingwire 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 HTTPDELETEverb). API is stable inside its surface; new resources are additive. Automatic retry on 429 is on by default.0.5.7 —
abacPoliciesCRUD cluster completed (additive, no breaking changes). TheabacPoliciesresource — attribute-based access-control policy management — reaches its full 5-method surface:list/create(shipped 0.5.6) plusretrieve/update/delete(this release). The SDK's FIRST 5-method CRUD cluster — prior multi-method resources either grew an existing class over many sessions (decisionsreached 7) or shipped a smaller surface. FIRST SDK method on the HTTPDELETEverb (abacPolicies.delete) and SECOND on the HTTPPATCHverb (abacPolicies.update;incidents.updateis the first). The three id-path methods (retrieve/update/delete) pre-validateidagainst a strict RFC 4122 UUID regex and interpolate it into the request path RAW — mirror ofbatch.get, with NOencodeURIComponentand NOURIErrordefense: a string matching the UUID regex is ASCII hex + hyphens, URL-safe verbatim and incapable of forming a lone surrogate, so a malformedidis pre-rejected synchronously (TypeError) before any URL is built (asymmetric withdecisions.retrieve, whose free-form id needsencodePathSegment).abacPolicies.updateis the richest method of the cluster — a 6-arminstanceofcatch block (the LARGEST on the SDK), the three-way 422 fan-out inherited from.create(BodyParseError→Array<{path, message}>/ a defensive DEADZodErrorarm →ZodIssue[]/AbacPolicyValidationError→{errors: string[]}) PLUS HTTP 409 (name conflict) PLUS HTTP 404 (AbacPolicyNotFoundError, an id-embedded message). Empty-patch pre-validation —.updateis a partial update with every field optional; the SDK pre-rejects an empty patch (update(id, {}), an all-undefinedpatch, or a patch carrying only unknown keys) synchronously with aTypeError, mirroring the kernelupdateAbacPolicySchema's.refine()..deletereturns the deleted row (the policy as it existed immediately before deletion — NOTvoid/ NOT a{deleted:true}envelope);.updatereturns the updated row (theafterstate). All 5 methods are dual-auth admin scope — HTTP 401 for no/invalid/expired key, HTTP 403 for a valid key lackingADMIN(both branches distinct — pin separately)..create/.update/.deleteeach write oneabac_policy.*audit-log entry;.list/.retrieveare quiet reads. Symmetric prototype-pollution defense on input AND response sides (Object.hasOwnmodule-load snapshot).0.5.6 —
shipGateresource added (additive, no breaking changes). Single-method resource wrappingPOST /api/v1/ship-gate/check— multi-approver workflow gate that asks "is an in-flight approval chain blocking THIS build?". Distinct fromgate.evaluate— that method is a synchronous compliance-score gate (pass/fail on assessment scores);shipGate.checkhas thegated → released/rejected/timed_outstate machine bound to an approval-chain execution. Variadic 4-shape response withgated: booleanas 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 emptyapprovers_pending); Shape D 6-field (gated awaiting approvers, populatedapprovers_pending). Discriminate viaresult.gated === true(closed-enum boolean, pollution-safe anchor), NOTreason === 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 (aftercheck.run,gate.evaluate, andbatch.submit) — UUID format onsystemId+ length 1-256 onattestationId(matches kernelMAX_ATTESTATION_ID_LENGTH = 256). Multi-permission UNION auth with READ_SYSTEMS FIRST (asymmetric withcheck.runandgate.evaluatewhich listREAD_ASSESSMENTSfirst). TWO distinct cascade-gap surfaces: execution-missing → HTTP 404 (namedShipGateExecutionNotFoundError); chain-missing → HTTP 500 (plainError, scrubbed byinternalErrorResponse).writeAuditLogside effect — every call writes oneship_gate.checkedentry. Kernel-side 15smaxDuration(same asgate.evaluate; tighter thanauditLog.verifyChain's 30s). Symmetric prototype-pollution defense on input AND response sides (Object.hasOwnsnapshot).0.5.5 —
auditLog.verifyChainadded (additive, no breaking changes). Sibling method toauditLog.exporton the same resource class — wrapsGET /api/v1/audit-chain/verifyfor org-wide audit-log hash-chain integrity verification. Distinct fromdecisions.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 onvalid: false— the kernel returns 200 withvalid: falseon tampered chains; the SDK resolves the Promise with the verdict body. First SDK route usingrequireApiKeyDIRECT (no permission scoping) — any valid api-key in the org succeeds; no 403 path. Asymmetric withauditLog.export(which gates on ADMIN role).brokenAtis OPTIONAL — the kernel uses a conditional spread, so the field is an OWN-PROPERTY only on broken chains; consumers detect broken-chain viaresult.valid === false(closed-enum boolean discriminator), NOTresult.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). NOwriteAuditLogside effect (the verifier is quiet — writing while verifying would be ironic). NO input → noTypeErrorfrom SDK boundary; the method takes onlyoptions?: RequestOptions. Symmetric prototype-pollution defense on the response side (input boundary is empty).0.5.4 —
batchresource added (additive, no breaking changes). Multi-method resource wrappingPOST /api/v1/batch(submit) andGET /api/v1/batch/<UUID>(get). First SDK resource with asymmetric auth between methods on the same resource —submit()requiresCLASSIFYORWRITE_ASSESSMENTS(UNION, the FIRST WRITE-side union pair on the SDK);get()requires onlyREAD_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 onsubmit()); the kernel'sPlanLimitErrorwording 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 asAttestryAPIError(403); consumers regex-matchapiErr.messageto 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 (jobTypeclosed-enum 3-string membership,systemIdsarray length [1, 50] + per-element UUID format,config.frameworksarray length ≤20 + per-element string length [1, 100]) — third SDK route to pre-validate aftercheck.runandgate.evaluate. Partial-success contract:submit()resolves successfully (no throw) even when every row failed; consumers branch on per-rowstatus === "success"(closed-enum string match — NOTerrorMessage === undefinedwhich is pollution-unsafe). TWO distinct status enums on response wire-shape family: top-level batch-jobstatus("completed" | "failed"on POST, wider 4-enum"pending" | "processing" | "completed" | "failed"on GET) vs per-rowresults[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 viaas anycasts).writeAuditLogside effect onsubmit()writes onebatch.submittedentry 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.hasOwnsnapshot, mirrors session-16 second-hostile-review MEDIUM #3).0.5.3 —
gateresource added (additive, no breaking changes). WrapsPOST /api/v1/gatewith a Zod-validated body (systemIdUUID + optionalminScoreint 0-100 default 70 + optionalframeworksfilter + optionalfailOnMissingAssessmentboolean default true), multi-permission union auth (READ_ASSESSMENTS or READ_SYSTEMS — same ascheck.run), cross-org systemId collapsed to 404 (LONGER kernel string thancheck.run:"System not found or access denied"), and TWO silent kernel-side truncations (assessments at 10 — TIGHTER thancheck.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-ENUMgate: "pass" | "fail"(NOT a boolean) over THREE emit paths (normal pass/fail + fail-on-missing + pass-on-missing);scoreisnumber | null(NOT defaulted to 0 — asymmetric withcheck.runwhere score=0 was the no-assessment default; gate preserves the null distinction at the type level). Every call writes onegate.checkedaudit log entry (new invariant candidate #53 — SDK documents the side effect). Symmetric prototype-pollution defense on input AND response sides (Object.hasOwnsnapshot, mirrorscheck.run's session-16 second-hostile-review MEDIUM #3 defense).0.5.2 —
checkresource added (additive, no breaking changes). WrapsPOST /api/v1/checkwith a Zod-validated body (systemIdUUID + optionalframeworksfilter), multi-permission union auth (READ_ASSESSMENTS or READ_SYSTEMS), cross-org systemId collapsed to 404 ("System not found", mirror ofdecisions.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 anycasts do NOT bypass them).scoredefaults to 0 (not null) when no completed assessment exists — consumers MUST checklastAssessedAt === nullto distinguish "scored zero" from "no completed assessment yet". Includes prototype-pollution defense on BOTH input field presence AND response field reads (Object.hasOwnsnapshot — 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).0.5.1 —
complianceCheckresource added (additive, no breaking changes). WrapsGET /api/v1/compliance-checkwith 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.hasOwninstead ofin).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 validateArray.isArray+nextCursorshape at the SDK boundary (kernel regressions to scalar/null surface asAttestryErrorinstead of cryptic consumer crashes); syncrequest<T>enforcesContent-Type: application/jsonon 2xx responses (proxy/LB-injected HTML error pages now throwAttestryAPIErrorinstead of soft-failing). The content-type guard is the only consumer-visible behavior change — wrong-content-type responses that previously soft-failed now throw.
Install
npm install @attestry/sdkRequires Node 18+ (uses the global fetch). Browser support is intentionally NOT in v0 — server-side use only.
Quick start
import { AttestryClient } from "@attestry/sdk";
const client = new AttestryClient({
apiKey: process.env.ATTESTRY_API_KEY!,
});
// Submit an AI incident.
const incident = await client.incidents.create({
incidentType: "prompt_injection",
severity: "high",
description: "Customer-facing chatbot leaked an internal system prompt.",
frameworksAffected: ["eu_ai_act", "nist_ai_rmf"],
optInShare: true,
});
// Append a decision record to the system's hash chain.
const record = await client.decisions.ingest({
systemId,
inputDigest: "sha256:abc...",
frameworkClaims: [
{ framework: "eu_ai_act", article: "Art.13", claim: "human oversight provided" },
],
humanOversightState: "approved",
policyOutcome: "permitted",
// Pass an idempotencyKey to make 429-retries safe under network failure.
idempotencyKey: "decision-2026-05-06-trace-789",
});
// Append a batch of records (1-500). Partial-success envelope: the
// call resolves even when some records fail. Inspect `result.failed[]`
// for per-record errors; `code` distinguishes recovery paths
// (e.g., retry idempotency_unique_violation via decisions.ingest).
const result = await client.decisions.bulk({
items: [
{ systemId, inputDigest: "sha256:abc...", idempotencyKey: "trace-001" },
{ systemId, inputDigest: "sha256:def...", idempotencyKey: "trace-002" },
],
});
console.log(`${result.totalInserted}/${result.totalSubmitted} succeeded`);
// Search the cross-tenant corpus for similar patterns.
const { clusters } = await client.incidents.search({
query: "system prompt leak",
limit: 10,
});
// Subscribe to live decision events as they're appended.
for await (const event of client.decisions.stream({ systemId })) {
console.log(event.id, event.sequenceNumber, event.recordHash);
}
// Export the entire chain as NDJSON (records + a Merkle-root trailer).
// The trailer is the LAST frame and commits the export to a single
// hash over per-record `recordHash` leaves.
for await (const frame of client.decisions.export({ systemId })) {
if ("type" in frame && frame.type === "ExportTrailer") {
console.log(`exported ${frame.recordCount} records, root=${frame.merkleRoot}`);
} else {
process(frame); // DecisionListItem shape
}
}
// Verify a system's hash chain integrity. Resolves with the verdict
// body even when chainValid:false — the kernel's answer to "is the
// chain tampered?" is itself a successful response. Branch on
// verdict.chainValid.
const verdict = await client.decisions.verifyChain(systemId);
if (!verdict.chainValid) {
// Two arrays distinguish the failure mode:
// tamperedRecordIds = direct content tampering (security signal)
// brokenRecordIds = sequence gap (ops signal — record missing)
console.error("chain integrity failure", {
tampered: verdict.tamperedRecordIds,
broken: verdict.brokenRecordIds,
verifiedUpTo: verdict.lastVerifiedSequence,
});
}
// Stream the org's audit-log to your SIEM. Default jsonl format yields
// AuditLogRecord rows; `format: "ecs"` yields Elastic Common Schema
// events; `format: "cef"` yields ArcSight CEF v0 lines as raw strings.
// Auto-paginates by default (walks all history newest-first).
//
// Dual-auth admin — the api-key must carry the ADMIN permission;
// 401 for no/invalid/expired key, 403 for a valid key lacking ADMIN.
for await (const row of client.auditLog.export()) {
if (row.action === "api_key_revoked") notifySecurity(row);
}
// List recent regulatory updates filtered by framework / severity.
// Returns a Promise<RegulatoryChange[]> sorted DESC by publishedAt.
// IMPORTANT: when `status` is omitted, the kernel filters dismissed
// rows OUT (default-excludes-dismissed). Pass status: "dismissed" to
// retrieve only dismissed rows.
const recentCritical = await client.regulatoryChanges.list({
framework: "EU_AI_ACT",
severity: "critical",
from: "2026-04-01T00:00:00Z",
limit: 50,
});
for (const change of recentCritical) {
console.log(change.framework, change.severity, change.title);
}Configuration
new AttestryClient({
apiKey: "sk_live_…", // required
baseUrl: "https://attestry.ai", // optional — defaults to prod
timeoutMs: 30_000, // optional — defaults to 30s (NOT applied to streams)
fetch: customFetch, // optional — defaults to globalThis.fetch
retry: { maxRetries: 3 }, // optional — see "Automatic retry" below
});| Option | Default | Notes |
|---|---|---|
| apiKey | required | API key from the Attestry org settings page. Sent as x-api-key. |
| baseUrl | https://attestry.ai | Override for self-hosted, EU residency, or local dev. Trailing slashes are stripped. |
| timeoutMs | 30_000 | Per-request timeout for JSON requests (not streams). Set 0 to disable. |
| fetch | globalThis.fetch | Inject a custom fetch (testing, retries, observability). Must match the standard fetch signature. |
| retry | {maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 30_000, honorRetryAfter: true} | Automatic retry on 429. See "Automatic retry" section below. Set {maxRetries: 0} to disable. |
Construction is fail-fast: a missing API key, missing fetch, or an invalid timeoutMs / retry config throws AttestryError synchronously.
Errors
Two error classes:
import { AttestryClient, AttestryError, AttestryAPIError } from "@attestry/sdk";
try {
await client.incidents.create({ /* … */ });
} catch (err) {
if (err instanceof AttestryAPIError) {
// The API returned a non-2xx response.
console.error(`API ${err.status}: ${err.message}`, err.details);
} else if (err instanceof AttestryError) {
// Network failure, timeout, or aborted request — the call did NOT
// reach the API.
console.error("transport error:", err.message);
} else {
throw err;
}
}AttestryAPIError extends AttestryError extends Error, so a single instanceof AttestryError catches both layers.
Cancellation
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
try {
await client.incidents.search({ query: "long-running" }, { signal: ac.signal });
} catch (err) {
if (err instanceof AttestryError && err.message === "request aborted by caller") {
// user cancelled
}
}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.
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.
Automatic retry
The SDK automatically retries HTTP 429 (Too Many Requests) responses with exponential backoff and full jitter.
Default config: {maxRetries: 3, initialDelayMs: 1_000, maxDelayMs: 30_000, honorRetryAfter: true}.
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.
// Disable client-wide:
const client = new AttestryClient({
apiKey,
retry: { maxRetries: 0 },
});
// Tighten for a latency-sensitive call:
await client.incidents.search(query, {
retry: { maxRetries: 1, initialDelayMs: 200, maxDelayMs: 1_000 },
});
// One-off "do not retry":
await client.incidents.create(input, { retry: { maxRetries: 0 } });| Field | Default | Notes |
|---|---|---|
| maxRetries | 3 | 0 disables. Capped at 100 (config DoS guard). |
| initialDelayMs | 1_000 | Base for exponential schedule. |
| maxDelayMs | 30_000 | Cap on both exponential and Retry-After. |
| honorRetryAfter | true | When false, the SDK ignores the server hint and uses pure exponential. |
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.
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.
Resources
client.incidents
| Method | Wraps | Returns |
|---|---|---|
| create(input, options?) | POST /api/v1/incidents | Incident |
| list(input?, options?) | GET /api/v1/incidents | { items, nextCursor? } |
| update(id, input, options?) | PATCH /api/v1/incidents/:id | Incident |
| search(input, options?) | POST /api/ai/incidents/search | { clusters, count, truncated } |
See src/resources/incidents.ts for the full input/output type shapes.
client.decisions
| Method | Wraps | Returns |
|---|---|---|
| ingest(input, options?) | POST /api/v1/decisions | DecisionRecord |
| bulk(input, options?) | POST /api/v1/decisions/bulk | BulkIngestResult (partial-success envelope) |
| retrieve(id, options?) | GET /api/v1/decisions/:id | DecisionRecord |
| list(input?, options?) | GET /api/v1/decisions | { items: DecisionListItem[], nextCursor: string \| null } |
| stream(input?, options?) | GET /api/v1/decisions/stream (SSE) | AsyncIterable<DecisionStreamEvent> |
| export(input, options?) | GET /api/v1/decisions/export (NDJSON) | AsyncIterable<DecisionExportFrame> |
| verifyChain(systemId, options?) | GET /api/v1/decisions/verify-chain/:systemId | ChainVerificationResult (200 with chainValid:true OR chainValid:false) |
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.
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.
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).
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:
let lastEventId: string | undefined;
try {
for await (const event of client.decisions.stream({ systemId, lastEventId })) {
process(event);
lastEventId = event.eventId; // keep for reconnection
}
} catch (err) {
if (err instanceof AttestryAPIError && err.status === 401) {
// re-auth
} else if (err instanceof AttestryError) {
// network / abort / parser error — wait + reconnect with lastEventId
}
}The SDK does not auto-reconnect — caller controls reconnect timing using lastEventId. Heartbeat frames are silently consumed; consumers see only real events.
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:
for await (const frame of client.decisions.export({ systemId })) {
if ("type" in frame && frame.type === "ExportTrailer") {
// Final commit — verify Merkle root client-side post-Prompt-1.
console.log(frame.recordCount, frame.merkleRoot, frame.signing);
} else {
// Per-record line — DecisionListItem shape (interchangeable with `list()` rows).
process(frame);
}
}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).
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.
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".
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).
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.
client.chat
| Method | Wraps | Returns |
|---|---|---|
| send(input, options?) | POST /api/ai/chat | { message, agent } |
| stream(input, options?) | POST /api/ai/chat (sync, iterator-shaped) | AsyncIterable<ChatStreamChunk> |
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.
client.auditLog
| Method | Wraps | Returns |
|---|---|---|
| export(input?, options?) | GET /api/v1/audit-log/export (NDJSON or text/plain) | AsyncIterable<AuditLogRecord \| unknown \| string> (format-discriminated) |
| verifyChain(options?) | GET /api/v1/audit-chain/verify | Promise<AuditChainVerificationResult> |
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).
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.)
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.
Rows arrive DESC by (timestamp, id) — newest first. Order is preserved across page boundaries.
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.
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.
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).
// Walk all admin events (auto-paginate)
for await (const row of client.auditLog.export()) {
if (row.action === "api_key_revoked") audit(row);
}
// ECS for SIEM ingest (Elastic / Datadog / Logstash):
for await (const event of client.auditLog.export({ format: "ecs" })) {
await elasticIngest(event); // event: unknown — parse via your own ECS schema
}
// CEF for ArcSight / QRadar:
for await (const line of client.auditLog.export({ format: "cef" })) {
await arcsightForward(line); // line: string starting with "CEF:0|Attestry|..."
}auditLog.verifyChain(options?) — org-wide audit-log hash-chain integrity
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.
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.
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).
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.
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).
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.
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).
Response shape (AuditChainVerificationResult): 5 always-present fields plus 1 optional own-property:
| Field | Type | Notes |
|---|---|---|
| valid | boolean | true iff chain intact. Empty logs verify as true (vacuous). |
| entriesVerified | number | Count verified before first broken link; equals totalEntries on valid chain. |
| totalEntries | number | Total entries fetched. Capped at 5000 by silent kernel truncation. |
| firstEntry | string \| null | ISO-8601 UTC of oldest entry. null on empty log. ALWAYS present on the wire. |
| lastEntry | string \| null | ISO-8601 UTC of newest entry. null on empty log. ALWAYS present on the wire. |
| 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). |
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.
// Detect a tampered audit log
const verdict = await client.auditLog.verifyChain();
if (!verdict.valid) {
// brokenAt is an OWN-PROPERTY only on broken chains. TypeScript
// narrows it to `string | undefined`; check before forwarding.
if (verdict.brokenAt) {
await notifySecurity({
entryId: verdict.brokenAt,
verifiedUpTo: verdict.entriesVerified,
totalEntries: verdict.totalEntries,
});
}
}
console.log(`Verified ${verdict.entriesVerified}/${verdict.totalEntries} entries`);
// Schedule periodic verification (cron job)
try {
const verdict = await client.auditLog.verifyChain();
if (!verdict.valid && verdict.brokenAt) {
await pageOncall({ brokenAt: verdict.brokenAt });
}
} catch (err) {
if (err instanceof AttestryAPIError && err.status === 429) {
// Back off — verifier is rate-limited per IP via `audit-chain-verify:${ip}`.
return;
}
throw err;
}client.regulatoryChanges
| Method | Wraps | Returns |
|---|---|---|
| list(input?, options?) | GET /api/v1/regulatory-changes | Promise<RegulatoryChange[]> |
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[]>.
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.
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.
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).
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.)
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.
// Most recent 200 non-dismissed rows (kernel default).
const changes = await client.regulatoryChanges.list();
// Filter to critical EU AI Act updates from the last 30 days.
const critical = await client.regulatoryChanges.list({
framework: "EU_AI_ACT",
severity: "critical",
from: "2026-04-07T00:00:00Z",
limit: 50,
});
// Retrieve only dismissed rows (pass status explicitly — default omits them).
const dismissed = await client.regulatoryChanges.list({ status: "dismissed" });client.complianceCheck
| Method | Wraps | Returns |
|---|---|---|
| check(input, options?) | GET /api/v1/compliance-check | Promise<ComplianceCheckResponse> |
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}.
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.
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).
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".
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.
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.
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.
// Compliance check by system UUID.
const single = await client.complianceCheck.check({
systemId: "11111111-1111-1111-1111-111111111111",
});
console.log(single.systems[0].compliant, single.systems[0].score);
// Compliance check by org name (capped at 100 systems — silently).
const org = await client.complianceCheck.check({
orgName: "Acme Corp",
});
console.log(`${org.systems.length} systems checked at ${org.checkedAt}`);client.check
| Method | Wraps | Returns |
|---|---|---|
| run(input, options?) | POST /api/v1/check | Promise<CheckResponse> |
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.
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.
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).
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).
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.
THREE silent kernel-side truncations (each separately load-bearing — the SDK does NOT mask any of them, faithful courier):
issues—gaps.slice(0, 20)atroute.ts:90. If the latest completed assessment has >20 gaps, the 21st+ are invisible (nototal, nohasMore, no truncation flag).assessmentsrow-population —.limit(100)atroute.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).attestationsrow-population —.limit(50)atroute.ts:100. The kernel reads up to 50 attestation rows and counts active ones. If a system has >50 attestations, theactiveAttestationscount may be UNDERCOUNTED.
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.
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.
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.
// Basic CI/CD check.
const result = await client.check.run({
systemId: "11111111-1111-1111-1111-111111111111",
});
if (result.compliant) {
console.log("OK to deploy — score:", result.score);
} else if (result.lastAssessedAt === null) {
// CRITICAL: score=0 + lastAssessedAt=null means "no completed
// assessment yet" — NOT "failed with score zero". Treat as
// pre-launch, not as a failing grade.
console.warn("No completed assessment yet — gate may need a baseline run");
} else {
console.warn("Compliance gaps:", result.issues);
console.warn("Score:", result.score, "(threshold = 70)");
}
// Filtered by frameworks (OR-overlap, not AND-all-required).
const euOnly = await client.check.run({
systemId: "11111111-1111-1111-1111-111111111111",
frameworks: ["EU_AI_ACT", "ISO_42001"],
});client.gate
| Method | Wraps | Returns |
|---|---|---|
| evaluate(input, options?) | POST /api/v1/gate | Promise<GateResponse> |
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.
Three emit paths. The response shape varies by whether a relevantAssessment was found (kernel route.ts:88-98) and the value of failOnMissingAssessment:
- Path 1 — normal pass/fail (
relevantAssessmentfound): 14 fields.score: number; emit-only fields (assessmentId,assessmentDate,gapCount,criticalGaps,highGaps) all present. - Path 2 — fail-on-missing:
failOnMissingAssessment=true(the default) ANDrelevantAssessmentis falsy. 9 fields.gate: "fail",score: null,gaps: []. Emit-only fields ABSENT (own-property false). - Path 3 — pass-on-missing:
failOnMissingAssessment=falseANDrelevantAssessmentis falsy. 9 fields.gate: "pass",score: null,gaps: []. Emit-only fields ABSENT.
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".
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.
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).
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.
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.
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).
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".
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:
systemId: RFC 4122 hyphenated UUID format (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-...-[0-9a-fA-F]{12}$/, case-insensitive).minScore: integer in[0, 100]inclusive —typeof === "number"+Number.isInteger(already rejects NaN / ±Infinity) + bounds check.failOnMissingAssessment:typeof === "boolean"(rejects truthy/falsy non-booleans like1/"true"/null).frameworks: array, ≤20 elements, each string of length 1-100. Snapshotted viaArray.fromfor TOCTOU defense.
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).
TWO silent kernel-side truncations (each separately load-bearing — the SDK does NOT mask either, faithful courier):
assessmentsrow-population —.limit(10)atroute.ts:85. TIGHTER thancheck.run's.limit(100)— gate is strictly less defensive against many-assessment systems. The kernel reads up to 10 assessment rows bycompletedAtDESC 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).remediationTasksrow-population —.limit(100)atroute.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 finalgaps.lengthmay be less than 100 even at the cap.
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.
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.
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.
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:
- Org-scoped, hash-chained (per
writeAuditLog). - 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 everygate.evaluate()response. Error semantics ARE non-blocking:writeAuditLogwraps its body in a try/catch that swallows + logs errors, so a write FAILURE does NOT fail the gate request. - NOT counted against
decisionsPerMonthqu
