@annexops/sdk
v0.3.0
Published
AnnexOps SDK — zero-dependency, hash-at-source clients for EU AI Act & GDPR: runtime logging (Article 12), consent receipts (Article 7), and private-database schema discovery for data mapping. Raw content and row values never leave your process.
Maintainers
Readme
@annexops/sdk
The client SDK for AnnexOps — log a tamper-evident, hash-only trace of every inference your AI system makes and every consent grant/withdrawal your users make, and map where personal data lives across your databases — all without the underlying content or any row value ever leaving your process.
Engineering documentation, not legal advice. AnnexOps does not certify conformity. Keeping these records supports (but does not by itself discharge) your EU AI Act Article 12 record-keeping and GDPR Article 7 consent-recording obligations — whether they satisfy Article 12/7 for your system is a determination for you and your counsel.
- Hashes only, schema only —
AILoggercomputesSHA-256(input)/SHA-256(output);ConsentLoggercomputesSHA-256(subjectId)/SHA-256(consentText);SchemaScannerreads only column names and types frominformation_schema. All at source; raw content and row values are never transmitted. - Zero runtime dependencies —
node:crypto+ the globalfetch. SchemaScanner— map where personal data lives in a private database AnnexOps can't reach directly: run it inside your own network to push schema (table/column names + declared types only, never a row value) into your data map.- Batched, retrying, idempotent — the loggers buffer and flush automatically; retries are safe by construction.
This is the TypeScript/Node client. The Python mirror (
annexops_sdk) and Go mirror offer the same wire contracts; any other language can POST directly to the same endpoints.
Install
npm install @annexops/sdkRequires Node.js ≥ 20. ESM.
Quickstart — AILogger (runtime inference log, Article 12)
Wrap your inference call in a few lines:
import { AILogger } from '@annexops/sdk';
const logger = new AILogger({
apiKey: process.env.ANNEXOPS_API_KEY!, // mint in AnnexOps → Settings → API keys
systemKey: 'my-model-v1', // a name you choose per AI system
});
// per inference — pass the RAW text; the SDK hashes it at source and discards it
logger.logInference({ input: prompt, output: response, modelVersion: 'gpt-4o', confidence: 0.98 });
// flush on graceful shutdown
await logger.close();That is the complete integration. logInference() is non-blocking; events buffer locally and flush automatically every 5 seconds (or when 100 accumulate). Always call logger.close() on shutdown to flush the remainder.
What arrives in AnnexOps
Each event is POSTed to the AnnexOps ingest endpoint and appended to a per-(org, system_key) SHA-256 hash chain — the tamper-evident Article 12 record. In the AnnexOps portal (Runtime Logs), signed in, you map each system_key to a registered AI system, browse events, check chain integrity, and export the Article 12 record. Verification and export happen in the portal, never via the SDK or your API key.
Configuration
| Option | Default | Description |
|---|---|---|
| apiKey | — (required) | Your ak_live_… key. Never logged or echoed in errors. |
| baseUrl | https://annex-ops.vercel.app/api/v1 | Override for a custom domain. |
| systemKey | — | Client-level default; overridable per logInference() call. |
| flushAt | 100 | Buffer size that triggers an automatic flush (hard-clamped to 500). |
| flushIntervalMs | 5000 | Interval-flush cadence in ms. 0 disables the timer. |
| maxRetries | 3 | Retry attempts after the initial POST (4 total). |
| backoffBaseMs | 500 | Full-jitter backoff base (ms). |
| backoffCapMs | 30000 | Full-jitter backoff cap (ms). |
| requestTimeoutMs | 10000 | Per-request timeout (ms). |
| fetch | globalThis.fetch | Injectable transport — useful for tests or proxies. |
| onRejected | — | Callback for per-event server-side rejections. |
| onError | — | Callback for transport/HTTP errors after retries are exhausted. |
Hashes only, never content
The SDK computes input_hash and output_hash at source using SHA-256. Raw prompt and response text never leave your process — only lowercase 64-char hex digests are sent. If you'd rather hash yourself:
import { sha256Hex } from '@annexops/sdk';
logger.logInference({ inputHash: sha256Hex(prompt), outputHash: sha256Hex(response) });Quickstart — ConsentLogger (GDPR consent receipts, Article 7)
ConsentLogger is the sibling client for a customer's own cookie banner / consent UI: it POSTs batched, subject-hashed grant/withdraw receipts, appended to a tamper-evident chain that is your org's GDPR Article 7 consent record.
import { ConsentLogger } from '@annexops/sdk';
const consentLogger = new ConsentLogger({
apiKey: process.env.ANNEXOPS_API_KEY!, // the same ak_live_… key as AILogger
purposeKey: 'marketing-emails', // a name you choose per consent purpose
});
// per consent event — pass the RAW subject id and notice text; the SDK
// hashes both at source and discards them
consentLogger.logConsent({
subjectId: user.email, // or any stable identifier you use
action: 'grant',
consentText: cookieBannerNoticeText, // the exact text the subject was shown
consentVersion: 'v3',
});
// a withdrawal doesn't need consentText — it isn't restating what was withdrawn from
consentLogger.logConsent({ subjectId: user.email, action: 'withdraw' });
// flush on graceful shutdown
await consentLogger.close();Subject identity is hashed differently from consent text. subjectId (an email, a user id, a device id — usually low-entropy) is hashed at source with SHA-256 and then, server-side, run through a second HMAC step with a secret pepper AnnexOps holds — so a stored value can't be reversed by hashing a list of candidate emails yourself. consentText (a paragraph of notice copy — high-entropy) is a bare SHA-256, the same treatment AILogger gives your prompts/responses. Your app never needs to know this distinction: logConsent() handles it.
If you'd rather hash the subject id yourself before it reaches your own logging code, pass subjectHash (and/or consentTextHash) instead of the raw value — same mutual-exclusivity rule as AILogger's input/inputHash.
What arrives in AnnexOps
Each receipt is appended to a per-(org, purpose_key) SHA-256 hash chain. In the AnnexOps portal (Consent), signed in, you map each purpose_key to a registered consent purpose, browse receipts, check chain integrity, and see the derived opt-in count/rate. Verification happens in the portal, never via the SDK or your API key.
Configuration
Same shape as AILogger's (apiKey, baseUrl, flushAt, flushIntervalMs, maxRetries, backoffBaseMs, backoffCapMs, requestTimeoutMs, fetch, onRejected, onError), with purposeKey in place of systemKey — see the table above; every default is identical.
Quickstart — SchemaScanner (data-map schema push)
SchemaScanner reads the column metadata of one of your databases — schema, table, and column names plus their declared types — and pushes it to AnnexOps, where it is classified into a data map. Only names and types are read; no row is ever queried, and no data value ever leaves your process. The query is a fixed information_schema read the SDK owns — you never pass SQL.
You bring your own driver (pg or mysql2); the SDK bundles none. Give it a runner that executes exactly the SQL it hands you and returns the rows:
import { SchemaScanner } from '@annexops/sdk';
import { Client } from 'pg'; // your own dependency
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
const scanner = new SchemaScanner({
apiKey: process.env.ANNEXOPS_API_KEY!, // the same ak_live_… key as the loggers
storeKey: 'prod-postgres', // a stable name you choose per database
source: 'postgres', // 'postgres' | 'mysql'
storeName: 'Production Postgres', // optional label
});
// the runner runs exactly the SQL passed to it — the SDK never interpolates
const result = await scanner.scanAndPush((sql) => db.query(sql).then((r) => r.rows));
await db.end();
console.log(`Pushed ${result.elements_ingested} columns; ${result.classifications} classified.`);For MySQL, pass source: 'mysql' and a mysql2 runner:
import mysql from 'mysql2/promise'; // your own dependency
const conn = await mysql.createConnection(process.env.MYSQL_URL!);
const scanner = new SchemaScanner({ apiKey, storeKey: 'prod-mysql', source: 'mysql' });
await scanner.scanAndPush((sql) => conn.query(sql).then(([rows]) => rows as any[]));
await conn.end();scan(runner) and push() are also available separately if you want to inspect the buffered schema between the two. A very large schema is truncated at 50 000 columns and reported as completeness: 'partial'.
What arrives in AnnexOps
Each column becomes a schema element (name + type) classified into a data category, and feeds your Record of Processing Activities (Article 30) and DSAR fulfilment automatically. In the AnnexOps portal (Data Stores), signed in, you review the discovered stores, their columns, and the personal-data classification; special-category (Article 9) and criminal-offence (Article 10) columns are flagged for a human to confirm. The scan uses a read-only connection and reads information_schema only — grant the scanning credential the least privilege that lets it read the catalog and nothing more.
Python: the same
SchemaScanneris mirrored in the stdlib-onlyannexops_sdkpackage —SchemaScanner(api_key=..., store_key=..., source=...),scan_and_push(runner), with apsycopg/pymysqlrunner.
Configuration
| Option | Default | Description |
|---|---|---|
| apiKey | — (required) | Your ak_live_… key. Never logged or echoed in errors. |
| storeKey | — (required) | Stable identifier for this database — resolves-or-creates the store on first push. |
| source | — (required) | 'postgres' or 'mysql' — selects the fixed introspection query. |
| storeName | — | Optional human-readable label, set on first push. |
| baseUrl | https://annex-ops.vercel.app/api/v1 | Override for a custom domain. |
| maxRetries | 3 | Retry attempts after the initial POST (4 total). |
| backoffBaseMs | 500 | Full-jitter backoff base (ms). |
| backoffCapMs | 30000 | Full-jitter backoff cap (ms). |
| requestTimeoutMs | 10000 | Per-request timeout (ms). |
| fetch | globalThis.fetch | Injectable transport — useful for tests or proxies. |
Retries & idempotency
Both loggers retry network errors, timeouts, 5xx, and 429 with full-jitter exponential backoff (base 500 ms, cap 30 s, 3 retries). Neither retries a 401 or 400 — those are permanent for that batch. Every retry re-sends the identical batch with the same event_ids (generated once at log time), so duplicate delivery is safe: the server de-duplicates per stream ((org, system_key, event_id) for AILogger; (org, purpose_key, event_id) for ConsentLogger).
Flush & close
The buffer flushes when it reaches flushAt, every flushIntervalMs (the timer is unref()'d, so it never holds your process open), on an explicit await logger.flush(), and on await logger.close().
Always await logger.close() on graceful shutdown to avoid losing buffered events:
process.on('SIGTERM', async () => {
await logger.close();
await consentLogger.close();
process.exit(0);
});flush() and close() resolve with { accepted: number; rejected: IngestRejection[] }. Permanently invalid events (field-level validation failures) appear in rejected and are never retried; valid events in the same batch are still accepted.
Key handling
Mint an API key in AnnexOps → Settings → API keys. The full key (ak_live_…) is shown exactly once — copy it then; it cannot be retrieved again. The same key works for both AILogger and ConsentLogger — there is nothing extra to mint.
- Store it in an environment variable (e.g.
ANNEXOPS_API_KEY); pass it as theapiKeyoption. - Never commit it — the
ak_live_prefix is a recognizable secret pattern that scanners flag. - Rotate by minting a new key, updating your environment, then revoking the old one (revoked keys return
401immediately).
Documentation
Full integration guide — including a language-agnostic HTTP contract for services not on JS/TS: annex-ops.vercel.app/docs/annexops-runtime-logger-guide.pdf
License
MIT © AnnexOps
