Skip to content

Integrity SDK

Two layers. IEntitySealer is the one you normally use: it seals a record on create, re-seals it after an edit, and owns the chain bookkeeping described below. IAxowlIntegrityClient is the transport underneath — RestIntegrityClient, GrpcIntegrityClient and FallbackIntegrityClient (auto-select), wired via ServiceCollectionExtensions and authenticated with the org API key (ApiKeyInterceptor). A separate Axowl.Sdk.Integrity.Hashing project mirrors the server hasher for byte-parity.

Reach for the transport directly only when you are managing sequence numbers yourself — an append-only event log, for instance, where the chain is per-log rather than per-record.

A WASM core (axowl-sdk/packages/sdk-wasm) for client-side, salt-free verification of sealed records — the “verify-it-yourself” model where a consumer recomputes the hash on their own device.

Every Seal call anchors into a server-side chain addressed by (organization, entityType, entityId, sequenceNumber), and that key is unique: two seals may never claim the same position. The server also verifies continuity — your request’s previousHash must equal the stored hash of position sequenceNumber - 1.

Two rules follow:

  1. An entity and its event log are two chains — give them two keys. The entity itself seals as (entityType, entityId, 1..n). If you also seal an append-only event log about that entity (created / transferred / redeemed …), anchor those under a namespaced type — the convention is "{entityType}:events" with the same entityId and the event’s own sequence. Reusing the entity’s plain type collides with its genesis anchor on the very first event: the first seal is rejected as a duplicate and every later one fails the continuity check.

  2. Retries are safe; overwrites are not. Sending the same (key, clientComputedHash) twice — e.g. a retry after a network timeout — returns the existing anchor (idempotent). Sending the same key with a different hash is refused with ALREADY_EXISTS: a chain position, once anchored, cannot be rewritten. Editing a sealed record is therefore never an overwrite — it claims the next position. See below.

A sealed record is not frozen. When a field inside the sealed payload changes, seal it again at the next chain position: Axowl ends up holding one anchor per version, so the edit is recorded, not hidden. Changing a price on a sealed listing leaves a mark instead of erasing one — which is the point.

IEntitySealer owns those chain rules. Implement ISealedRecord on the entity and call it; do not drive SealAsync by hand.

// on create — chain position 1, previous-hash of 64 zeros
await sealer.SealNewAsync(brand, "Brand", brand.Id, Canonical(brand), ct: ct);
// later — a sealed field changed
brand.Name = "New name";
await sealer.ReSealAsync(brand, "Brand", brand.Id, Canonical(brand), ct: ct);

ReSealAsync is safe to call unconditionally after any edit — it hashes the payload you pass and works out what actually happened:

SituationWhat it does
Sealed payload changedNew chain position, one more anchor
Unchanged, already anchoredNothing — an unchanged save never fabricates a version
Unchanged, anchor failed earlierRetries that position instead of skipping past it
Never sealed (sequence 0)Seals as genesis

So you do not have to diff the payload yourself before deciding whether to re-seal, and a transient anchor failure does not leave a permanent hole in the chain.

Registration comes with the client — AddAxowlIntegrityClient(...) also registers IEntitySealer and a default Sha256Hasher. Set OrganizationId on the options, or pass one per call.

Only what you put in the canonical payload is protected, so only a change to those fields needs re-sealing. Display-only columns — media URLs, descriptions, status flags — are normally left out of the payload precisely so they can be edited freely. Decide that split once, when you design the payload:

In the payloadOut of the payload
Economic terms, identity, anything a counterparty relies onMedia URLs, prose descriptions, workflow status
Editing it requires a re-sealEditing it is a plain UPDATE

Keep the payload’s shape stable. Adding or removing a field changes every future hash, and rows sealed under the old shape can no longer be reproduced — they will read as tampered forever. Renaming a property that feeds the payload is the same change.

Position n’s previousHash must equal what the server anchored at n − 1 — the sealedHash it returned, not the recordHash you computed. Those are the same string today only because server-side sealing is a pass-through; anything chained off recordHash breaks at the next link the day that changes. IEntitySealer already chains off sealedHash, falling back to recordHash only when the previous seal failed and left no anchor to match against.

By default a seal records what happened; pass a SealActor to also record who did it. The actor is a ConnectedId — the badge every subject in your organization holds, your end customers included, not just staff. This is what makes a sealed record usable as billing or attribution evidence: “customer X performed this action” is inside the seal, not in a mutable side column.

// introspect gave you the caller's connected_id and is_employee
var actor = new SealActor(principal.ConnectedId, principal.IsEmployee);
await sealer.SealNewAsync(order, "Order", order.Id, Canonical(order),
actor: actor, ct: ct);

Three things to know:

  1. The claim is hash-covered. When an actor is passed, its two fields join the hashed input (see the V2 shape below), so the “who” cannot be swapped after the fact without breaking the seal. Requests without an actor hash exactly as before — existing chains reproduce unchanged.
  2. The server checks the badge. The actor_connected_id must belong to the request’s organization, and is_employee must match the badge’s subject axis — pass the value a fresh introspect response gave you; a stale or fabricated claim is rejected with INVALID_ARGUMENT.
  3. Persist the actor on your row. Reproducing the record hash later needs the same inputs — store the actor fields alongside the payload, or verification of that version becomes impossible.

This is notarization-level today: the seal fixes what the API caller claimed about the actor at seal time. A cryptographic actor signature (the actor’s own WebAuthn key counter-signing the decision) is the planned next phase.

Anything you sealed, anyone can re-check. The content hash is SHA-256 over a JSON record of five inputs (computed by Sha256Hasher, mirrored byte-for-byte by @axowl/sdk-wasm):

{"EntityType":"{entityType}:events","EntityId":"<guid, N format>",
"CanonicalPayload":"<the exact stored payload string>",
"PreviousHash":"<prior event's hash, or 64 zeros>","SequenceNumber":n}

Versions sealed with an actor use the V2 shape — the same five fields followed by the two actor fields:

{"EntityType":"","EntityId":"","CanonicalPayload":"","PreviousHash":"",
"SequenceNumber":n,"ActorConnectedId":"<guid, N format>","ActorIsEmployee":false}

Three independent checks make a verified chain, and they fail for different reasons — report them separately:

  1. Content — recomputing the five-input hash reproduces the stored recordHash.
  2. Linkage — event n’s previousHash equals event n − 1’s recordHash (genesis links to 64 zeros), and no two events claim the same sequence.
  3. Anchor — the event was acknowledged by the anchor server when it happened (sealStatus/chainAnchorId).

Store the canonical string verbatim. The hash covers the payload’s raw bytes, so the column you verify from must return exactly what you hashed. A Postgres jsonb column does not: it reorders keys and normalizes spacing on INSERT, so every content re-check fails on data that was never touched. Use text. (Measured in production before this page said so.)

A working reference: Bullmark’s public verifier, GET /integrity/events/{entityType}/{entityId} — hashes and verdicts only, no payloads, with rows from the pre-text era reported as legacy rather than tampered.