@nexart/ai-execution
v0.14.0
Published
AI Execution Integrity — tamper-evident records and Certified Execution Records (CER) for AI operations
Maintainers
Readme
@nexart/ai-execution v0.14.0
Tamper-evident records and Certified Execution Records (CER) for AI operations, project bundles for grouping multiple certified executions into portable, self-verifiable evidence artifacts, and browser-safe async verification for all verification functions.
Version Information
| Component | Version | |---|---| | Service | — | | SDK | 0.14.0 | | Protocol | 1.2.0 |
Why Not Just Store Logs?
Logs tell you what happened. CERs prove integrity. A log entry can be edited, truncated, or fabricated after the fact with no way to detect it. A CER bundle is cryptographically sealed: any modification to hashed fields — including input, output, parameters, and any recorded chain/tool evidence — invalidates the certificate hash. If you need to demonstrate to an auditor, regulator, or downstream system that a recorded execution has not been modified post-hoc, logs are insufficient. CERs provide the tamper-evident chain of custody that logs cannot. CERs certify records, not model determinism or provider execution.
What This Does
This package creates integrity records for AI executions. Every time you call an AI model, it captures:
- What you sent (input + prompt)
- What you got back (output)
- The exact parameters used (temperature, model, etc.)
- SHA-256 hashes of everything for tamper detection
- Optionally: upstream context signals from other systems, sealed into the same certificate hash (v0.10.0+)
These records can be verified offline to detect any post-hoc modification and prove integrity of the recorded execution.
Important: This does NOT promise that an AI model will produce the same output twice, and it does not verify provider or model identity. LLMs are not deterministic. This package provides integrity and auditability — proof that the recorded input, output, and parameters have not been modified, and chain-of-custody for the execution record.
Compatibility Guarantees
- v0.1.0, v0.2.0, and v0.3.0 bundles verify forever. Any CER bundle produced by any prior version will pass
verify()in v0.4.2 and all future versions. - Hashing rules are frozen for
cer.ai.execution.v1. The canonicalization, SHA-256 computation, and certificate hash inputs (bundleType, version, createdAt, snapshot) are unchanged. - New optional snapshot fields (runId, stepId, stepIndex, etc.) default to undefined and are excluded from legacy snapshots. They participate in the certificate hash only when present.
- Canonicalization is frozen for v1. Number-to-string conversion uses
JSON.stringify(), which is consistent across JavaScript engines but does not implement RFC 8785 (JCS) for edge cases like-0. If stricter canonicalization is required, it will ship as a new bundle type (cer.ai.execution.v2), never as a modification to v1.
Installation
npm install @nexart/ai-executionQuick Start
Single Decision (3 lines)
import { certifyDecision } from '@nexart/ai-execution';
const cer = certifyDecision({
provider: 'openai',
model: 'gpt-4o',
prompt: 'Summarize.',
input: userQuery,
output: llmResponse,
parameters: { temperature: 0.7, maxTokens: 1024, topP: null, seed: null },
});
console.log(cer.certificateHash); // "sha256:..."Manual Snapshot + Seal
import { createSnapshot, sealCer, verify } from '@nexart/ai-execution';
const snapshot = createSnapshot({
executionId: 'exec-001',
provider: 'openai',
model: 'gpt-4o',
prompt: 'You are a helpful assistant.',
input: 'What is 2+2?',
parameters: { temperature: 0.7, maxTokens: 1024, topP: null, seed: null },
output: 'The answer is 4.',
});
const bundle = sealCer(snapshot);
const result = verify(bundle);
console.log(result.ok); // trueAgentic Multi-Step Workflow
import { RunBuilder } from '@nexart/ai-execution';
const run = new RunBuilder({ runId: 'analysis-run', workflowId: 'data-pipeline' });
run.step({
provider: 'openai', model: 'gpt-4o',
prompt: 'Plan the analysis.',
input: 'Analyze Q1 sales data.',
output: 'I will: 1) load data, 2) compute totals, 3) summarize.',
parameters: { temperature: 0.3, maxTokens: 512, topP: null, seed: null },
});
run.step({
provider: 'openai', model: 'gpt-4o',
prompt: 'Execute step 1.',
input: 'Load and total Q1 data.',
output: 'Total revenue: $1.2M.',
parameters: { temperature: 0.3, maxTokens: 512, topP: null, seed: null },
});
const summary = run.finalize();
// { runId, stepCount: 2, steps: [...], finalStepHash: "sha256:..." }Attest to NexArt Attestation Node (optional)
import { certifyDecision, attest } from '@nexart/ai-execution';
const cer = certifyDecision({ /* ... */ });
const proof = await attest(cer, {
nodeUrl: 'https://node.nexart.io',
apiKey: process.env.NEXART_API_KEY!,
});
console.log(proof.attestationId);Attestation verifies internal integrity only. It does not re-run the model. The node confirms the bundle's hashes are consistent and returns an independently verifiable signed receipt (when the node is configured for signing).
Archive (Export / Import)
import { exportCer, importCer } from '@nexart/ai-execution';
const json = exportCer(bundle); // canonical JSON string
const restored = importCer(json); // parse + verify (throws on tamper)Snapshot Format (ai.execution.v1)
Required vs Optional Fields
| Field | Required | Type | Notes |
|---|---|---|---|
| executionId | Yes | string | Caller-supplied unique ID |
| provider | Yes | string | e.g. "openai", "anthropic" |
| model | Yes | string | e.g. "gpt-4o" |
| prompt | Yes | string | System prompt |
| input | Yes | string \| object | User input (text or structured) |
| output | Yes | string \| object | Model output (text or structured) |
| parameters.temperature | Yes | number | Must be finite |
| parameters.maxTokens | Yes | number | Must be finite |
| timestamp | Optional | string | ISO 8601; defaults to new Date().toISOString() |
| modelVersion | Optional | string \| null | Defaults to null |
| parameters.topP | Optional | number \| null | Defaults to null |
| parameters.seed | Optional | number \| null | Defaults to null |
| sdkVersion | Optional | string \| null | Defaults to "0.8.0" |
| appId | Optional | string \| null | Defaults to null |
| runId | Optional | string \| null | Workflow run ID |
| stepId | Optional | string \| null | Step identifier within a run |
| stepIndex | Optional | number \| null | 0-based step position |
| workflowId | Optional | string \| null | Workflow template ID |
| conversationId | Optional | string \| null | Conversation/session ID |
| prevStepHash | Optional | string \| null | certificateHash of previous step |
| toolCalls | Optional | ToolEvent[] | v0.7.0+ tool/dependency evidence records. Included in certificateHash when present. See Level 4 section. |
Auto-generated fields (set by createSnapshot, do not set manually): type, protocolVersion, executionSurface, inputHash, outputHash.
CER Bundle Format
{
"bundleType": "cer.ai.execution.v1",
"certificateHash": "sha256:...",
"createdAt": "2026-02-12T00:00:00.000Z",
"version": "0.1",
"snapshot": { "..." : "..." },
"context": {
"signals": [
{ "type": "approval", "source": "github-actions", "step": 0,
"timestamp": "2026-02-12T00:00:00.000Z", "actor": "alice",
"status": "ok", "payload": { "pr": 42 } }
]
},
"meta": { "source": "my-app", "tags": ["production"] },
"declaration": {
"stabilitySchemeId": "nexart-cer-v1",
"protectedSetId": "ai.execution.v1.full",
"protectedFields": ["snapshot", "bundleType", "version", "createdAt"],
"notes": "optional free text"
}
}context is optional and only appears when signals are supplied. Bundles without context are structurally identical to all prior versions.
Certificate Hash Computation
The certificateHash is SHA-256 of the UTF-8 bytes of the canonical JSON of exactly: { bundleType, version, createdAt, snapshot } — or, when context signals are present: { bundleType, version, createdAt, snapshot, context }. meta and declaration are always excluded. Key-ordering is recursive. This computation is identical across all SDK versions.
Context and backward compatibility: when no signals are provided (or an empty array is passed), context is omitted from the hash payload entirely. The resulting certificateHash is byte-for-byte identical to a pre-v0.10.0 bundle with the same snapshot. Passing signals is strictly additive — it can only extend the hash, never break it for existing callers.
Declaration Block (v0.7.0+)
The optional declaration field is a self-describing metadata block for AIEF-02 conformance. It carries stabilitySchemeId, protectedSetId, and protectedFields so that verifiers can confirm which fields are covered by the certificate hash without side-channel knowledge.
declaration is excluded from certificateHash by design. It is purely informational — mutating it does not invalidate the bundle. Pass it via sealCer(snapshot, { declaration: { ... } }).
Verifiers MUST treat declaration as advisory. For cer.ai.execution.v1, the protected set is defined by the bundleType semantics: { bundleType, version, createdAt, snapshot } is what is hashed.
const bundle = sealCer(snapshot, {
declaration: {
stabilitySchemeId: 'nexart-cer-v1',
protectedSetId: 'ai.execution.v1.full',
protectedFields: ['snapshot', 'bundleType', 'version', 'createdAt'],
},
});
// verifyCer(bundle).ok === true — declaration does not affect the resultCER Packages (v0.12.0+)
A CER package is a transport/export envelope that wraps a sealed cer.ai.execution.v1 bundle with optional receipt, signature, and attestation metadata.
{
"cer": { ...sealed cer.ai.execution.v1 bundle... },
"receipt": { ...optional attestation receipt... },
"signature": "base64url...",
"attestation": { ...optional attestation summary... },
"verificationEnvelope": { ...optional envelope metadata... },
"verificationEnvelopeSignature": "base64url..."
}When to use package helpers vs raw bundle helpers
| Use case | Recommended API |
|---|---|
| Creating, verifying, or archiving a CER locally | certifyDecision, verifyCer, exportCer / importCer (raw bundle) |
| Wrapping a CER for transport or external storage with optional metadata | createCerPackage, exportCerPackage, importCerPackage |
| Detecting whether an object is a package or a raw bundle | isCerPackage |
| Extracting the CER from a received package | getCerFromPackage |
| Verifying integrity of a received package's inner CER | verifyCerPackage |
The cer field inside a package is authoritative. Package helpers never mutate it, never re-hash it, and never inject package-level fields into the CER.
Creating and exporting a package
import { certifyDecision, createCerPackage, exportCerPackage } from '@nexart/ai-execution';
const cer = certifyDecision({
provider: 'openai',
model: 'gpt-4o',
prompt: 'Summarize.',
input: userQuery,
output: llmResponse,
parameters: { temperature: 0.7, maxTokens: 1024, topP: null, seed: null },
});
const pkg = createCerPackage({
cer,
receipt: myAttestationReceipt, // optional
signature: 'base64url...', // optional
});
const json = exportCerPackage(pkg); // stable canonical JSONImporting and verifying a package
import { importCerPackage, verifyCerPackage, getCerFromPackage } from '@nexart/ai-execution';
// importCerPackage parses + validates shape + verifies inner CER hash
const pkg = importCerPackage(receivedJsonString); // throws CerVerificationError on failure
// Alternatively: parse yourself then verify
const result = verifyCerPackage(parsedObj);
if (!result.ok) throw new Error(result.errors.join('; '));
const cer = getCerFromPackage(parsedObj); // throws if not a valid packageDesign constraints of package helpers:
- Additive only — no changes to CER hashing, canonicalization, or
verifyCer()semantics verifyCerPackageonly verifies the innercerbundle; receipt/signature/envelope fields are opaque transport metadata not verified here- All existing raw bundle flows (
importCer,exportCer,verifyCer) are unchanged
Attestation
Endpoint: POST {nodeUrl}/api/attest
- Authorization:
Bearer {apiKey} - Body: the full CER bundle as JSON (auto-sanitized via
sanitizeForAttestationin v0.4.0+) - Returns:
AttestationResultwithattestationId,nodeRuntimeHash,certificateHash,protocolVersion - Default timeout: 10 seconds (configurable via
timeoutMs) - Validates: response
certificateHashmatches submitted bundle; all hashes insha256:<64hex>format - Throws:
CerAttestationErroron mismatch, network error, timeout, or HTTP error
Attestation verifies internal integrity only. It does not re-run the model or validate the correctness of the AI output.
Attestation Receipt
After a successful attestation, you get a normalized AttestationReceipt:
type AttestationReceipt = {
attestationId: string;
certificateHash: string; // sha256:...
nodeRuntimeHash: string; // sha256:...
protocolVersion: string;
nodeId?: string;
attestedAt?: string; // ISO 8601
attestorKeyId?: string; // kid of the signing key (v0.5.0+)
signatureB64Url?: string; // base64url Ed25519 signature (v0.5.0+)
};Recommended one-call integration:
import { certifyAndAttestDecision } from '@nexart/ai-execution';
const { bundle, receipt } = await certifyAndAttestDecision(params, {
nodeUrl: 'https://my-node.example.com',
apiKey: process.env.NODE_API_KEY!,
});
// bundle is the sealed CER, receipt is the normalized attestation proofSkip re-attestation when already attested:
import { attestIfNeeded, getAttestationReceipt } from '@nexart/ai-execution';
const { receipt } = await attestIfNeeded(bundle, options);
// or just read without network call:
const receipt = getAttestationReceipt(bundle); // null if not yet attestedgetAttestationReceipt(bundle)— extracts a normalized receipt from any supported shape (top-level fields orbundle.meta.attestation); returnsnullif required fields are missing, never throwsattestIfNeeded(bundle, options)— skips the node call if a valid receipt is already present; prevents double-attestationcertifyAndAttestDecision(params, options)— recommended one-call integration:certifyDecision+attest+ normalized receipt
Signed Receipt Verification (v0.5.0+)
After a node signs the receipt, verify it offline without a round-trip:
import {
verifyNodeReceiptSignature,
verifyBundleAttestation,
fetchNodeKeys,
selectNodeKey,
} from '@nexart/ai-execution';
// One-call: fetches node keys, selects correct key, verifies Ed25519 signature
const result = await verifyBundleAttestation(bundle, {
nodeUrl: 'https://my-node.example.com',
});
// result.ok === true → signature is valid
// result.code → CerVerifyCode enum value
// result.details → string[] with failure explanation (when ok=false)Verify against a specific key directly:
const result = await verifyNodeReceiptSignature({
receipt: { attestationId: '...', certificateHash: 'sha256:...', ... },
signatureB64Url: 'aDEKyu...Q',
key: { jwk: { kty: 'OKP', crv: 'Ed25519', x: '<base64url pubkey>' } },
// or: key: { rawB64Url: '<base64url raw 32 bytes>' }
});Failure codes:
| Code | Meaning |
|---|---|
| ATTESTATION_MISSING | No signed receipt in bundle |
| ATTESTATION_KEY_NOT_FOUND | kid not found in node keys document |
| ATTESTATION_INVALID_SIGNATURE | Ed25519 signature did not verify |
| ATTESTATION_KEY_FORMAT_UNSUPPORTED | Key cannot be decoded (wrong crv, no fields, etc.) |
Node keys document is fetched from {nodeUrl}/.well-known/nexart-node.json. See SPEC.md for the full shape.
Sanitization and Redaction
sanitizeForAttestation(bundle) returns a JSON-safe deep clone:
- Removes keys with
undefinedvalues at all nesting levels - Rejects
BigInt, functions, and symbols (throws) - Safe to serialize with
JSON.stringifyor canonical JSON
Recommended redaction pattern: delete keys or set them to null — never set to undefined, which is not valid JSON. Call sanitizeForAttestation before archiving or attesting if your bundle may contain undefined values.
Skip re-attestation: use hasAttestation(bundle) to check if a bundle already includes attestation fields before calling attest() again.
AIEF Interop (v0.7.0+)
verifyAief(bundle) is an adapter over the existing verifyCer() / verify(). It returns the exact output shape required by AIEF §9.1 for cross-vendor verifier interoperability. The internal verify() return value is unchanged — verifyAief is purely additive.
import { verifyAief } from '@nexart/ai-execution';
const result = verifyAief(bundle);
// {
// result: 'PASS' | 'FAIL',
// reason: string | null, // null on PASS; AIEF §9.2 reason string on FAIL
// checks: {
// schemaSupported: boolean, // false if SCHEMA_ERROR or CANONICALIZATION_ERROR
// integrityValid: boolean, // false if any hash mismatch
// protectedSetValid: boolean, // false if any hash mismatch (mirrors integrityValid for v1)
// chainValid: boolean, // false only if CHAIN_BREAK_DETECTED
// },
// notes?: string[], // failure detail strings when available
// }What we map / what we don't rename:
| NexArt CerVerifyCode | AIEF reason |
|---|---|
| OK | ok (reason is null) |
| CERTIFICATE_HASH_MISMATCH | integrityProofMismatch |
| INPUT_HASH_MISMATCH | integrityProofMismatch |
| OUTPUT_HASH_MISMATCH | integrityProofMismatch |
| TOOL_OUTPUT_HASH_MISMATCH | integrityProofMismatch |
| SCHEMA_ERROR | unsupportedSchema |
| CANONICALIZATION_ERROR | malformedArtifact |
| INVALID_SHA256_FORMAT | malformedArtifact |
| UNKNOWN_ERROR | malformedArtifact |
| INCOMPLETE_ARTIFACT | incompleteArtifact |
| TOOL_EVIDENCE_MISSING | incompleteArtifact |
| CHAIN_BREAK_DETECTED | chainBreakDetected |
| VERIFICATION_MATERIAL_UNAVAILABLE | verificationMaterialUnavailable |
| ATTESTATION_MISSING | verificationMaterialUnavailable |
| ATTESTATION_KEY_NOT_FOUND | verificationMaterialUnavailable |
| ATTESTATION_KEY_FORMAT_UNSUPPORTED | verificationMaterialUnavailable |
| ATTESTATION_INVALID_SIGNATURE | signatureInvalid |
The AIEF reason strings are not renamed or mapped to NexArt-specific vocabulary — they are passed through verbatim for AIEF conformance. Per AIEF §9.0 rule #7, chainValid is true when chain fields are absent from the snapshot.
mapToAiefReason(code: string): string is also exported if you need to convert a CerVerifyCode to an AIEF reason string directly. Unknown codes fall back to "malformedArtifact".
Level 4 (Optional): Chain + Tool Evidence (v0.7.0+)
If you do nothing, nothing changes. All Level 4 features are additive and opt-in. Existing bundles without
toolCallsverify identically.
Tool Calls
Add a toolCalls array to the snapshot to record evidence of external tool or dependency invocations. When present, toolCalls is included in the certificateHash computation — tool evidence is part of the sealed record.
toolCalls provide tamper-evident evidence of what was recorded about a tool/dependency call. They do not prove the external tool actually executed unless the tool itself provides independent verifiable proof (e.g., its own signed receipt).
import { makeToolEvent, createSnapshot, sealCer } from '@nexart/ai-execution';
const webResult = await fetch('https://api.example.com/data');
const data = await webResult.json();
const toolEvent = makeToolEvent({
toolId: 'web-search',
output: data, // hashed automatically
input: { query: 'Q1 revenue' }, // optional; hashed if provided
evidenceRef: 'https://api.example.com/data', // optional URL/ID
});
const snapshot = createSnapshot({
// ...
toolCalls: [toolEvent],
});
const bundle = sealCer(snapshot);ToolEvent shape:
| Field | Required | Description |
|---|---|---|
| toolId | Yes | Identifier of the tool/dependency called |
| at | Yes | ISO 8601 timestamp (defaults to new Date() in makeToolEvent) |
| outputHash | Yes | sha256:<64hex> of the tool output |
| inputHash | No | sha256:<64hex> of the tool input (optional) |
| evidenceRef | No | URL or external ID pointing to the raw evidence |
| error | No | Error message if the tool call failed |
hashToolOutput(value) hashes a tool output value: strings → SHA-256 of UTF-8 bytes; anything else → SHA-256 of canonical JSON bytes.
Chain Verification
verifyRunSummary(summary, bundles, opts?) validates that a RunBuilder multi-step run forms an unbroken cryptographic chain. It detects insertion, deletion, and reordering of steps.
import { RunBuilder, verifyRunSummary } from '@nexart/ai-execution';
const run = new RunBuilder({ runId: 'my-run' });
run.step({ /* step 0 */ });
run.step({ /* step 1 */ });
const summary = run.finalize();
const bundles = run.getBundles(); // or retrieve from storage
const result = verifyRunSummary(summary, bundles);
// { ok: boolean, code: CerVerifyCode, errors: string[], breakAt?: number }verifyRunSummary returns RunSummaryVerifyResult:
ok: true— full chain is validok: falsewithINCOMPLETE_ARTIFACT— step count mismatchok: falsewithCHAIN_BREAK_DETECTED— stepIndex/prevStepHash/certificateHash mismatch;breakAtis the index of the first broken link
Profiles (Opt-in Strictness)
validateProfile(target, profile) applies extra field-presence checks at creation time. It never affects certificateHash or verifyCer().
| Profile | What it enforces |
|---|---|
| 'flexible' | No extra validation (default SDK behaviour) |
| 'AIEF_L2' | AIEF-01 required fields: executionId, timestamp, provider, model, input, output, inputHash, outputHash |
| 'AIEF_L3' | Same as AIEF_L2 |
| 'AIEF_L4' | AIEF_L3 + validates each ToolEvent in toolCalls; requires prevStepHash when stepIndex > 0 |
import { validateProfile } from '@nexart/ai-execution';
const result = validateProfile(bundle, 'AIEF_L4');
// { ok: boolean, errors: string[] }Opinionated Run Helper
certifyAndAttestRun(steps, options?) combines RunBuilder step creation, optional per-step attestation, and finalization into a single call. It does not change RunBuilder semantics — it creates an internal RunBuilder and returns all artifacts together.
import { certifyAndAttestRun, verifyRunSummary, attest } from '@nexart/ai-execution';
const { runSummary, stepBundles, receipts, finalStepHash } =
await certifyAndAttestRun(
[step0Params, step1Params, step2Params],
{
runId: 'analysis-run',
workflowId: 'data-pipeline',
// Optional: attest each step immediately after sealing
attestStep: (bundle) => attest(bundle, { nodeUrl, apiKey }),
},
);
verifyRunSummary(runSummary, stepBundles); // { ok: true }Return shape:
| Field | Type | Description |
|---|---|---|
| runSummary | RunSummary | From RunBuilder.finalize() — stepCount, steps, finalStepHash |
| stepBundles | CerAiExecutionBundle[] | Sealed bundles in step order (index 0 = step 0) |
| receipts | (AttestationReceipt \| null)[] | Attestation receipts in step order; null if attestStep was not provided |
| finalStepHash | string \| null | Alias for runSummary.finalStepHash |
Testing without network: inject a mock attestStep to test the full flow without hitting a node:
const { runSummary, stepBundles, receipts } = await certifyAndAttestRun(steps, {
attestStep: async (bundle) => ({
attestationId: 'mock-' + bundle.certificateHash.slice(7, 15),
certificateHash: bundle.certificateHash,
nodeRuntimeHash: 'sha256:' + 'a'.repeat(64),
protocolVersion: '1.2.0',
}),
});Redaction Semantics (v0.7.0+)
Pre-seal verifiable redaction
redactBeforeSeal(snapshot, policy) replaces sensitive snapshot fields with stable envelopes before sealing. Because the certificateHash is computed over the already-redacted snapshot, the resulting bundle passes verifyCer() unchanged.
import { redactBeforeSeal, sealCer, verify } from '@nexart/ai-execution';
const redacted = redactBeforeSeal(snapshot, { paths: ['input', 'output'] });
const bundle = sealCer(redacted);
verify(bundle).ok; // true — hash matches the redacted snapshotEach redacted field becomes { _redacted: true, hash: "sha256:..." } where hash is the SHA-256 of the original value. This lets authorized reviewers confirm what was there without accessing the raw content.
Supported fields for pre-seal redaction:
| Field | Supported | Notes |
|---|---|---|
| input | Yes | inputHash is recomputed from the envelope |
| output | Yes | outputHash is recomputed from the envelope |
| toolCalls[n].output (via ToolEvent.outputHash) | Yes | The outputHash already stores only the hash — the raw tool output need not be in the bundle at all |
| prompt and other schema-validated strings | No | verifySnapshot validates these as non-empty strings. Replacing with an object envelope causes verifyCer() to return SCHEMA_ERROR. |
Verifiable redacted export (post-seal, new bundle)
exportVerifiableRedacted(bundle, policy, options?) is the right choice when you already have a sealed bundle and want to share a sanitized version that is still independently verifiable. It:
- Applies
redactBeforeSeal()to the original snapshot - Re-seals the redacted snapshot into a new bundle with a new
certificateHash - Stores the original
certificateHashinmeta.provenance.originalCertificateHashas an informational cross-reference
import { certifyDecision, exportVerifiableRedacted, verify } from '@nexart/ai-execution';
const original = certifyDecision({ ... });
const { bundle, originalCertificateHash } = exportVerifiableRedacted(
original,
{ paths: ['input', 'output'] },
);
verify(bundle).ok; // true — new bundle verifies
bundle.certificateHash !== original.certificateHash; // true — different hash
bundle.meta.provenance.originalCertificateHash; // 'sha256:...' — reference only
bundle.snapshot.input; // { _redacted: true, hash: 'sha256:...' }Provenance semantics: meta.provenance.originalCertificateHash is reference metadata only. It is not part of the new certificateHash computation. Recipients of the new bundle cannot verify the original bundle's integrity from it — they can only confirm what hash the original had. If you need to prove the original's integrity, keep the original bundle available alongside the redacted one.
Rule of thumb for choosing a redaction approach:
| Approach | verify() passes | Original hash preserved | Notes |
|---|---|---|---|
| redactBeforeSeal(snapshot, policy) + sealCer() | ✅ | N/A — no original exists yet | Use when building a redacted bundle from scratch |
| exportVerifiableRedacted(bundle, policy) | ✅ | Reference only in meta.provenance | Use when you have a sealed bundle and need a shareable sanitized copy |
| sanitizeForStorage(bundle, options) | ❌ | N/A — hash broken by design | Use only for storage; do not pass result to verify() |
Post-hoc redaction breaks integrity — by design
sanitizeForStorage(bundle, options) can replace arbitrary paths with a string placeholder, but it does not recompute any content hashes. This means verifyCer() will return CERTIFICATE_HASH_MISMATCH on the result. This is intentional: post-hoc redaction is a lossy, storage-only operation. If you need a verifiable record after storage-side redaction, use exportVerifiableRedacted instead.
Canonical JSON Constraints
- Object keys sorted lexicographically (Unicode codepoint order) at every nesting level.
- No whitespace between tokens.
- Array order preserved.
nullserialized asnull.- Numbers must be finite.
NaN,Infinity,-Infinityrejected (throw). undefinedvalues in object properties omitted (key dropped).BigInt, functions,Symbolrejected (throw).- Strings JSON-escaped.
Canonicalization is frozen for v1. Number formatting uses JSON.stringify() (V8-consistent). This does not normalize -0 to 0 and does not implement RFC 8785 exponential notation rules. These are documented known behaviors, not bugs. Any future stricter canonicalization will ship under a new bundle type.
Error Types
| Error | When thrown | Structured data |
|---|---|---|
| CerVerificationError | importCer() on invalid/tampered data | .errors: string[] |
| CerAttestationError | attest() on failure | .statusCode, .responseBody, .details: string[] |
Interoperability (Test Vectors)
Fixtures at fixtures/vectors/ and fixtures/golden/. Cross-language implementations must match committed hash values exactly. Golden fixtures (v0.1.0 semantics) must verify with every future version.
API Reference
Core
| Function | Description |
|---|---|
| createSnapshot(params) | Create snapshot with computed hashes |
| verifySnapshot(snapshot) | Verify snapshot hashes and structure (Node 18+, uses crypto.createHash) |
| sealCer(snapshot, options?) | Seal snapshot into CER bundle |
| verify(bundle) / verifyCer(bundle) | Verify CER bundle (Node 18+, uses crypto.createHash) |
| certifyDecision(params) | One-call: createSnapshot + sealCer. Accepts optional signals?: CerContextSignal[] — included in certificateHash when present (v0.10.0+) |
Async Verification — Browser-safe (v0.14.0+)
Use these functions whenever Node's built-in crypto module is unavailable: browsers, edge workers (Cloudflare Workers, Vercel Edge), Deno, React Native, or any context where import 'crypto' fails. They use globalThis.crypto.subtle.digest (Web Crypto API) and produce byte-for-byte identical verification outcomes to their sync counterparts.
If you are running on Node 18+ only, use the sync functions — they are simpler and equally correct. Use the async functions when you need the same SDK-owned verification to work in browser environments without maintaining a separate reimplementation.
| Function | Description |
|---|---|
| verifyCerAsync(bundle) | Async mirror of verifyCer(). Returns Promise<VerificationResult>. Use in browser apps to verify a received CER bundle without Node's crypto module. |
| verifyProjectBundleAsync(bundle) | Async mirror of verifyProjectBundle(). Returns Promise<ProjectBundleVerifyResult>. Use in browser apps to verify a full project bundle. |
| verifySnapshotAsync(snapshot) | Async mirror of verifySnapshot(). Returns Promise<VerificationResult>. Primarily consumed internally by verifyCerAsync. |
| sha256HexAsync(data) | WebCrypto SHA-256 primitive. Returns Promise<string> (64-char hex). Accepts string or Uint8Array. |
| hashUtf8Async(value) | sha256: + SHA-256 of a UTF-8 string (async). |
| hashCanonicalJsonAsync(value) | sha256: + SHA-256 of canonical JSON of value (async). |
| computeInputHashAsync(input) | Async mirror of computeInputHash(). |
| computeOutputHashAsync(output) | Async mirror of computeOutputHash(). |
Workflow
| Export | Description |
|---|---|
| RunBuilder | Multi-step workflow builder with prevStepHash chaining |
| verifyRunSummary(summary, bundles, opts?) | Verify that a RunSummary + step bundles form an unbroken cryptographic chain (v0.7.0+) |
| certifyAndAttestRun(steps, options?) | One-call: create RunBuilder internally, certify all steps, optionally attest each bundle, return { runSummary, stepBundles, receipts, finalStepHash }. Injectable attestStep for mocking. |
AIEF Interop (v0.7.0+)
| Function | Description |
|---|---|
| verifyAief(bundle) | Verify a CER bundle and return the exact AIEF §9.1 output shape |
| mapToAiefReason(code) | Convert a CerVerifyCode string to an AIEF §9.2 reason string |
| hashToolOutput(value) | Hash a tool output value: string → UTF-8 SHA-256; other → canonical JSON SHA-256 |
| makeToolEvent(params) | Build a ToolEvent record for inclusion in snapshot.toolCalls |
| redactBeforeSeal(snapshot, policy) | Pre-seal redaction: replace input/output with verifiable envelopes before sealing |
| exportVerifiableRedacted(bundle, policy, options?) | Post-seal: produce a new sealed bundle with redacted snapshot + meta.provenance.originalCertificateHash. verify() passes on the new bundle. Original is unchanged. |
| validateProfile(target, profile) | Validate a snapshot or bundle against an AIEF strictness profile (does not affect hashing) |
Attestation & Archive
| Function | Description |
|---|---|
| attest(bundle, options) | Post CER to canonical node (auto-sanitizes) |
| certifyAndAttestDecision(params, options) | One-call: certifyDecision + attest + receipt |
| attestIfNeeded(bundle, options) | Attest only if no receipt already present |
| getAttestationReceipt(bundle) | Extract normalized AttestationReceipt or null |
| verifyNodeReceiptSignature(params) | Verify an Ed25519-signed receipt offline (v0.5.0+) |
| fetchNodeKeys(nodeUrl) | Fetch NodeKeysDocument from /.well-known/nexart-node.json (v0.5.0+) |
| selectNodeKey(doc, kid?) | Select a key from a NodeKeysDocument by kid or activeKid (v0.5.0+) |
| verifyBundleAttestation(bundle, options) | One-call offline attestation verification (v0.5.0+) |
| sanitizeForAttestation(bundle) | Remove undefined keys, reject BigInt/functions/symbols |
| sanitizeForStorage(bundle, options?) | Sanitize for DB storage with optional path-based redaction; does not recompute hashes (v0.6.0+) |
| sanitizeForStamp(bundle) | Extract attestable core (no meta); does not recompute hashes (v0.6.0+) |
| hasAttestation(bundle) | Check if bundle already has attestation fields |
| exportCer(bundle) | Serialize to canonical JSON string |
| importCer(json) | Parse + verify from JSON string |
CER Package Helpers (v0.12.0+)
| Function | Description |
|---|---|
| isCerPackage(value) | Type guard: returns true if value is a CER package shape ({ cer: { bundleType: 'cer.ai.execution.v1', ... }, ... }). Structural check only — does not verify the inner CER hash. |
| createCerPackage(params) | Assemble a CER package from a sealed CER and optional transport fields (receipt, signature, attestation, verificationEnvelope, verificationEnvelopeSignature). Assembly only — does not re-hash or re-sign. |
| getCerFromPackage(pkg) | Extract and return the inner CER bundle. Throws CerVerificationError if pkg is not a valid package shape. |
| exportCerPackage(pkg) | Serialize a CER package to a stable canonical JSON string. |
| importCerPackage(json) | Parse a CER package JSON string, validate its shape, and verify the inner CER with verifyCer(). Throws CerVerificationError on any failure. |
| verifyCerPackage(pkg) | Verify the inner CER of a package using verifyCer(). Returns a VerificationResult. Conservative: only verifies the inner cer — does not verify receipt/signature/envelope. |
Exported types: AiCerPackage, CreateCerPackageParams
Project Bundle Helpers (v0.13.0+)
| Function | Description |
|---|---|
| createProjectBundle(params) | Assemble and finalize a project bundle from project metadata and a list of already-certified CER bundles. Derives totalSteps, step registry entries (with certificateHash + executionId per step), model identity, and integrity.projectHash deterministically. stepId and projectBundleId are auto-generated (UUID v4) when omitted. Input array order is authoritative; no reshuffling. |
| verifyProjectBundle(bundle) | Fully verify a project bundle. Sync — uses Node's crypto.createHash. Use in Node 18+ server and CLI contexts. Returns ProjectBundleVerifyResult. |
| verifyProjectBundleAsync(bundle) | Async mirror of verifyProjectBundle(). Uses Web Crypto (globalThis.crypto.subtle). Use this in browser apps, edge workers, or any non-Node runtime. Same verification model, same result shape, same error messages. Returns Promise<ProjectBundleVerifyResult>. (v0.14.0+) |
Exported types: ProjectBundle, ProjectBundleIntegrity, ProjectBundleStepEntry, ProjectBundleStepType, ProjectBundleStepModelIdentity, ProjectBundleVerifyResult, ProjectBundleStepVerifyResult, CreateProjectBundleParams, CreateProjectBundleStepParams
CerVerifyCode values: OK, SCHEMA_ERROR, CERTIFICATE_HASH_MISMATCH, INPUT_HASH_MISMATCH, OUTPUT_HASH_MISMATCH, PROJECT_HASH_MISMATCH, STEP_REGISTRY_MISMATCH
Provider Drop-in (v0.6.0+)
| Function | Description |
|---|---|
| certifyDecisionFromProviderCall(params) | One-function wrapper: extracts prompt/input/output/params from raw provider request+response and returns { ok, bundle } or { ok: false, code: 'SCHEMA_ERROR', reason }. Supports OpenAI, Anthropic, Gemini, Mistral, Bedrock, and generic shapes. |
Opinionated Client (v0.6.0+)
| Export | Description |
|---|---|
| createClient(defaults) | Returns a NexArtClient with bound defaults (appId, workflowId, nodeUrl, apiKey, tags, source). Methods: certifyDecision, certifyAndAttestDecision, verify, verifyBundleAttestation. Defaults do not affect bundle hashing. |
Reason Codes
CerVerifyCode — stable string-union constant exported from the package root:
| Code | When set |
|---|---|
| OK | Verification passed |
| CERTIFICATE_HASH_MISMATCH | certificateHash doesn't match recomputed hash |
| INPUT_HASH_MISMATCH | inputHash doesn't match recomputed hash |
| OUTPUT_HASH_MISMATCH | outputHash doesn't match recomputed hash |
| SNAPSHOT_HASH_MISMATCH | Both inputHash and outputHash are wrong |
| INVALID_SHA256_FORMAT | A hash field doesn't start with sha256: |
| SCHEMA_ERROR | Wrong bundleType/version, missing snapshot, non-finite parameters, etc. |
| CANONICALIZATION_ERROR | toCanonicalJson threw during verification |
| UNKNOWN_ERROR | Catch-all for unclassified failures |
| ATTESTATION_MISSING | No signed receipt found in bundle (v0.5.0+) |
| ATTESTATION_KEY_NOT_FOUND | kid not found in node keys document (v0.5.0+) |
| ATTESTATION_INVALID_SIGNATURE | Ed25519 signature did not verify (v0.5.0+) |
| ATTESTATION_KEY_FORMAT_UNSUPPORTED | Key cannot be decoded (v0.5.0+) |
| CHAIN_BREAK_DETECTED | verifyRunSummary detected a broken prevStepHash link or reordered step (v0.7.0+) |
| INCOMPLETE_ARTIFACT | Step count mismatch between RunSummary and provided bundles (v0.7.0+) |
| VERIFICATION_MATERIAL_UNAVAILABLE | Required verification material (keys, receipt) is absent (v0.7.0+) |
| TOOL_EVIDENCE_MISSING | Required tool call evidence absent in an AIEF_L4 context (v0.7.0+) |
| TOOL_OUTPUT_HASH_MISMATCH | A recorded outputHash in toolCalls does not match the provided tool output (v0.7.0+) |
Priority when multiple failures exist: CANONICALIZATION_ERROR > SCHEMA_ERROR > INVALID_SHA256_FORMAT > CERTIFICATE_HASH_MISMATCH > INPUT_HASH_MISMATCH > OUTPUT_HASH_MISMATCH > SNAPSHOT_HASH_MISMATCH > UNKNOWN_ERROR.
These codes are stable across all future versions. New codes may be added but existing codes will not be renamed or removed.
Providers (sub-exports)
| Function | Export path |
|---|---|
| runOpenAIChatExecution | @nexart/ai-execution/providers/openai |
| runAnthropicExecution | @nexart/ai-execution/providers/anthropic |
| wrapProvider | @nexart/ai-execution/providers/wrap |
LangChain Integration
@nexart/ai-execution includes a minimal LangChain helper surface (introduced v0.10.0, current v0.13.0). Certify the final input and output of any LangChain chain, agent, or runnable as a tamper-evident CER — no LangChain package dependency required.
certifyLangChainRun operates in two modes depending on whether nodeUrl and apiKey are supplied:
| Mode | How to call | Returns |
|---|---|---|
| Local (default) | No nodeUrl / apiKey | LangChainCerResult (sync) |
| Node-attested | nodeUrl + apiKey on input | Promise<LangChainAttestedResult> |
Mode 1 — Local CER creation (synchronous, no network)
createLangChainCer is always local. certifyLangChainRun without nodeUrl/apiKey is identical.
import { createLangChainCer, certifyLangChainRun } from '@nexart/ai-execution';
// or: import { ... } from '@nexart/ai-execution/langchain';
// Using createLangChainCer — always explicit about local-only behaviour
const { bundle, certificateHash, executionId } = createLangChainCer({
executionId: 'run-001', // optional — UUID generated if omitted
provider: 'openai',
model: 'gpt-4o-mini',
input: { messages: [{ role: 'user', content: 'What is the capital of France?' }] },
output: { text: 'Paris.' },
metadata: { appId: 'my-app', projectId: 'docs-bot' },
parameters: { temperature: 0, maxTokens: 200, topP: null, seed: null },
createdAt: new Date().toISOString(), // pin for deterministic hash
});
console.log(certificateHash); // sha256:...
// certifyLangChainRun without nodeUrl/apiKey: identical, sync
const { bundle: b2 } = certifyLangChainRun({
provider: 'openai', model: 'gpt-4o-mini',
input: { messages: [{ role: 'user', content: 'Summarise this.' }] },
output: { text: 'Summary...' },
});Mode 2 — Node-attested certification (async)
Add nodeUrl and apiKey to the same input to route through the existing certifyAndAttestDecision() path. The function returns a Promise<LangChainAttestedResult> with the receipt from the NexArt node.
import { certifyLangChainRun } from '@nexart/ai-execution';
const result = await certifyLangChainRun({
executionId: 'run-001',
provider: 'openai',
model: 'gpt-4o-mini',
input: { messages: [{ role: 'user', content: 'What is the capital of France?' }] },
output: { text: 'Paris.' },
metadata: { appId: 'my-app', projectId: 'docs-bot' },
createdAt: new Date().toISOString(),
nodeUrl: 'https://node.nexart.io', // ← triggers attested mode
apiKey: process.env.NEXART_API_KEY!, // ← required with nodeUrl
});
console.log(result.certificateHash); // sha256:... (same semantics as local)
console.log(result.attested); // true
console.log(result.receipt);
// {
// attestationId: "nxa_attest_...",
// certificateHash: "sha256:...",
// nodeRuntimeHash: "sha256:...",
// protocolVersion: "1.2.0"
// }result.bundle passes verifyCer() identically to local mode — the certificateHash covers only the CER content, not the receipt fields.
Three helpers
All three helpers accept an optional signals?: CerContextSignal[] field on their input. When provided, signals are sealed into bundle.context and included in certificateHash. Omitting signals produces a hash identical to a bundle without signals.
| Helper | Returns | Network |
|---|---|---|
| createLangChainCer(input) | LangChainCerResult (sync) | Never |
| certifyLangChainRun(input) without nodeUrl/apiKey | LangChainCerResult (sync) | Never |
| certifyLangChainRun({ ...input, nodeUrl, apiKey }) | Promise<LangChainAttestedResult> | Yes — NexArt node |
Result types
interface LangChainCerResult {
bundle: CerAiExecutionBundle;
certificateHash: string;
executionId: string;
}
interface LangChainAttestedResult extends LangChainCerResult {
receipt: AttestationReceipt;
attested: true;
}Input normalization
| Input shape | Stored in CER as |
|---|---|
| string | string (pass-through) |
| Plain object {} | Record<string, unknown> (pass-through) |
| Array [] | { items: [...] } |
| Anything else | String(value) |
Prompt extraction
Resolved in this order: metadata.prompt → input.messages[0].content → input.prompt → input.text → "[LangChain run]".
Metadata mapping
| Metadata key | Mapped to |
|---|---|
| appId | snapshot.appId |
| runId | snapshot.runId |
| workflowId | snapshot.workflowId |
| conversationId | snapshot.conversationId |
| prompt | Used as the CER prompt field |
| All other keys | bundle.meta.tags (as "key:value" strings) |
Verification
Bundles from all three helpers are fully protocol-aligned:
import { verifyCer, verifyAiCerBundleDetailed } from '@nexart/ai-execution';
const basic = verifyCer(bundle);
// { ok: true, code: 'OK' }
const detailed = verifyAiCerBundleDetailed(bundle);
// { status: 'VERIFIED', checks: { bundleIntegrity: 'PASS', nodeSignature: 'SKIPPED' }, ... }Testing the attested path without a network
Use the injectable _attestFn test option (same pattern as certifyAndAttestRun's attestStep):
import type { AttestDecisionFn } from '@nexart/ai-execution';
const mockAttest: AttestDecisionFn = async (params, _opts) => {
const { certifyDecision } = await import('@nexart/ai-execution');
const bundle = certifyDecision(params);
return { bundle, receipt: { attestationId: 'mock-123', certificateHash: bundle.certificateHash,
nodeRuntimeHash: 'sha256:' + 'beef'.repeat(16), protocolVersion: '1.2.0' } };
};
const result = await certifyLangChainRun(
{ ...input, nodeUrl: 'https://node.nexart.io', apiKey: 'nxa_test' },
{ _attestFn: mockAttest },
);V3 roadmap (not yet implemented)
BaseCallbackHandlerintegration for automatic mid-chain CER capture atonLLMEnd- LangSmith trace correlation via
metadata.runId→snapshot.conversationId - Separate
@nexart/langchainpackage once the callback surface is proven in production
Context Signals (v0.10.0+)
CERs can optionally include context signals — structured, protocol-agnostic records of upstream events that were present when the AI execution ran. Signals are evidence-only: they have no effect on how the AI call is made, what parameters are used, or how the output is processed. Their only role is to be bound into the certificateHash alongside the execution record so that any post-hoc modification to either the signals or the snapshot is detectable.
What signals are:
- Records of things that happened before or alongside the AI call (approvals, deploys, audits, tool invocations, review outcomes, etc.)
- Protocol-agnostic —
type,source,actor, andpayloadare all free-form strings and objects - Included in
certificateHashonly when present — omitting signals produces a hash identical to pre-v0.10.0
What signals are not:
- Governance enforcement — NexArt does not interpret signal content or enforce policy based on it
- Execution dependencies — signals do not gate or modify the AI call in any way
- A replacement for
toolCalls—toolCallsrecords tool invocations within a run; signals record upstream context around a run
Quick example
import { createSignalCollector } from '@nexart/signals';
import { certifyDecision, verifyCer } from '@nexart/ai-execution';
// 1. Collect upstream signals during your pipeline run
const collector = createSignalCollector({ defaultSource: 'github-actions' });
collector.add({ type: 'approval', actor: 'alice', status: 'ok', payload: { pr: 42 } });
collector.add({ type: 'deploy', actor: 'ci-bot', status: 'ok', payload: { env: 'prod' } });
const { signals } = collector.export();
// 2. Certify the AI execution with signals bound as context evidence
const bundle = certifyDecision({
provider: 'openai',
model: 'gpt-4o-mini',
prompt: 'Summarise.',
input: userQuery,
output: llmResponse,
parameters: { temperature: 0, maxTokens: 512, topP: null, seed: null },
signals, // ← included in certificateHash; omit to get identical hash as before
});
verifyCer(bundle).ok; // true
console.log(bundle.context?.signals.length); // 2 — signals are sealed in the bundleNexArtSignal[] from @nexart/signals is structurally identical to CerContextSignal[] — no casting or conversion needed. @nexart/ai-execution has no hard dependency on @nexart/signals; any object matching the CerContextSignal shape works.
Signals also work on the LangChain path
import { certifyLangChainRun } from '@nexart/ai-execution';
const { bundle } = certifyLangChainRun({
provider: 'openai',
model: 'gpt-4o-mini',
input: { messages: [...] },
output: { text: '...' },
signals, // ← same field, same semantics
});CerContextSignal shape
Every CerContextSignal has the following fields. All are always present in the stored bundle — no undefined values:
| Field | Type | Default | Description |
|---|---|---|---|
| type | string | required | Signal category — free-form (e.g. "approval", "deploy", "audit") |
| source | string | required | Upstream system — free-form (e.g. "github-actions", "linear") |
| step | number | 0 | Position in sequence — used for ordering within the context.signals array |
| timestamp | string | current time | ISO 8601 |
| actor | string | "unknown" | Who produced the signal — free-form |
| status | string | "ok" | Outcome — free-form (e.g. "ok", "error", "pending") |
| payload | Record<string, unknown> | {} | Opaque upstream data — NexArt does not interpret this |
Signal ordering within the array is part of the sealed hash. Reordering signals produces a different certificateHash.
Tamper detection
verifyCer() always reconstructs context from the stored bundle.context.signals when recomputing the hash. Any mutation — changing a signal field, adding or removing signals, or injecting a context block into a no-signals bundle — produces a CERTIFICATE_HASH_MISMATCH result:
const tampered = { ...bundle, context: { signals: [...altered] } };
verifyCer(tampered).ok; // false
verifyCer(tampered).code; // 'CERTIFICATE_HASH_MISMATCH'Exported types
import type { CerContextSignal, CerContext } from '@nexart/ai-execution';CerContext is the shape of bundle.context — { signals: CerContextSignal[] }. Both are exported from the package root.
Project Bundles (v0.13.0)
A project bundle (bundleType: 'cer.project.bundle.v1') is a project-scoped evidence artifact that groups multiple already-certified CerAiExecutionBundles under a single projectHash. Each step in the bundle preserves its own executionId and certificateHash from when it was individually certified. The bundle does not re-seal or mutate the embedded CERs.
Project bundles support:
- Tightly connected agentic multi-step workflow traces (step A → step B → step C)
- Partially connected steps where only some steps declare parent step relationships
- Looser project-scoped collections of certified executions — product development runs, client implementation evidence, grouped audit/compliance records — where steps have no parent links at all
parentStepIds are always optional. A bundle with no parent links on any step is valid and fully verifiable.
Creating a project bundle
Certify each individual step first, then assemble:
import { certifyDecision, createProjectBundle } from '@nexart/ai-execution';
const b1 = certifyDecision({ prompt: 'Classify intent', input: userQuery, output: intent, model: 'gpt-4o', provider: 'openai' });
const b2 = certifyDecision({ prompt: 'Retrieve docs', input: intent, output: documents, model: 'gpt-4o', provider: 'openai' });
const b3 = certifyDecision({ prompt: 'Draft response', input: documents, output: response, model: 'claude-3-5-sonnet-20241022', provider: 'anthropic' });
const project = createProjectBundle({
// projectBundleId is auto-generated if omitted
projectTitle: 'Support resolution — 2026-04-06',
projectGoal: 'Resolve billing query end-to-end',
projectSummary: 'Classified, retrieved context, and drafted reply.',
appName: 'support-triage',
frameworkName: 'LangChain',
tags: ['billing', 'production'],
startedAt: '2026-04-06T08:00:00.000Z',
completedAt: '2026-04-06T08:00:04.321Z',
steps: [
{ stepId: 'step-classify', stepLabel: 'Classify intent', bundle: b1 },
{ stepId: 'step-retrieve', stepLabel: 'Retrieve docs', bundle: b2, parentStepIds: ['step-classify'] },
{ stepId: 'step-draft', stepLabel: 'Draft response', bundle: b3, parentStepIds: ['step-retrieve'] },
],
});
console.log(project.bundleType); // 'cer.project.bundle.v1'
console.log(project.integrity.projectHash); // 'sha256:...'
console.log(project.totalSteps); // 3
console.log(project.stepRegistry[0].certificateHash); // 'sha256:...' (from b1)Loose collection — no parent links needed:
const project = createProjectBundle({
projectTitle: 'Q1 implementation runs',
startedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-03-31T23:59:59.000Z',
steps: [
{ stepLabel: 'Run A', bundle: bundleA },
{ stepLabel: 'Run B', bundle: bundleB },
{ stepLabel: 'Run C', bundle: bundleC },
// No parentStepIds — loose grouping is fully valid.
],
});When stepId or projectBundleId are omitted they are auto-generated as UUID v4 values.
Verifying a project bundle
Two verification functions are available. They run identical checks and return identical result shapes — the only difference is which crypto API they use:
| | verifyProjectBundle | verifyProjectBundleAsync |
|---|---|---|
| Return type | ProjectBundleVerifyResult (sync) | Promise<ProjectBundleVerifyResult> |
| Crypto API | Node.js crypto.createHash | globalThis.crypto.subtle (Web Crypto) |
| Use when | Node 18+ servers, CLI tools, scripts | Browsers, edge workers, Deno, non-Node runtimes |
| Available since | v0.13.0 | v0.14.0 |
On Node 18+ (server / CLI):
import { verifyProjectBundle } from '@nexart/ai-execution';
const result = verifyProjectBundle(project);
if (result.ok) {
console.log(`All ${result.totalSteps} steps verified`);
} else {
console.log(`${result.failedSteps}/${result.totalSteps} steps failed`);
for (const step of result.steps) {
if (!step.ok) console.log(` ${step.stepId}: ${step.code}`);
}
}In browser apps (React, Vue, Svelte, Next.js client components, etc.):
import { verifyProjectBundleAsync } from '@nexart/ai-execution';
// Works in any browser that supports the Web Crypto API (all modern browsers).
// No separate reimplementation needed — this is the same SDK-owned verification
// model as the server path, just using a different crypto primitive.
const result = await verifyProjectBundleAsync(project);
if (result.ok) {
console.log(`All ${result.totalSteps} steps verified`);
} else {
console.log(`${result.failedSteps}/${result.totalSteps} steps failed`);
for (const step of result.steps) {
if (!step.ok) console.log(` ${step.stepId}: ${step.code}`);
}
}Verifying a single CER bundle in the browser:
import { verifyCerAsync } from '@nexart/ai-execution';
const result = await verifyCerAsync(bundle);
// Identical outcome to verifyCer(bundle) — same checks, same codes, same error messages.
console.log(result.ok, result.code);verifyProjectBundle / verifyProjectBundleAsync check (in order):
- Discriminant —
bundleType === 'cer.project.bundle.v1' - Required top-level fields
totalStepsconsistencystepIduniquenessexecutionIduniqueness- No duplicate
sequencevalues - Parent reference resolution (all
parentStepIdsmust reference a knownstepId) - Per-step cross-check —
executionId+certificateHashin the registry match the embedded CER - Inner
verifyCer()on every embedded CER bundle projectHashrecomputation over all material project metadata
Result shape:
type ProjectBundleVerifyResult = {
ok: boolean;
code: string; // CerVerifyCode
errors: string[];
projectHashValid?: boolean;
structuralValid?: boolean;
totalSteps?: number;
passedSteps?: number;
failedSteps?: number;
steps: ProjectBundleStepVerifyResult[];
};
type ProjectBundleStepVerifyResult = {
stepId: string;
sequence: number;
executionId: string;
certificateHash?: string;
ok: boolean;
code: string;
errors: string[];
};Project hash scope
The integrity.projectHash is a SHA-256 over a deterministic canonical JSON payload. It covers:
| Field | Included |
|---|---|
| bundleType, version, protocolVersion | Yes |
| projectBundleId, projectTitle | Yes |
| projectGoal, projectSummary | Yes (when set) |
| startedAt, completedAt | Yes |
| appName, frameworkName, tags | Yes (when set) |
| totalSteps, stepRegistry (full, including per-step certificateHash) | Yes |
| integrity.projectHash | No — self-referential |
| finalOutputSummary | No — human-readable, may be updated post-facto |
Because the full stepRegistry (including each step's certificateHash) is covered, any change to any embedded CER propagates into the projectHash.
Sequence rules
Each step in stepRegistry has a sequence: number field:
- Not provided → auto-assigned as the 0-based array index (
0,1,2, …) - Explicitly provided → preserved exactly as-is
- Sequence values must be unique; duplicates cause
verifyProjectBundleto fail with a descriptive error - Values need not be contiguous —
5, 10, 15is valid - Step registry is written in input array order; no reshuffling occurs
What is NOT in this package
The following are deliberately out of scope for @nexart/ai-execution:
- Project-level node attestation — the
integrity.projectHashis offline-verifiable only; no node endpoint changes - Project-level receipt or verification envelope — no network submission of the project bundle as a whole
- High-level session lifecycle abstraction — incremental step addition, session start/end, project export/import helpers, rich graph visualization
Higher-level developer ergonomics built on createProjectBundle and verifyProjectBundle are planned in @nexart/agent-kit.
Version History
| Version | Description |
|---|---|
| v0.1.0 | Core snapshot + CER + verify + OpenAI adapter |
| v0.2.0 | certifyDecision, RunBuilder, attest, archive, Anthropic, wrapProvider, typed errors, workflow fields |
| v0.3.0 | Attestation hardening (hash validation, timeout), verify alias, CerAttestationError.details, release hygiene |
| v0.4.0 | Dual ESM/CJS build, sanitizeForAttestation, hasAttestation, auto-sanitize in attest(), fixed ERR_PACKAGE_PATH_NOT_EXPORTED |
| v0.4.1 | Verification reason codes (CerVerifyCode), code + details on VerificationResult, README provenance wording tightened |
| v0.4.2 | AttestationReceipt, getAttestationReceipt, certifyAndAttestDecision, attestIfNeeded |
| v0.5.0 | Ed25519 signed receipt verification: verifyNodeReceiptSignature, verifyBundleAttestation, fetchNodeKeys, selectNodeKey; new attestation CerVerifyCode entries; SPEC.md; NodeKeysDocument, SignedAttestationReceipt, NodeReceiptVerifyResult types |
| v0.6.0 | Frictionless integration: certifyDecisionFromProviderCall (OpenAI/Anthropic/Gemini/Mistral/Bedrock drop-in); sanitizeForStorage + sanitizeForStamp redaction helpers; createClient(defaults) factory; regression fixture suite; all backward-compatible, no hash changes |
| v0.7.0 | AIEF alignment: verifyAief() (AIEF §9.1 adapter); verifyRunSummary() chain verifier; makeToolEvent() + hashToolOutput() + snapshot.toolCalls (AIEF-06); BundleDeclaration block (stabilitySchemeId, protectedSetId, protectedFields) excluded from certificateHash; redactBeforeSeal() pre-seal verifiable redaction; validateProfile() (flexible/AIEF_L2/L3/L4); 5 new CerVerifyCode entries; backward-compatible, no hash changes |
| v0.8.0 | Helper APIs: exportVerifiableRedacted() (post-seal re-seal with redacted snapshot + provenance metadata); certifyAndAttestRun() (one-call multi-step certify + optional per-step attestation with injectable mock); test determinism fix; all v0.1–v0.7 bundles verify identically |
| v0.9.0 | CER Protocol types: CerVerificationResult, ReasonCode, CheckStatus; verifyAiCerBundleDetailed(); CertifyDecisionParams.createdAt wired through; determinism bug fix |
| v0.10.0 | LangChain integration: createLangChainCer() (sync/local); certifyLangChainRun() dual-mode — local (sync) or node-attested (Promise<LangChainAttestedResult> when nodeUrl+apiKey supplied); LangChainAttestedResult, AttestDecisionFn; injectable _attestFn for test mocking. Context signals: optional signals?: CerContextSignal[] on certifyDecision, certifyLangChainRun, and createLangChainCer — sealed into bundle.context and included in certificateHash when non-empty; absent or empty = hash unchanged from pre-v0.10.0. New types: CerContextSignal, CerContext. New example: examples/signals-cer.ts. 413 total tests; all prior bundles verify identically |
| v0.11.0 | Version release. Ships all v0.10.0 features (LangChain integration + context signals) as the published package version. cerSignals.test.js added to the npm test script. No API, hash, or canonicalization changes |
| v0.12.0 | CER package helpers: isCerPackage, createCerPackage, getCerFromPackage, exportCerPackage, importCerPackage, verifyCerPackage. New types: AiCerPackage, CreateCerPackageParams. Additive only — no changes to CER hashing, canonicalization, or verification semantics. 466 total tests; all prior bundles verify identically |
| v0.13.0 | Project Bundle support: createProjectBundle() + verifyProjectBundle(). New artifact type cer.project.bundle.v1 — groups multiple certified CERs into one portable, self-verifiable record. Supports tightly connected agentic workflows, partial chains, and loose project-scoped collections. New types: ProjectBundle, ProjectBundleIntegrity, ProjectBundleStepEntry, ProjectBundleStepType, ProjectBundleStepModelIdentity, ProjectBundleVerifyResult, ProjectBundleStepVerifyResult, CreateProjectBundleParams, CreateProjectBundleStepParams. Two new CerVerifyCode values: PROJECT_HASH_MISMATCH, STEP_REGISTRY_MISMATCH. Strictly additive — 530 total tests; all prior bundles verify identically |
| v0.14.0 | Browser-safe async verification. New async mirrors of all verification functions using globalThis.crypto.subtle (Web Crypto API) instead of Node's crypto.createHash. Browser apps and edge workers can now use the SDK's own verification model without maintaining a separate reimplementation. New exports: verifyCerAsync(), verifyProjectBundleAsync(), verifySnapshotAsync(), sha256HexAsync(), hashUtf8Async(), hashCanonicalJsonAsync(), computeInputHashAsync(), computeOutputHashAsync(). Produces byte-for-byte identical outcomes to the sync counterparts for any well-formed bundle. Compatible with Node 18+, browsers, Cloudflare Workers, Vercel Edge, Deno. Strictly additive — 558 total tests (28 new Group F tests including cross-runtime behavioral alignment); all existing sync functions unchanged |
| v1.0.0 | Planned: API stabilization, freeze public API surface |
Releasing
cd packages/ai-execution
npm run build
npm test
npm publish --access publicThe release script automates build, test, version bump, and publish:
npm run releaseLicense
MIT
