Skip to main content

What interoperates and what does not

The TypeScript library (@glinr/theauth) and the Go library (theauth-go) each keep their own tamper evident audit log. The two logs are different formats. Only the layer on top of them is shared. The claim this page supports, and no stronger one: receipts and signed checkpoints interoperate; the chains are not interchangeable. A receipt issued by either library verifies with the other. A chain written by one library cannot be verified, extended or read in place by the other, and one database cannot hold a single log written by both. A receipt proves that one row, as disclosed, is a leaf of the log a checkpoint signed. It does not prove the log holds every event for an agent. See Audit checkpoints and receipts for what a receipt proves and what it does not.

What a receipt is

A receipt is one JSON object that a counterparty can verify offline with the checkpoint public key:
Every field of the row that the receipt layer knows about is either revealed (value plus salt) or hidden (commitment only). The verifier recomputes the commitments for revealed fields, rebuilds the leaf, checks the leaf is in the tree, and checks the checkpoint signature.

Leaf encoding

For each field name in the field set of the row’s hash version:
0x00 is a single zero byte. canonicalJson writes object keys sorted by UTF-16 code unit at every depth, no whitespace, strings escaped like JavaScript JSON.stringify, and null for absent values. auditKey is audit.hmacKey when set, otherwise the per-installation audit secret (as UTF-8 bytes of its hex string in TypeScript, as raw bytes in Go). Salts are derived, never stored, and a receipt reveals a salt only for a revealed field. Field sets, in the order the library lists them: Version 2 and 101 leave the personal ids out so erasure does not break a receipt. The row hash still covers them through their pseudonyms (version 2) or their stored form (Go).

Tree construction

Leaves are every chained row, sorted ascending by row timestamp in milliseconds, then by row id. The tree is the RFC 6962 Merkle tree:
The tree splits at the largest power of two below the leaf count, so an odd node at the end of a level is carried up unchanged. Inclusion and consistency proofs follow RFC 9162 section 2.1. A checkpoint signs {version, alg, keyId, size, rootHash, timestamp, since, until}. The signed bytes are the string theauth.audit.checkpoint.v1 followed by a newline and the canonical JSON of that object. The signature is Ed25519, 64 bytes, encoded as base64url without padding. Any other alg is rejected, and the keyId must match one of the keys you pass in.

Verify with the TypeScript library

verifyInclusionProof, verifyConsistencyProof and verifyCheckpoint work the same way. Reasons include malformed, unknown_key, bad_signature, leaf_mismatch, bad_proof and bad_commitment.

Verify with the Go library

checkpoint.VerifyInclusionProof, VerifyConsistencyProof and VerifyCheckpoint mirror the TypeScript functions and return the same reason strings.

How interoperation is tested

  • The Go repository writes go-fixture.json (checkpoints, inclusion and consistency proofs, receipts and tamper cases). This repository vendors a byte-identical copy in packages/core/tests/fixtures/receipts/ and tests/audit-receipt-go-fixture.test.ts verifies it with the TypeScript verifiers in the normal test run. Refresh the copy with node scripts/refresh-go-receipt-fixture.mjs when the Go checkout sits next to this one. CI never needs the Go checkout.
  • The TypeScript side writes ts-fixture.json and Merkle vectors that the Go CI verifies and rebuilds byte for byte.
  • A drift in either direction fails a test in one of the two repositories.

Chain test vectors (TypeScript row hashes)

packages/core/tests/fixtures/audit-chain-vectors.json lets an outside verifier recompute TypeScript row hashes without this library. It is generated by npx tsx scripts/gen-audit-chain-vectors.mjs, and a test regenerates it in memory and fails if the committed file differs. Go has its own, separate chain format; these vectors say nothing about it. Format (formatVersion 1; any incompatible change bumps the number): To verify a row: build payload as canonicalJson({v: hashVersion, seq, prev, row}) where row holds the version’s hashed fields, then hash = hex(HMAC-SHA256(hmacKey, utf8(payload))) when hmacKey is set (key as UTF-8 text) or hex(SHA-256(utf8(payload))) when it is null. Hashed fields:
  • Version 1: id, action, resource, parameters, result, reason, durationMs, tokensCost, ip, userAgent, cacheHit, timestamp (milliseconds), agentId, userId.
  • Version 2: the same twelve common fields plus policyVersion, delegationChain, inputProvenance and idPseudonyms. The raw id columns are not hashed.
