npm package: @nexart/ai-execution
Description: AI Execution Integrity — tamper-evident records and Certified Execution Records (CER) for AI operations
Author: NexArt
Latest version: 0.14.0

# @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

```bash
npm install @nexart/ai-execution
```

## Quick Start

### Single Decision (3 lines)

```typescript
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

```typescript
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); // true
```

### Agentic Multi-Step Workflow

```typescript
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)

```typescript
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)

```typescript
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

```json
{
  "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.

```typescript
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 result
```

## CER 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

```typescript
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 JSON
```

### Importing and verifying a package

```typescript
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 package
```

**Design constraints of package helpers:**
- Additive only — no changes to CER hashing, canonicalization, or `verifyCer()` semantics
- `verifyCerPackage` only verifies the inner `cer` bundle; 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 `sanitizeForAttestation` in v0.4.0+)
- Returns: `AttestationResult` with `attestationId`, `nodeRuntimeHash`, `certificateHash`, `protocolVersion`
- Default timeout: 10 seconds (configurable via `timeoutMs`)
- Validates: response `certificateHash` matches submitted bundle; all hashes in `sha256:<64hex>` format
- Throws: `CerAttestationError` on 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`:

```ts
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:**

```ts
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 proof
```

**Skip re-attestation when already attested:**

```ts
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 attested
```

- `getAttestationReceipt(bundle)` — extracts a normalized receipt from any supported shape (top-level fields or `bundle.meta.attestation`); returns `null` if required fields are missing, never throws
- `attestIfNeeded(bundle, options)` — skips the node call if a valid receipt is already present; prevents double-attestation
- `certifyAndAttestDecision(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:

```ts
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:**

```ts
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 `undefined` values at all nesting levels
- Rejects `BigInt`, functions, and symbols (throws)
- Safe to serialize with `JSON.stringify` or 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.

```typescript
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 `toolCalls` verify 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).

```typescript
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.

```typescript
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 valid
- `ok: false` with `INCOMPLETE_ARTIFACT` — step count mismatch
- `ok: false` with `CHAIN_BREAK_DETECTED` — stepIndex/prevStepHash/certificateHash mismatch; `breakAt` is 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` |

```typescript
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.

```typescript
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:

```typescript
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.

```typescript
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 snapshot
```

Each 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:

1. Applies `redactBeforeSeal()` to the original snapshot
2. Re-seals the redacted snapshot into a **new** bundle with a **new** `certificateHash`
3. Stores the original `certificateHash` in `meta.provenance.originalCertificateHash` as an informational cross-reference

```typescript
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

1. Object keys sorted lexicographically (Unicode codepoint order) at every nesting level.
2. No whitespace between tokens.
3. Array order preserved.
4. `null` serialized as `null`.
5. Numbers must be finite. `NaN`, `Infinity`, `-Infinity` rejected (throw).
6. `undefined` values in object properties omitted (key dropped).
7. `BigInt`, functions, `Symbol` rejected (throw).
8. 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.

```ts
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.

```ts
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

```ts
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:

```ts
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`):

```ts
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)

- `BaseCallbackHandler` integration for automatic mid-chain CER capture at `onLLMEnd`
- LangSmith trace correlation via `metadata.runId` → `snapshot.conversationId`
- Separate `@nexart/langchain` package 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`, and `payload` are all free-form strings and objects
- Included in `certificateHash` only 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` — `toolCalls` records tool invocations *within* a run; signals record upstream context *around* a run

### Quick example

```ts
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 bundle
```

`NexArtSignal[]` 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

```ts
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:

```ts
const tampered = { ...bundle, context: { signals: [...altered] } };
verifyCer(tampered).ok;   // false
verifyCer(tampered).code; // 'CERTIFICATE_HASH_MISMATCH'
```

### Exported types

```ts
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 `CerAiExecutionBundle`s 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:

```typescript
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:**

```typescript
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):**

```typescript
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.):**

```typescript
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:**

```typescript
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):
1. Discriminant — `bundleType === 'cer.project.bundle.v1'`
2. Required top-level fields
3. `totalSteps` consistency
4. `stepId` uniqueness
5. `executionId` uniqueness
6. No duplicate `sequence` values
7. Parent reference resolution (all `parentStepIds` must reference a known `stepId`)
8. Per-step cross-check — `executionId` + `certificateHash` in the registry match the embedded CER
9. Inner `verifyCer()` on every embedded CER bundle
10. `projectHash` recomputation over all material project metadata

**Result shape:**

```typescript
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 `verifyProjectBundle` to fail with a descriptive error
- Values need not be contiguous — `5, 10, 15` is 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.projectHash` is 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

```bash
cd packages/ai-execution
npm run build
npm test
npm publish --access public
```

The `release` script automates build, test, version bump, and publish:

```bash
npm run release
```

## License

MIT