@nexart/signals
v0.8.2
Published
Minimal, protocol-agnostic signal capture SDK for optional CER context evidence
Maintainers
Readme
@nexart/signals v0.8.0
Protocol-agnostic structured execution context SDK with deterministic capture, integrity hashing, replay-safe diffing, a first-class ExecutionContext object, and a builder-friendly createContext() API designed to make agent execution context capture feel effortless.
Originally a minimal signal capture utility for binding upstream evidence into Certified Execution Records (CERs). v0.2 → v0.8 layered seven additive capability tiers on top of the same simple core, without breaking a single v0.1 user.
2-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,
});That's it. Auto-step, auto-timestamp, signals injected into the CER for you.
- Does not define governance semantics or enforce policy
- Does not interpret the meaning of signals
- Validates structure only, normalizes to safe defaults, and (optionally) hashes for tamper-detection
- Fully optional and independent of
@nexart/ai-execution
Install
npm install @nexart/signalsCapability tiers
| Version | Layer | What it adds |
|---|---|---|
| v0.1 | Core | createSignal, createSignalCollector, normalization, defaults, ordered export |
| v0.2 | Deterministic mode | deterministic: true option, lock(), validate() — no auto-generated values |
| v0.3 | Context integrity | hashSignals(), validateSignals(), exportWithHash() — tamper-evident before certification |
| v0.4 | Structured context | findByType, findByStep, filter, diffSignals() — signals as queryable execution context |
| v0.5 | Execution Context object | createExecutionContext() — first-class wrapper with validate, equals, toJSON, immutable by default |
| v0.6 | Snapshot + replay | exportContext, importContext (with tamper-detection), compareContexts |
| v0.7 | Integrity contract | assertContextDeterministic, freezeContext (deep), context.signaturePayload(), context.summary() |
| v0.8 | Builder DX layer | createContext() + step, wrap, start/Span, input/output/tool/decision, certify, debug, print |
All layers are additive. Existing v0.1 code keeps working unchanged.
Quick start (v0.1 — still works)
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-...' }Signal shape
Every NexArtSignal has exactly these fields — all always present, no undefined values:
| Field | Type | Default | Description |
|---|---|---|---|
| type | string | required | Signal category — free-form (e.g. "approval", "deploy", "audit") |
| source | string | required | Upstream system or protocol — free-form (e.g. "github-actions", "linear") |
| step | number | 0 / auto | Position in sequence. Auto-assigned in insertion order by the collector |
| 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", "skipped") |
| payload | Record<string, unknown> | {} | Opaque upstream data — NexArt does not interpret this |
v0.2 — Deterministic mode
Opt in to fully deterministic signal collection. Same code, same inputs → bit-identical signals every run, every machine.
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.actor,status,payloaddefaults still apply — they are caller-controlled and don't vary across runs.
collector.lock()
Freeze the collector to guarantee immutability before hashing or export:
collector.lock();
collector.add({ /* ... */ }); // throws: 'cannot add() after lock()'
collector.export(); // still works
collector.exportWithHash(); // still works
collector.validate(); // still works
collector.locked; // truelock() is idempotent.
collector.validate()
Check the current buffer for structural integrity before binding into a CER:
const result = collector.validate();
// { ok: boolean, errors: string[] }Checks:
- All required fields present
stepis a finite numbertimestampis a parseable datepayloadis a plain object- No two signals share the same
step
v0.3 — Context integrity
Make signal collections tamper-evident before certification.
hashSignals(signals)
Deterministic content hash over an array of signals:
import { hashSignals } from '@nexart/signals';
const contextHash = hashSignals(collection.signals);
// 'sha256:9f3c...' — stable across environmentsAlgorithm:
- Sort signals by
step(ascending, stable). - Canonicalize each signal — object keys sorted alphabetically at every level (including nested payload).
- Serialize as canonical JSON.
- sha256 →
sha256:<64-hex>.
The hash excludes exportedAt and any wall-clock metadata. Same signals → same hash, regardless of insertion order or environment.
validateSignals(signals)
Standalone validation for any NexArtSignal[] (e.g. signals received from another process or restored from disk):
import { validateSignals } from '@nexart/signals';
const { ok, errors } = validateSignals(restoredSignals);
if (!ok) throw new Error(errors.join('; '));collector.exportWithHash()
Export + hash in a single call:
const { signals, count, exportedAt, contextHash } = collector.exportWithHash();
// contextHash === hashSignals(signals)v0.4 — Structured context
Signals as a queryable, diffable execution context — not just logs.
Query helpers
collector.findByType('approval');
// → NexArtSignal[] (sorted by step)
collector.findByStep(2);
// → NexArtSignal[] (one element per matching step)
collector.filter({ type: 'deploy', status: 'ok' });
// → NexArtSignal[] (top-level field match, sorted by step)
collector.filter((s) => s.payload.severity === 'high');
// → NexArtSignal[] (custom predicate)The predicate-object form matches strict equality on top-level fields only. For payload-aware matching, use the function form.
diffSignals(a, b)
Compare two signal collections, matched by step:
import { diffSignals } from '@nexart/signals';
const before = previousRun.signals;
const after = currentRun.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 differs
// → { step, before, after }All output arrays are sorted by step ascending. Use validateSignals() first if you need to enforce unique steps in either input.
Execution Context (v0.5+)
Once you have a signal collection, the next layer up is a first-class ExecutionContext — a sorted, hashed, validatable, portable object that represents the canonical evidence bundle for a single piece of execution work.
import { createExecutionContext } from '@nexart/signals';
const collector = createSignalCollector({ deterministic: true });
collector.add({ type: 'approval', source: 'gh', step: 0, timestamp: '2026-03-17T00:00:00.000Z' });
collector.add({ type: 'deploy', source: 'ci', step: 1, timestamp: '2026-03-17T00:01:00.000Z' });
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 snapshot — same as exportContext(ctx)What contextHash means
contextHash is a sha256:<64-hex> digest computed from the canonical serialization of the signals array — sorted by step, with object keys sorted alphabetically at every level (including nested payload), undefined stripped, no whitespace.
It does not include createdAt or any wall-clock metadata. Two contexts with the same signals produce the same contextHash, regardless of when or where they were built. This is the property that makes context bindable to execution: the certifying record (CER, signed envelope, audit log entry, etc.) commits to contextHash, and any downstream verifier can reconstruct it from the signals alone.
How context binds to execution
The intended pattern:
- Capture signals during execution 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 alongside the actual outputs. - To verify: reconstruct the context from stored signals and check
hashSignals(signals) === storedContextHash.
The signals package is passive — it never influences execution. It only describes it, in a form that survives travel and can be re-checked.
Snapshot, replay, and comparison (v0.6+)
import { exportContext, importContext, compareContexts } from '@nexart/signals';
// Persist
const snapshot = exportContext(ctx);
fs.writeFileSync('ctx.json', JSON.stringify(snapshot));
// Replay
const restored = importContext(fs.readFileSync('ctx.json', 'utf8'));
restored.equals(ctx); // true — identity preserved across the wire
// Compare
const r = compareContexts(ctx, restored);
// {
// equal: true, // strict canonical equality
// hashEqual: true, // contextHash match (cheapest check)
// diff: { added: [], removed: [], changed: [] }, // structured diff
// }importContext performs tamper-detection automatically: it recomputes the hash from the snapshot's signals and throws contextHash mismatch if it doesn't match the stored contextHash. Round-tripping a clean snapshot always preserves identity.
Replay use cases
- Reproduce a past run: persist the context snapshot alongside the output; on rerun, import and compare.
- Audit trail comparison:
compareContexts(yesterdayCtx, todayCtx)to see which signals changed between runs. - Cross-environment verification: ship a snapshot from prod to a staging verifier; tamper-detection ensures fidelity.
- Pre-certification freeze:
freezeContext()before passing to a CER builder so nothing can mutate between hash and signature.
Integrity contract (v0.7+)
Three primitives make the context safe to bind to a verifiable execution record:
assertContextDeterministic(ctx)
Throws if the context fails the determinism contract:
- structural validation (
validateSignals) returns errors — missing fields, non-finite step, unparseable timestamp, malformed payload, duplicate steps - the stored
contextHashno longer matches the recomputed hash (i.e. signals were tampered with after construction)
assertContextDeterministic(ctx); // throws on any violationfreezeContext(ctx)
Deep-freezes the signals array, every signal object, and every signal's payload (recursively). Idempotent. Returns the same context for chaining.
const ctx = freezeContext(createExecutionContext({ signals }));
ctx.signals[0].payload.x = 1; // throws (in strict mode) — fully immutablecontext.signaturePayload()
Returns a canonical string suitable for signing or CER binding. Includes contextHash and signals only — excludes createdAt, because the signature must depend on content alone.
const payload = ctx.signaturePayload();
const sig = sign(privateKey, payload);
// Persist { snapshot: ctx.toJSON(), signature: sig }context.summary()
Lightweight overview useful for UI surfaces and verify layers:
ctx.summary();
// {
// count: 2,
// types: { approval: 1, deploy: 1 },
// stepRange: { min: 0, max: 1 },
// }Builder API (v0.8) — createContext()
The new high-level entry point. Returns a mutable builder over a SignalCollector with the lowest possible cognitive load. Backward-compatible with everything below — createSignal, createSignalCollector, createExecutionContext, snapshots, diffs all still work unchanged.
Capture — step()
Two forms. Convenience first:
const ctx = createContext();
ctx.step('approval', { actor: 'alice', approved: true });
ctx.step('deploy', { env: 'prod' });
ctx.step('verify');Power-user form (full CreateSignalInput) when you need explicit source, actor, or status:
ctx.step({
type: 'review',
source: 'github',
actor: 'bob',
status: 'pending',
payload: { pr: 42 },
});Inspection:
ctx.signals // current signal array, sorted by step
ctx.hash // canonical contextHash over the current signals
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 an end signal with status='error'For more control, open a span explicitly:
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() are not available in deterministic mode (they record wall-clock duration). Use step() with explicit step + timestamp instead.
Zero-config mode
const ctx = createContext({ auto: true });The auto: true flag is purely declarative — auto-step, auto-timestamp, default actor/source are the default behavior already. The flag exists so the intent is obvious in code review.
Agent-friendly semantic helpers
Lightweight wrappers around step(). They do not enforce meaning or add validation rules — purely convenience for common AI/agent patterns:
ctx.input({ q: 'who is the user' });
ctx.tool('search', { query: 'foo' });
ctx.decision('route', { selected: 'fraud-check' });
ctx.output({ answer: '...' });Emits signals with type of input, tool, decision, output respectively, with the supplied data placed in the payload.
Direct certification — ctx.certify()
One call. Signals are injected automatically:
const bundle = await ctx.certify({
provider: 'openai',
model: 'gpt-4o-mini',
input,
output,
parameters: { temperature: 0 },
});Internally lazily imports @nexart/ai-execution and calls certifyDecision({ ...input, signals: ctx.signals }). The signals package itself has no hard dependency on @nexart/ai-execution — install it as a peer when you need certification.
For tests or full decoupling, pass an injectable 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 });Debugging UX
Structured view:
const view = ctx.debug();
// {
// count: 3,
// hash: 'sha256:...',
// types: { fetch: 1, transform: 1, store: 1 },
// timeline: [{ step, type, timestamp, status, actor, source }, ...],
// }Human-readable timeline (logs to console and returns the string):
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=agent
// [ 1] 2026-04-26T... transform status=ok actor=agent source=agent
// [ 2] 2026-04-26T... store status=ok actor=agent source=agentProgressive complexity
| Layer | Use when |
|---|---|
| createContext() + step() | You're building an agent / pipeline and want minimum boilerplate |
| + wrap() / start() | You want auto-duration and status capture |
| + certify() | You're emitting CERs and want signals injected automatically |
| createSignalCollector({ deterministic: true }) + createExecutionContext | You need bit-for-bit reproducible context for verification / replay |
The deterministic mode and the builder are not in tension — createContext({ deterministic: true }) is a valid combination. It just disables the wall-clock helpers (wrap, start) and forces explicit step + timestamp on every step() call.
Replay-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}`);API reference
Functions
| Symbol | Since | Description |
|---|---|---|
| createSignal(input) | v0.1 | Normalize a single CreateSignalInput into a NexArtSignal |
| createSignalCollector(options?) | v0.1 | Build a SignalCollector |
| hashSignals(signals) | v0.3 | Deterministic sha256:<hex> over a signal array |
| canonicalJson(value) | v0.3 | Sorted-key, undefined-stripped JSON serialization |
| canonicalize(value) | v0.3 | Recursive key-sorted clone of a value |
| sortSignals(signals) | v0.3 | Stable sort by step (does not mutate) |
| validateSignals(signals) | v0.3 | Structural integrity check returning { ok, errors } |
| diffSignals(a, b) | v0.4 | Step-keyed diff returning { added, removed, changed } |
| createExecutionContext(input) | v0.5 | Build a frozen ExecutionContext from signals |
| exportContext(ctx) | v0.6 | Canonical snapshot — same as ctx.toJSON() |
| importContext(json) | v0.6 | Reconstruct a context 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. Idempotent |
| createContext(options?) | v0.8 | Builder-friendly mutable context (DX layer) |
| 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. Emits <type>.start and <type>.end (with status, duration_ms). Re-throws errors. Disabled in deterministic mode. |
| start(type, payload?) → Span | Open a span. span.end({ status?, payload? }) emits the end signal. 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. Accepts { certifier } for testing. |
| signals / hash / size / locked | Live read-only views of the underlying collector. |
| lock() | Lock the collector — no further mutations. |
| snapshot() → ExecutionContext | Build the immutable v0.5 ExecutionContext. |
| debug() → ContextDebugView | { count, hash, types, timeline }. |
| print() → string | Pretty-print the timeline. Logs and returns the string. |
ExecutionContext methods
| Method | Since | Description |
|---|---|---|
| validate() | v0.5 | validateSignals() over the underlying signals |
| equals(other) | v0.5 | Hash-based equality with another context |
| toJSON() | v0.5 | Canonical ContextSnapshot — { signals, contextHash, createdAt } |
| signaturePayload() | v0.7 | Canonical signing payload, excludes createdAt |
| summary() | v0.7 | { count, types, stepRange } |
Collector methods
| Method / property | Since | Description |
|---|---|---|
| add(input) | v0.1 | Append a signal. Throws after lock(). Throws on missing required fields in deterministic mode. |
| export() | v0.1 | { signals, count, exportedAt } — sorted by step |
| exportWithHash() | v0.3 | Same as export() plus contextHash |
| validate() | v0.2 | Check current buffer; returns { ok, errors } |
| lock() | v0.2 | Freeze collector. Idempotent. |
| findByType(type) | v0.4 | Signals matching type, sorted by step |
| findByStep(step) | v0.4 | Signals matching step |
| filter(predicate) | v0.4 | Predicate object (top-level match) or function |
| size | v0.1 | Current signal count |
| locked | v0.2 | true after lock() |
Types
NexArtSignal, CreateSignalInput, CollectorOptions, SignalCollection, SignalCollector, SignalCollectionWithHash, SignalsValidationResult, SignalChange, SignalsDiff, SignalFilterPredicate, ExecutionContext, CreateExecutionContextInput, ContextSnapshot, ContextComparison, ContextSummary, ContextBuilder, ContextBuilderOptions, ContextCertifyInput, ContextCertifyOptions, ContextDebugView, ContextTimelineEntry, Span.
Integrity model
| Property | Guarantee |
|---|---|
| Passive | Signals describe execution. They never influence it. |
| Deterministic-capable | With deterministic: true + pinned timestamps, same input → identical signals → 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 | contextHash changes if any signal field changes anywhere. |
| Stable serialization | Canonical JSON (sorted keys at every level), undefined stripped, no whitespace. |
Integration with @nexart/ai-execution
NexArtSignal[] is structurally identical to CerContextSignal[] in @nexart/ai-execution — no casting or conversion needed. Pass collection.signals directly to any certify call and it will be sealed into the certificateHash alongside the execution record.
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).
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 add(),export(),sizework exactly as in v0.1- All defaults (
step: 0/auto,actor: "unknown",status: "ok",payload: {}) unchanged defaultSourceanddefaultActorcollector options unchanged
The new methods (exportWithHash, validate, lock, findByType, findByStep, filter, locked) are pure additions on the same returned object. Existing code that only uses v0.1 methods is byte-for-byte equivalent in behavior.
Version history
| Version | Description |
|---|---|
| v0.8.0 | Phases 7–12 (DX layer): createContext() + step/wrap/start/input/output/tool/decision/certify/debug/print |
| v0.7.0 | Phase 6: assertContextDeterministic, freezeContext, context.signaturePayload(), context.summary() |
| v0.6.0 | Phase 5: exportContext, importContext (with tamper-detection), compareContexts |
| v0.5.0 | Phase 4: createExecutionContext, ExecutionContext.{validate,equals,toJSON}, immutable-by-default |
| v0.4.0 | Phase 3: findByType, findByStep, filter, diffSignals, normalization guarantees |
| v0.3.0 | Phase 2: hashSignals, canonicalJson, validateSignals, exportWithHash, sortSignals |
| v0.2.0 | Phase 1: deterministic mode, lock(), validate(), locked |
| v0.1.0 | Initial release: core capture, normalization, defaults, ordered export |