Absent values hash as null. idPseudonyms[field] = hex(HMAC-SHA256(pseudonymKey, "theauth.audit.pseudonym.v1:" + id)) for userId, agentId, subjectId, actorId and approverId, where pseudonymKey is audit.hmacKey when set and the per-installation secret otherwise. Check order for a chain: prev equals the previous row’s hash (null for the first row), then the recomputed hash equals hash, then, for version 2, every raw id still present and not starting with [deleted- matches its pseudonym. The negative vectors carry the reason each mutation must produce: A chain cut off at its tail still verifies row by row. Detecting that needs a signed checkpoint or an expectedHeads anchor, which is why checkpoints exist. The vectors above test the row hash only. verifyAuditChain on a live database also checks where each chain starts. The oldest remaining row must be seq 1 with prev = null, or be explained by an audit.checkpoint row: either the one retention cleanup writes (throughSeq and lastHash match the first row’s link) or the anchor theauth.audit.checkpoint() writes (firstSeq, firstId and firstHash name the first row). Both are chained rows and carry a mac: hex(HMAC-SHA256(key, "theauth.audit.anchor.v1:" + canonicalJson(fields))), with the same key as the pseudonyms. Otherwise the result is truncated_prefix. checkpoint() also returns the anchors and heads, so you can keep them outside the database and pass them back as anchors and expectedHeads; a pinned anchor whose row is gone gives truncated_prefix, one whose hash differs gives anchor_mismatch. It anchors chains as they are when called, so verify first. allowUnanchoredPrefix: true turns the start check off. audit.erasure records follow the same rule. They are chained rows, and each entry is MACed under "theauth.audit.erasure.v1:" over the erased row’s id, agent, seq, hash, prevHash, hashVersion, reason (anonymized or deleted) and, for an anonymized version 1 row, the hash of the row as erasure left it. A record that is not on a chain gives erasure_unchained, an entry with a bad MAC gives erasure_forged, and a row whose hash_version went back to 1 (or NULL) gives version_downgrade.

Verifying a Go export from TypeScript

verifyGoAuditChain(rows, options?) recomputes the hash chain of rows exported by theauth-go. It is a separate verifier from verifyAuditChain, because the Go chain is a different format (see the table at the top). It is pure and runs on Web Crypto only, so it works in a browser, a worker or Node.
From a shell: theauth audit verify-go export.json [--secret-hex <hex>] [--partial] [--json] (a JSON array, {"rows": [...]} or NDJSON). It reads the file and nothing else. Rows use the JSON of Go’s models.AuditEvent (id, organizationId, action, createdAt, chainSeq, prevHash, hash, and so on). The four hashed pseudonyms are not part of that struct, so the export must add actorUserPseudonym, subjectPseudonym, actorPseudonym and approverPseudonym (empty or absent for ids that were never set). Without them you must pass pseudonymSecret (the 32 raw bytes as Uint8Array, or hex), and erased rows can then not be verified. What is checked, per chain key (the organization id, or "" for global events), in this order, stopping at the first failure: Erasure works as in Go: a row whose raw id is blank while its pseudonym is set counts as erased and still verifies through the pseudonym. Rows with no hash are skipped and counted as unchained. Interleaved chains are fine. allowPartialStart trusts the first row of each chain, for an export window. What is not checked:
  • Truncation. A chain cut at its tail still verifies. Only a signed checkpoint or an externally kept head detects that.
  • Keys. The Go chain is plain SHA-256, with no HMAC mode, so anyone who can write the table can rewrite a suffix and recompute every hash. This verifier cannot change that, and it has no HMAC support because there is nothing to support. Without pseudonymSecret the pseudonyms are trusted as exported (pseudonymsUnchecked counts those rows).
  • Fields outside the hash. event, recordedAtUs and the native columns of a database are not hashed.
  • Numbers beyond 2^53 inside metadata or inputProvenance: JavaScript parses them as floats, so the rebuilt text can differ from Go’s. Metadata with such values reports hash_mismatch.
  • Storage. It checks an export, not that the export matches the database.
The conformance vectors are packages/core/tests/fixtures/go-audit-chain-vectors.json, produced by running the real Go hashing code. The generator source and the steps to rerun it are in scripts/go-chain-vectors/.

Running both libraries

Do not point both libraries at one database or schema. Both create a table named theauth_audit_secrets, with incompatible layouts: The TypeScript library checks the table before it migrates and again on every start with skipMigrations. If the table exists with a layout it did not create, it throws:
The error has code === "AUDIT_SECRETS_LAYOUT_MISMATCH" and a found string listing the column types it saw. Nothing is renamed or altered. Give each library its own database or schema. To check by hand, list the column types of theauth_audit_secrets (pragma_table_info on SQLite, information_schema.columns on Postgres and MySQL): a TypeScript table has text id and secret columns. If you see an integer id and a binary secret, the Go library created it.
Last modified on October 18, 2026