Integrity SDK
.NET — Axowl.Sdk.Integrity.Client
Section titled “.NET — Axowl.Sdk.Integrity.Client”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.
JS/TS — @axowl/sdk-wasm
Section titled “JS/TS — @axowl/sdk-wasm”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.
Anchoring contract — one chain per key
Section titled “Anchoring contract — one chain per key”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:
-
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 sameentityIdand 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. -
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 withALREADY_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.
Editing a sealed record
Section titled “Editing a sealed record”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 zerosawait sealer.SealNewAsync(brand, "Brand", brand.Id, Canonical(brand), ct: ct);
// later — a sealed field changedbrand.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:
| Situation | What it does |
|---|---|
| Sealed payload changed | New chain position, one more anchor |
| Unchanged, already anchored | Nothing — an unchanged save never fabricates a version |
| Unchanged, anchor failed earlier | Retries 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.
Which fields need a re-seal
Section titled “Which fields need a re-seal”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 payload | Out of the payload |
|---|---|
| Economic terms, identity, anything a counterparty relies on | Media URLs, prose descriptions, workflow status |
| Editing it requires a re-seal | Editing 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.
Chain off the sealed hash, not your own
Section titled “Chain off the sealed hash, not your own”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.
Sealing who acted — the seal actor
Section titled “Sealing who acted — the seal actor”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_employeevar actor = new SealActor(principal.ConnectedId, principal.IsEmployee);
await sealer.SealNewAsync(order, "Order", order.Id, Canonical(order), actor: actor, ct: ct);Three things to know:
- 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.
- The server checks the badge. The
actor_connected_idmust belong to the request’s organization, andis_employeemust match the badge’s subject axis — pass the value a fresh introspect response gave you; a stale or fabricated claim is rejected withINVALID_ARGUMENT. - 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.
Verifying an event chain
Section titled “Verifying an event chain”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:
- Content — recomputing the five-input hash reproduces the stored
recordHash. - Linkage — event n’s
previousHashequals event n − 1’srecordHash(genesis links to 64 zeros), and no two events claim the same sequence. - 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.