Signals SDK
@nexart/signals v0.8.2 — protocol-agnostic structured execution context with deterministic capture, integrity hashing, replay-safe diffing, and a builder API.
Overview
@nexart/signals captures structured execution context as an ordered, hashable, replay-safe array of signals. It is protocol-agnostic, fully optional, and independent of @nexart/ai-execution.
- Does not define governance semantics or enforce policy.
- Does not interpret the meaning of signals.
- Validates structure, normalizes to safe defaults, and (optionally) hashes for tamper-detection.
For how signals attach to a CER, see Execution Context and Signals and Context Signals.
Install
npm install @nexart/signals2-minute quickstart
import { createContext } from '@nexart/signals';
const ctx = createContext();
ctx.step('fetch', { url });
ctx.step('transform');
ctx.step('store');
await ctx.certify({
provider: 'openai',
model: 'gpt-4o-mini',
input,
output,
});Auto-step, auto-timestamp, signals injected into the CER for you. @nexart/ai-execution is a peer dependency for ctx.certify().
Capability tiers
All layers are additive. v0.1 code keeps working unchanged.
| Version | Layer | What it adds |
|---|---|---|
| v0.1 | Core | createSignal, createSignalCollector, normalization, defaults, ordered export |
| v0.2 | Deterministic mode | deterministic: true, lock(), validate() |
| v0.3 | Context integrity | hashSignals(), validateSignals(), exportWithHash() |
| v0.4 | Structured context | findByType, findByStep, filter, diffSignals() |
| v0.5 | Execution Context object | createExecutionContext() with validate, equals, toJSON |
| v0.6 | Snapshot + replay | exportContext, importContext (tamper-detection), compareContexts |
| v0.7 | Integrity contract | assertContextDeterministic, freezeContext, signaturePayload(), summary() |
| v0.8 | Builder DX layer | createContext() + step, wrap, start, input, output, tool, decision, certify |
Signal shape
Every NexArtSignal has exactly these fields. All fields are always present, no undefined values.
| Field | Type | Default | Description |
|---|---|---|---|
| type | string | required | Signal category, free-form (e.g. "approval", "deploy") |
| source | string | required | Upstream system or protocol, free-form |
| step | number | 0 / auto | Position in sequence. Auto-assigned in insertion order |
| timestamp | string | current time | ISO 8601 |
| actor | string | "unknown" | Who produced this 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 |
v0.1 Core — createSignalCollector
The original capture API. Still works unchanged.
import { createSignal, createSignalCollector } from '@nexart/signals';
const collector = createSignalCollector({ defaultSource: 'my-pipeline' });
collector.add({ type: 'fetch', payload: { url: '...' } });
collector.add({ type: 'transform', actor: 'etl-bot' });
collector.add({ type: 'store', status: 'ok' });
const collection = collector.export();
// { signals: [...], count: 3, exportedAt: '2026-...' }v0.2 Deterministic mode
Same code, same inputs, bit-identical signals every run.
const collector = createSignalCollector({ deterministic: true });
collector.add({
type: 'approval',
source: 'ci',
step: 0, // required in deterministic mode
timestamp: '2026-03-17T00:00:00.000Z', // required in deterministic mode
});In deterministic mode:
stepMUST be supplied explicitly on everyadd(). No insertion-index fallback.timestampMUST be supplied explicitly on everyadd(). NoDate.now()fallback.add()throws synchronously when these constraints are violated.
collector.lock()
Freeze the collector to guarantee immutability before hashing or export. Idempotent.
collector.lock();
collector.add({ /* ... */ }); // throws: 'cannot add() after lock()'
collector.export(); // still works
collector.exportWithHash(); // still works
collector.validate(); // still works
collector.locked; // truecollector.validate()
Check the current buffer for structural integrity. Returns { ok, errors }. Verifies required fields, finite step, parseable timestamp, plain-object payload, no duplicate steps.
v0.3 Context integrity
Make signal collections tamper-evident before certification.
import { hashSignals, validateSignals } from '@nexart/signals';
const contextHash = hashSignals(collection.signals);
// 'sha256:9f3c...' — stable across environments
const { ok, errors } = validateSignals(restoredSignals);
if (!ok) throw new Error(errors.join('; '));
// Or both at once:
const { signals, count, exportedAt, contextHash: h } = collector.exportWithHash();Hash algorithm:
- Sort signals by
step(ascending, stable). - Canonicalize each signal: object keys sorted alphabetically at every level.
- Serialize as canonical JSON.
- sha256 to
sha256:<64-hex>.
The hash excludes exportedAt and any wall-clock metadata.
v0.4 Structured context
Signals as a queryable, diffable execution context.
collector.findByType('approval');
collector.findByStep(2);
collector.filter({ type: 'deploy', status: 'ok' });
collector.filter((s) => s.payload.severity === 'high');The predicate-object form matches strict equality on top-level fields only. For payload-aware matching, use the function form.
import { diffSignals } from '@nexart/signals';
const { added, removed, changed } = diffSignals(before, after);
// added: signals in 'b' whose step is not in 'a'
// removed: signals in 'a' whose step is not in 'b'
// changed: signals at the same step whose canonical content differsv0.5 Execution Context object
A first-class wrapper: sorted, hashed, validatable, portable, immutable by default.
import { createExecutionContext } from '@nexart/signals';
const ctx = createExecutionContext({ signals: collector.export().signals });
ctx.signals; // sorted by step, frozen array
ctx.contextHash; // 'sha256:...' — stable across runs and machines
ctx.createdAt; // ISO 8601 of construction
ctx.validate(); // { ok, errors }
ctx.equals(other); // hash-based equality
ctx.toJSON(); // canonical, portable snapshotHow context binds to execution
- Capture signals via
createSignalCollector({ deterministic: true }). collector.lock()and build anExecutionContextfromcollector.export().signals.- Optional:
freezeContext(ctx)for deep-immutability. - Bind
ctx.contextHash(orctx.signaturePayload()) into your execution record. - To verify: reconstruct from stored signals and check
hashSignals(signals) === storedContextHash.
v0.6 Snapshot and replay
import { exportContext, importContext, compareContexts } from '@nexart/signals';
// Persist
const snapshot = exportContext(ctx);
fs.writeFileSync('ctx.json', JSON.stringify(snapshot));
// Replay (tamper-detection runs automatically)
const restored = importContext(fs.readFileSync('ctx.json', 'utf8'));
restored.equals(ctx); // true
// Compare
const r = compareContexts(ctx, restored);
// { equal, hashEqual, diff: { added, removed, changed } }importContext recomputes the hash from the snapshot's signals and throws contextHash mismatch if it does not match the stored hash.
v0.7 Integrity contract
import {
assertContextDeterministic,
freezeContext,
} from '@nexart/signals';
// Throws on validation errors or hash mismatch
assertContextDeterministic(ctx);
// Deep-freeze signals + payloads (recursive). Idempotent.
const frozen = freezeContext(ctx);
// Canonical signing payload — excludes createdAt
const payload = ctx.signaturePayload();
// Lightweight overview for UI surfaces
ctx.summary();
// { count, types: { approval: 1, deploy: 1 }, stepRange: { min, max } }v0.8 Builder API — createContext()
The high-level entry point. Backward-compatible with everything below.
Capture with step()
const ctx = createContext();
// Convenience form
ctx.step('approval', { actor: 'alice', approved: true });
ctx.step('deploy', { env: 'prod' });
ctx.step('verify');
// Power-user form (full CreateSignalInput)
ctx.step({
type: 'review',
source: 'github',
actor: 'bob',
status: 'pending',
payload: { pr: 42 },
});
ctx.signals // current signal array, sorted by step
ctx.hash // canonical contextHash
ctx.size // count
ctx.locked // boolean
ctx.lock() // freeze the underlying collector
ctx.snapshot() // → immutable ExecutionContext (v0.5)Auto-instrumentation: wrap() and start()
const result = await ctx.wrap('llm_call', async () => {
return await openai.chat(...);
});
// emits llm_call.start, then llm_call.end with { status, duration_ms }
// re-throws errors after emitting end with status='error'
// Manual span
const span = ctx.start('tool_call', { name: 'search' });
try {
const results = await search(query);
span.end({ status: 'ok', payload: { count: results.length } });
} catch (e) {
span.end({ status: 'error' });
throw e;
}wrap() and start() record wall-clock duration and are disabled in deterministic mode. Use step() with explicit step + timestamp instead.Agent-friendly helpers
Lightweight wrappers around step(). They do not enforce meaning.
ctx.input({ q: 'who is the user' });
ctx.tool('search', { query: 'foo' });
ctx.decision('route', { selected: 'fraud-check' });
ctx.output({ answer: '...' });Direct certification: ctx.certify()
const bundle = await ctx.certify({
provider: 'openai',
model: 'gpt-4o-mini',
input,
output,
parameters: { temperature: 0, maxTokens: 1024, topP: null, seed: null },
});
// For tests or full decoupling, inject a custom certifier
await ctx.certify(
{ provider, model, input, output },
{ certifier: (params) => myCustomCertify(params) },
);
// Optional pre-freeze for tamper-evident handoff
await ctx.certify(decisionInput, { freeze: true });Internally, ctx.certify() lazily imports @nexart/ai-execution and calls certifyDecision({ ...input, signals: ctx.signals }). Install @nexart/ai-execution as a peer when you need certification.
Debugging
const view = ctx.debug();
// {
// count, hash, types: { fetch: 1, ... },
// timeline: [{ step, type, timestamp, status, actor, source }, ...]
// }
ctx.print();
// ExecutionContext (3 signals)
// hash: sha256:...
// types: fetch=1, transform=1, store=1
// timeline:
// [ 0] 2026-04-26T... fetch status=ok actor=agent source=agentReplay-safe signals — full example
import {
createSignalCollector,
hashSignals,
validateSignals,
diffSignals,
} from '@nexart/signals';
// ── Capture ────────────────────────────────────────────────
const c = createSignalCollector({ deterministic: true });
c.add({ type: 'approval', source: 'gh', step: 0, timestamp: '2026-03-17T00:00:00.000Z', actor: 'alice', payload: { pr: 42 } });
c.add({ type: 'deploy', source: 'ci', step: 1, timestamp: '2026-03-17T00:01:00.000Z', actor: 'ci-bot', payload: { env: 'prod' } });
c.lock();
const v = c.validate();
if (!v.ok) throw new Error(v.errors.join('; '));
const { signals, contextHash } = c.exportWithHash();
// ── Persist 'signals' and 'contextHash' somewhere ──────────
// ── Later: verify nothing was tampered with ────────────────
if (hashSignals(signals) !== contextHash) {
throw new Error('Signal collection has been tampered with');
}
// ── Compare against a prior run ────────────────────────────
const diff = diffSignals(previousSignals, signals);
console.log(`+${diff.added.length} -${diff.removed.length} ~${diff.changed.length}`);Integration with @nexart/ai-execution
NexArtSignal[] is structurally identical to CerContextSignal[] in @nexart/ai-execution. No casting needed.
import { createSignalCollector } from '@nexart/signals';
import { certifyDecision, verifyCer } from '@nexart/ai-execution';
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();
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,
});
verifyCer(bundle).ok; // true
bundle.context?.signals.length; // 2The CER's certificateHash covers the signals in canonical form, identical to (and independently checkable with) hashSignals(signals).
API reference
Functions
| Symbol | Since | Description |
|---|---|---|
| createSignal(input) | v0.1 | Normalize a single CreateSignalInput |
| createSignalCollector(options?) | v0.1 | Build a SignalCollector |
| hashSignals(signals) | v0.3 | Deterministic sha256 over a signal array |
| canonicalJson(value) | v0.3 | Sorted-key, undefined-stripped JSON |
| canonicalize(value) | v0.3 | Recursive key-sorted clone |
| sortSignals(signals) | v0.3 | Stable sort by step (does not mutate) |
| validateSignals(signals) | v0.3 | Structural integrity check |
| diffSignals(a, b) | v0.4 | Step-keyed diff |
| createExecutionContext(input) | v0.5 | Build a frozen ExecutionContext |
| exportContext(ctx) | v0.6 | Canonical snapshot |
| importContext(json) | v0.6 | Reconstruct with hash tamper-detection |
| compareContexts(a, b) | v0.6 | { equal, hashEqual, diff } |
| assertContextDeterministic(ctx) | v0.7 | Throws on invalid signals or hash mismatch |
| freezeContext(ctx) | v0.7 | Deep-freeze signals + payloads |
| createContext(options?) | v0.8 | Builder-friendly mutable context |
| SIGNALS_VERSION | v0.1 | Package version string constant |
ContextBuilder methods (v0.8)
| Method / property | Description |
|---|---|
| step(type, payload?) / step(input) | Add a signal. Auto-step + auto-timestamp by default. |
| wrap(type, fn) | Auto-instrument an async fn. Disabled in deterministic mode. |
| start(type, payload?) → Span | Open a span. Disabled in deterministic mode. |
| input(data) / output(data) | Convenience for step('input'/'output', { data }). |
| tool(name, payload?) | Convenience for step('tool', { name, ...payload }). |
| decision(name, payload?) | Convenience for step('decision', { name, ...payload }). |
| certify(input, options?) | Lazily calls @nexart/ai-execution.certifyDecision, injecting signals. |
| signals / hash / size / locked | Live read-only views of the underlying collector. |
| lock() | Lock the collector. |
| snapshot() → ExecutionContext | Build the immutable v0.5 ExecutionContext. |
| debug() → ContextDebugView | { count, hash, types, timeline } |
| print() → string | Pretty-print the timeline. |
ExecutionContext methods
| Method | Since | Description |
|---|---|---|
| validate() | v0.5 | validateSignals() over the underlying signals |
| equals(other) | v0.5 | Hash-based equality |
| toJSON() | v0.5 | Canonical ContextSnapshot |
| signaturePayload() | v0.7 | Canonical signing payload, excludes createdAt |
| summary() | v0.7 | { count, types, stepRange } |
Integrity model
- Passive. Signals describe execution. They never influence it.
- Deterministic-capable. With
deterministic: trueand pinned timestamps, same input produces identical signals and identical hash. - Protocol-agnostic. No business meaning, no policy engine, no framework coupling.
- Composable. Works with any pipeline, agent, or workflow that produces or consumes a
NexArtSignal[]. - Tamper-evident.
contextHashchanges if any signal field changes anywhere. - Stable serialization. Canonical JSON (sorted keys at every level),
undefinedstripped, no whitespace.
Backward compatibility
Every v0.1 API and behavior is preserved exactly:
createSignal()andcreateSignalCollector()signatures unchanged.- All v0.1 fields (
type,source,step,timestamp,actor,status,payload) unchanged. - All defaults unchanged.
- New methods are pure additions on the same returned object.
Related
- Execution Context and Signals: how signals attach to a CER.
- Context Signals: protocol-level concept.
- AI Execution SDK:
certifyDecisionand thesignalsparameter. - Verification Semantics: how hash-bound vs supplemental signals are reported.