What it is · Feature tour · Install · Usage · Per-language status · Platform
An audit log you can't quietly rewrite is worth more than one you can.
@smooai/auditchains every event into a per-org-per-day SHA-256 hash chain, serialized to byte-identical canonical JSON in five languages — so a hash computed by a Go service verifies against one computed in Rust, TypeScript, Python, or C#. That isn't a hope, it's a tested guarantee: all five implementations assert byte-for-byte against one shared parity corpus, and all five ship to their registries in version lockstep.
A polyglot client SDK for tamper-evident, SQL-queryable audit logging. Every service — in any language — gets one shared way to emit audit events that are:
- Canonical — a single
AuditEventschema, serialized to byte-identical canonical JSON regardless of language, so events are comparable and hashable everywhere. - Tamper-evident — each event's hash covers the previous event's hash (per org, per day), so any retroactive edit or deletion breaks every subsequent link.
- Trace-correlated — the W3C trace context rides in the wire envelope, so an audit row joins back to the exact request that caused it.
- SQL-queryable — events land in a structured store you query with plain SQL.
Native in TypeScript · Python · Rust · Go · .NET, with identical semantics — see the honest per-language status for the few places the surfaces still differ.
| Capability | What you get | |
|---|---|---|
| 🧬 | One corpus, five languages | Byte-for-byte parity, asserted in every language's CI — not claimed, tested |
| 🔗 | Tamper-evident hash chain | Per-org-per-day SHA-256 chain; edit one event, break every later link |
| 🔎 | Trace correlation | traceId/spanId on the envelope — never inside the hashed event |
| ♻️ | Same retry policy everywhere | 3 attempts, doubling backoff, 4xx fails fast — asserted from one shared policy |
| 📦 | Version-lockstep releases | v0.2.0 on npm, PyPI, crates.io, NuGet, and the Go module — same commit, same tag |
Audit trails are only trustworthy if a hash means the same thing everywhere. The canonical serializer and hash chain are held to a shared parity corpus — spec/parity-corpus.json, 8 fixtures, each a fixed input event with its expected canonical JSON and expected SHA-256:
All five test suites load this exact file (TS · Python · Rust · Go · .NET) and assert byte-for-byte equality — so a chain written by a Go service verifies cleanly in a Rust or TypeScript reader. A divergence is a CI failure, not a production surprise.
The corpus has a second half, chainFixtures: 11 whole chains, sealed by the real builder and then genuinely tampered with — a mutated field, a backdated timestamp, a reordered pair, a rewritten hashPrevious, a deleted middle event, a truncated head. Each carries the verdict every language must return (ok, brokenAt, and a shared failure code), so all five prove they detect tampering, not merely that they can hash. Sealing parity without detection parity is how a library ends up tamper-evident in one language and tamper-oblivious in four.
Each event's hashCurrent is SHA-256(canonical-JSON(event minus hashCurrent)), and every event carries hashPrevious — the prior event's hash in the per-org-per-day chain. Rewrite or delete any event and every subsequent hash stops verifying.
%%{init: {'theme':'base','themeVariables':{
'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52',
'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif'}}}%%
flowchart LR
E1["event 1<br/>hashPrevious: ∅<br/>hashCurrent: a3f…"] --> E2["event 2<br/>hashPrevious: a3f…<br/>hashCurrent: 9c1…"] --> E3["event 3<br/>hashPrevious: 9c1…<br/>hashCurrent: d47…"]
E2 -. "edit this event" .-> X["every later hash<br/>stops verifying"]
classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00;
classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011;
class X warm
class E1,E2,E3 teal
Verification replays the chain: recompute every hash and confirm each hashPrevious matches the prior hashCurrent. A ready-made verifier ships in all five languages — verifyChain (TS) · verify_chain (Python, Rust) · VerifyChain (Go) · HashChain.Verify (.NET) — and each returns the same verdict: ok, the index of the first broken link, and a shared failure code (hash_previous_mismatch when the LINK is wrong, hash_current_mismatch when the event BODY was edited after sealing). Pass the chain head you already hold when verifying a slice that continues an existing chain rather than one starting at the beginning of the org's day.
What replay cannot see. Deleting events from the tail of a chain leaves something that still verifies — every remaining link is genuine. Catching that needs an external anchor (a stored chain head, an expected count) compared against the last event's
hashCurrent.okmeans nothing here was altered, not nothing is missing. The corpus pins this as an explicit fixture (truncated_chain_tail_removed, expectedok: true) so the limit stays visible instead of being mistaken for coverage.
emit POSTs the canonical JSON of an envelope, not the bare event:
{
"event": { "…the sealed event…": "…" },
"spanId": "00f067aa0ba902b7",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}traceId / spanId are the W3C trace context captured at emit time, so an audit row can be joined back to the request that caused it. They live in the envelope, one level above the event, and never inside it: the hash chain covers canonical-JSON(event minus hashCurrent), so a field added to the event would change every hash and invalidate every chain already in a store. The bytes under "event" are byte-identical with or without a trace active — every language asserts the parity corpus inside an active span as well as outside one.
Both ids are omitted entirely when there is no valid span — never an empty string, never the all-zero id an unregistered SDK hands you. OpenTelemetry is optional everywhere: TypeScript (optional @opentelemetry/api peer dep) · Rust (otel cargo feature, off by default) · Python (pip install smooai-audit[otel], guarded import) · Go (trace API only, no SDK — reads the span off the ctx you already pass) · .NET (Activity.Current from the BCL, no new package at all).
Every language exposes the same four things:
| Concept | What it does |
|---|---|
AuditEvent |
The canonical event schema — id, organizationId, actorType, actorId, action, resource {type, id}, outcome, metadata, timestamp, plus optional context (actorEmail, reason, sessionId, conversationId, ipAddress, userAgent, geoCountry, diff) and the chain fields hashPrevious? / hashCurrent?. |
canonicalJson(event) |
Deterministic, byte-identical JSON serialization. |
computeEventHash(event) / buildHashChain(events) |
The per-org-per-day SHA-256 hash chain (HashChain.ComputeEventHash / HashChain.Build in .NET). |
verifyChain(events, genesisPreviousHash?) |
Replays the chain and reports the first broken link (HashChain.Verify in .NET). |
AuditClient / emit(event) |
Seals the event (stamps hashCurrent) and POSTs the canonical envelope to a configurable ingest endpoint with a bearer token. |
All five packages release in version lockstep — v0.2.0 everywhere, cut from the same commit:
| Language | Package | Install |
|---|---|---|
| TypeScript | @smooai/audit |
pnpm add @smooai/audit |
| Python | smooai-audit |
uv add smooai-audit (or pip install smooai-audit) |
| Rust | smooai-audit |
cargo add smooai-audit |
| Go | github.com/SmooAI/audit/go |
go get github.com/SmooAI/audit/go |
| .NET | SmooAI.Audit |
dotnet add package SmooAI.Audit |
TypeScript — full shape; the other languages mirror it.
import { AuditClient, type AuditEvent } from "@smooai/audit";
const client = new AuditClient({
endpoint: process.env.AUDIT_ENDPOINT!,
token: process.env.AUDIT_TOKEN!,
});
await client.emit({
id: "01HXXXXXXXXXXXXXXXXXXXXXXX", // ULID-like sortable id
organizationId: "org_123",
actorType: "user",
actorId: "user_abc",
action: "crm.contact_deleted",
resource: { type: "crm.contact", id: "c-42" },
outcome: "success",
metadata: { reason: "gdpr" },
timestamp: new Date().toISOString(),
});Python — snake_case construction, camelCase on the wire (pydantic aliases):
from smooai_audit import AuditClient, AuditClientOptions, AuditEvent, AuditResource
client = AuditClient(AuditClientOptions(endpoint=endpoint, token=token))
client.emit(AuditEvent(
id="01HXXXXXXXXXXXXXXXXXXXXXXX", organization_id="org_123",
actor_type="user", actor_id="user_abc", action="crm.contact_deleted",
resource=AuditResource(type="crm.contact", id="c-42"),
outcome="success", metadata={}, timestamp="2026-08-20T12:00:00.000Z",
))Rust
use smooai_audit::{AuditClient, AuditClientOptions};
let client = AuditClient::new(AuditClientOptions { endpoint, token });
client.emit(&event).await?; // or emit_with_trace(&event, Some(trace))Go
import audit "github.com/SmooAI/audit/go"
client := audit.NewClient(endpoint, token)
err := client.Emit(ctx, event) // trace context read from ctx.NET
using System.Text.Json.Nodes;
using SmooAI.Audit;
var client = new AuditClient(new AuditClientOptions { Endpoint = endpoint, Token = token });
await client.EmitAsync(new AuditEvent
{
Id = "01HXXXXXXXXXXXXXXXXXXXXXXX", OrganizationId = "org_123",
ActorType = "user", ActorId = "user_abc", Action = "crm.contact_deleted",
Resource = new AuditResource { Type = "crm.contact", Id = "c-42" },
Outcome = "success", Metadata = new JsonObject(),
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
});The core contract — canonical JSON, the hash chain, the emit envelope, trace correlation — is complete and parity-tested in all five languages. The surfaces around it are not yet symmetric, and you should know exactly where:
| Capability | TS | Python | Rust | Go | .NET |
|---|---|---|---|---|---|
| Canonical JSON + hash chain (parity-corpus-verified) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Envelope trace correlation (optional OTel) | ✅ | ✅ | ✅ | ✅ | ✅ |
emit client |
✅ async | ✅ sync + emit_async |
✅ async | ✅ sync (go it) |
✅ async |
| Retry with backoff on transient emit failure | ✅ | ✅ | ✅ | ✅ | ✅ |
| Emit failure surfaces to the caller | ✅ | ✅ | ✅ | ✅ | ✅ |
| Chain verification (corpus-verified against 11 tampered chains) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Chain builder name | buildHashChain |
build_hash_chain |
build_hash_chain |
BuildHashChain |
HashChain.Build |
| Chain verifier name | verifyChain |
verify_chain |
verify_chain |
VerifyChain |
HashChain.Verify |
Retry and error posture are now the same everywhere, and the numbers are asserted rather than restated: retryPolicy in spec/parity-corpus.json holds the defaults (3 attempts, 100 ms base backoff, doubling) and every language's test suite checks its own client against it. Transport errors and HTTP 5xx are retried; a 4xx is surfaced immediately, because it will say the same thing on the next attempt. A retried POST carries the same canonical bytes — ingest dedupes on the event hash.
Failures that survive the retries are raised in every language. Python's client used to swallow them by default, which meant a misconfigured endpoint or an expired token dropped every event and reported success; the gap stayed invisible until someone went looking for a trail that was never written. swallow_errors=True is still available as an explicit opt-in, and on_error fires either way.
Concurrency differs where the language differs, not by accident: Python adds emit_async (the blocking urllib POST moved off the event loop with asyncio.to_thread, rather than a second transport to keep in parity), and Go's Emit(ctx, event) stays synchronous because go client.Emit(ctx, event) is how Go does async — context cancellation is honoured both in flight and between retries.
One repo, five implementations, one CI job that runs them all (pr-checks.yml). See CLAUDE.md / AGENTS.md for the full command set. The short version:
pnpm install
pnpm check-all # typecheck + lint + test + build across all languagesParity is the contract: any change to canonicalJson or the hash chain must update spec/parity-corpus.json and pass in all five languages — a canonical/hash change in one language is a breaking, cross-language change. The chainFixtures half is generated, never hand-written: pnpm tsdown && node scripts/gen-chain-fixtures.mjs. A hand-typed expected hash is a hash nobody computed.
@smooai/audit is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.
- 🧰 More open source from Smoo AI — smoo.ai/open-source
- 🧩 Sibling packages — @smooai/config (typed config/secrets/flags, 7 languages), smooth-operator (the polyglot AI agent service), smooth (the
thCLI)
Issues and PRs welcome. Add a changeset for SDK changes, and call out any change to canonical/hash behavior in bold — it's a breaking, cross-language change.
MIT © SmooAI
Built by Smoo AI — AI built into every product.
{ "name": "minimal_first_of_day", "event": { "id": "01HXXX…", "organizationId": "org-1", "actorType": "user" /* … */ }, "expectedCanonical": "{\"action\":\"crm.contact_created\",\"actorId\":\"user-1\",…}", "expectedHash": "fda23a489aabc145eb0f0ab4c2c60c6df9c303053a8d18ab089f0bd8871c79ac", }