Skip to content
VeritioDocs

Deterministic redaction

Kind
concept
For
developer · governance
Verified against
@veritio/core@0.4.7 · redaction conformance fixtures at veritio@c4100ee

Veritio redacts metadata before canonicalization and hashing. The goal is stable minimization: equivalent inputs produce equivalent sanitized structures, and sensitive values do not become permanent parts of the evidence digest.

Two mechanisms do this work, and they behave differently on failure. Key-name redaction runs on every event and silently replaces matching values. The template raw-content guard runs on the agent and code templates and throws instead. Knowing which one you are relying on is the difference between a leaked value and a rejected request.

There is no exported redact() helper in @veritio/core. Redaction runs inside createAuditEvent and createEvidenceEdge, on the metadata bag, as the event object is constructed. A caller cannot invoke it early, skip it, or run it after hashing.

createAuditEvent(input)
├─ validate actor / action / target / scope
├─ redactMetadata(input.metadata) ← here, always
└─ return the event object
↓ canonicalJson (veritio-json-v1)
↓ SHA-256
event or record hash

Because redaction sits upstream of the canonical bytes, a redacted value is not merely hidden at read time. It never existed in the bytes that were hashed, so no later verification pass can recover it and no store can be asked to unredact it.

One case-insensitive regular expression decides everything:

(password|secret|token|api[_-]?key|authorization|email|phone|ssn)

It is a substring match on the key, so clientSecret, accessToken, x-api-key, AUTHORIZATION, and customerEmail all match. The value is never inspected. That is deliberate: a decision that depended on payload shape or length would vary between languages and between runs, and a hash-affecting decision cannot be allowed to vary.

The verified fixture runs one metadata bag containing every family, plus recursion, arrays, digest envelopes, and safe keys, through createAuditEvent:

verified output
{
"sensitiveKeyFamilies": [
"password",
"secret",
"token",
"apiKey / api_key / api-key",
"authorization",
"email",
"phone",
"ssn"
],
"matching": {
"note": "Case-insensitive SUBSTRING match on the KEY NAME only. The value is never inspected.",
"redactionMarker": "[redacted]"
},
"keyOutcomes": {
"AUTHORIZATION": "redacted",
"accessToken": "redacted",
"activityEpisodeId": "preserved",
"amountCents": "preserved",
"apiKey": "redacted",
"apiKeys": "redacted",
"api_key": "redacted",
"billingEmailCommitment": "preserved",
"clientSecret": "redacted",
"contactEmailCommitment": "rewritten",
"currency": "preserved",
"customerEmail": "redacted",
"dunningStage": "preserved",
"invoiceNumber": "preserved",
"password": "redacted",
"phoneNumber": "redacted",
"recipients": "rewritten",
"request": "rewritten",
"seatCount": "preserved",
"secretRotation": "redacted",
"sessionId": "preserved",
"ssn": "redacted",
"tokenizerVersion": "redacted",
"x-api-key": "redacted"
},
"beforeMetadata": {
"password": "hunter2",
"clientSecret": "sk_live_NOT_A_REAL_KEY_0000000000",
"accessToken": "sk_live_NOT_A_REAL_KEY_0000000000",
"apiKey": "sk_live_NOT_A_REAL_KEY_0000000000",
"api_key": "sk_live_NOT_A_REAL_KEY_0000000000",
"x-api-key": "sk_live_NOT_A_REAL_KEY_0000000000",
"AUTHORIZATION": "Bearer abcdef",
"customerEmail": "billing@acme.example",
"phoneNumber": "+1-555-0100",
"ssn": "000-00-0000",
"tokenizerVersion": "3",
"secretRotation": {
"previousKeyId": "key_a",
"rotatedAtSequence": 41
},
"apiKeys": [
"primary",
"standby"
],
"request": {
"method": "POST",
"route": "/v1/invoices",
"headers": {
"authorization": "Bearer abcdef",
"content-type": "application/json"
}
},
"recipients": [
{
"id": "usr_owner",
"email": "owner@acme.example"
}
],
"billingEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
},
"contactEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea",
"value": "contact@acme.example"
},
"invoiceNumber": "INV-4821",
"currency": "EUR",
"amountCents": 129900,
"seatCount": 25,
"dunningStage": null,
"sessionId": "ses_7c2a",
"activityEpisodeId": "aep_7c2a"
},
"afterMetadata": {
"AUTHORIZATION": "[redacted]",
"accessToken": "[redacted]",
"activityEpisodeId": "aep_7c2a",
"amountCents": 129900,
"apiKey": "[redacted]",
"apiKeys": "[redacted]",
"api_key": "[redacted]",
"billingEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
},
"clientSecret": "[redacted]",
"contactEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
},
"currency": "EUR",
"customerEmail": "[redacted]",
"dunningStage": null,
"invoiceNumber": "INV-4821",
"password": "[redacted]",
"phoneNumber": "[redacted]",
"recipients": [
{
"email": "[redacted]",
"id": "usr_owner"
}
],
"request": {
"headers": {
"authorization": "[redacted]",
"content-type": "application/json"
},
"method": "POST",
"route": "/v1/invoices"
},
"seatCount": 25,
"secretRotation": "[redacted]",
"sessionId": "ses_7c2a",
"ssn": "[redacted]",
"tokenizerVersion": "[redacted]",
"x-api-key": "[redacted]"
},
"subtreeReplacement": {
"note": "A sensitive key replaces its whole subtree, so nested objects and arrays cannot leak a value through a sensitive parent.",
"secretRotation": "[redacted]",
"apiKeys": "[redacted]"
},
"recursion": {
"note": "Nested objects are walked, so a sensitive key is caught at any depth. Object keys are also sorted into canonical order.",
"requestHeaders": {
"authorization": "[redacted]",
"content-type": "application/json"
},
"recipients": [
{
"email": "[redacted]",
"id": "usr_owner"
}
]
},
"digestEnvelopeCarveOut": {
"note": "Only a minimized { algorithm, digest } envelope survives a sensitive key, keeping governed field commitments usable. Any sibling raw value inside the envelope is stripped.",
"billingEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
},
"contactEmailCommitment": {
"algorithm": "sha256",
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
},
"rawSiblingStripped": true
},
"determinism": {
"canonicalJsonIdentical": true,
"hashIdentical": true,
"eventHash": "aadedb14575e3550f828905d2998e0f8c03892e2199056a39100eba7cbd35948"
},
"secretNeverReachesTheHash": {
"note": "Redaction runs inside createAuditEvent, before canonical JSON and before hashing, so the raw value is absent from the hashed bytes and cannot move the hash.",
"rawSecretInCanonicalBytes": false,
"realSecretHash": "7f0059c881f0f1a61de2f610cad024ac41e755949a8bfe6af7932982595987b1",
"differentSecretHash": "7f0059c881f0f1a61de2f610cad024ac41e755949a8bfe6af7932982595987b1",
"preRedactedHash": "7f0059c881f0f1a61de2f610cad024ac41e755949a8bfe6af7932982595987b1",
"hashUnchangedByTheSecretValue": true
}
}

Read keyOutcomes first. Each top-level key is classified as preserved (canonically identical to the input), redacted (replaced wholesale by the marker [redacted]), or rewritten (structurally changed, because something inside it was redacted or minimized).

Four behaviors are worth naming explicitly.

Substring matching over-redacts on purpose. tokenizerVersion is redacted because it contains token. Nothing about the value "3" is sensitive. The pattern accepts this cost rather than maintaining a per-key exception list that would have to stay identical in three SDKs. If you need such a value, rename the key.

A sensitive key replaces its whole subtree. secretRotation was an object and apiKeys was an array; both become the bare string [redacted]. The redactor does not descend into a sensitive parent to preserve harmless children, so nothing can leak upward through a matching key.

Recursion finds sensitive keys at any depth. request is rewritten: request.headers.authorization became [redacted] while method, route, and content-type survived. recipients is rewritten for the same reason — the array itself is not sensitive, but the email key inside each element is. Array elements are walked with their parent’s key, so an array under a sensitive key is caught before its items are ever examined.

Object keys are sorted as part of the walk. Compare beforeMetadata with afterMetadata: the surviving keys come back in canonical order, and object properties whose value was undefined are dropped. This is the same JSON-domain normalization the hash chain depends on, which is why non-finite numbers throw rather than receive an unstable representation.

Two keys in the fixture are preserved by contract rather than by accident. sessionId and activityEpisodeId are the un-shadowable grouping keys the provenance recorder stamps on every event in a session. They are non-PII, and they must not match the sensitive-key pattern — if they did, activity episodes could not be reassembled from a chain.

Evidence sometimes needs a stable commitment to a sensitive value without storing the value. A customerEmail field in a governed change is the standard case: the revision must prove the address changed without recording either address.

A narrowly shaped digest envelope therefore survives a sensitive key. digest must be a string matching sha256: followed by 64 lowercase hex characters, and one of three shapes must hold:

  • algorithm: "sha256" keeps algorithm and digest only.
  • algorithm: "hmac-sha256" keeps algorithm, digest, and keyVersion — but only when keyVersion is a non-empty string. Without it the whole envelope is rejected.
  • captureMode of content_digest, randomized_digest, reference, redact, or encrypt keeps captureMode and digest only.

Everything else in the envelope is discarded. In the fixture, contactEmailCommitment is classified rewritten rather than preserved precisely because a raw value sibling was smuggled in alongside a valid digest; rawSiblingStripped: true confirms it did not survive. Key material passed as hmacKey or key is dropped the same way.

An envelope that fails these checks under a sensitive key is not partially cleaned. It becomes [redacted] in full, which is why the shared conformance fixture’s contactEmailInvalidDigest case — a valid-looking envelope carrying sha256:not-a-digest — collapses to the marker.

Two boundaries around this carve-out are easy to miss. First, envelope minimization also runs under non-sensitive keys: any object that matches one of the three shapes is reduced to its minimized form regardless of what it is called. Second, an object carrying canonicalization, schemaRef, fieldSetRef, or fields is never treated as a field commitment. Those are evidence commit shapes, and letting them through the carve-out would let a caller park arbitrary structure under a sensitive key.

The fixture’s last two sections make the ordering claim falsifiable rather than rhetorical. rawSecretInCanonicalBytes is false: a substring search of the canonical JSON for the fake credential finds nothing.

More usefully, three events that differ only in the raw value under apiKey — a realistic-looking key, a differently shaped key, and the literal string [redacted] — all hash to 7f0059c8…. The hash depends on the key being sensitive, not on what the value was. In practice this means an accidental credential in an already-recorded event did not change the digest, and re-recording the same logical event after removing it produces the same hash.

A key-name redactor cannot understand the meaning of arbitrary content. A token stored under note, a raw prompt under summary, a diff under description, or personal data embedded in a value all pass through untouched. So does an array of raw strings under a safe key, because the array inherits its parent’s harmless name.

One more gap is easy to trip over: a Principal’s optional display field is not redacted at all. It is not metadata, so the pattern never sees it. Callers must keep display labels free of PII and prefer stable ids. Evidence edges drop display entirely and carry only actor ids.

The working rule is that the application allowlists the metadata fields needed to answer its evidence question before calling the SDK. Do not send raw prompts, source diffs, access tokens, request bodies, email addresses, or filesystem paths merely because a redactor exists. Prefer stable opaque IDs, enumerated classifications, counts, and digests.

For agent and code evidence, that advice is enforced rather than suggested. Eleven template builders — agentSessionStartedTemplate, agentPromptRecordedTemplate, agentToolCalledTemplate, changeProposalCreatedTemplate, filesChangedTemplate, the three review* builders, ciJobCompletedTemplate, deploymentCreatedTemplate, and runtimeObservedTemplate — declare a block-raw-content metadata policy. Their caller-supplied metadata is walked recursively before the event object is built, and rejected on two independent rules.

verified raw-content guard output
{
"rejections": [
{
"attempt": "'diff' metadata key on change.proposal.created",
"rule": "blocked-key",
"outcome": "rejected",
"errorName": "TypeError",
"message": "metadata.diff is not allowed in agent/code audit template metadata"
},
{
"attempt": "'filePath' metadata key on change.files.changed",
"rule": "blocked-key",
"outcome": "rejected",
"errorName": "TypeError",
"message": "metadata.filePath is not allowed in agent/code audit template metadata"
},
{
"attempt": "nested 'stdout' blob on agent.tool.called",
"rule": "blocked-key",
"outcome": "rejected",
"errorName": "TypeError",
"message": "metadata.result.stdout is not allowed in agent/code audit template metadata"
},
{
"attempt": "git-diff-shaped value under innocuous key 'summary'",
"rule": "blocked-value-shape",
"outcome": "rejected",
"errorName": "TypeError",
"message": "metadata.summary looks like raw content or credential material"
},
{
"attempt": "bearer-token-shaped value under innocuous key 'note'",
"rule": "blocked-value-shape",
"outcome": "rejected",
"errorName": "TypeError",
"message": "metadata.note looks like raw content or credential material"
}
],
"minimizedAlternative": {
"action": "change.files.changed",
"target": {
"type": "source_tree",
"id": "tree_5f2a"
},
"metadata": {
"diffHash": "a7f5f35426b927411fc9231b56382173",
"fileCount": 2,
"filePathHashes": [
"b1946ac92492d234",
"591785b794601e21"
]
}
}
}

The key-name rule blocks names that normally carry raw code, paths, logs, arguments, or credentials, at any depth — diff, filePath, and a nested result.stdout all fail. Names ending in hash, hashes, id, ids, count, or status are exempt, which is what lets the minimizedAlternative through with diffHash, fileCount, and filePathHashes. The guard blocks raw content, not the fact that content changed.

The value-shape rule catches git-patch and bearer-token shapes even under an innocuous key, which is how summary and note are rejected here.

The contrast with key-name redaction is the point. Redaction replaces a value and continues; the guard raises a TypeError and no event exists, so nothing partial reaches the hash chain and a caught guard error converts cleanly into a request-level rejection. It is a fail-closed boundary, not a sanitizer — and it applies only to those eleven builders. The auth, organization, consent, and data templates fall back to key-name redaction alone. See Audit templates for the full builder list and the exemption rules.

The TypeScript, Python, and Go SDKs compile the same pattern and share the public fixtures in spec/conformance/redaction.json. All three walk objects in sorted key order, replace sensitive subtrees with the same [redacted] marker, and apply the same digest-envelope carve-out.

A change to the pattern, to recursion, to the marker, or to envelope sanitization changes canonical bytes and therefore changes hashes. It is a protocol compatibility change requiring a coordinated release across all three SDKs, not a website copy edit.

A redacted, hashed event proves that the sanitized structure you are looking at is the structure that was hashed, and that no value under a matching key was part of it. It does not prove that the host minimized the fields the pattern does not cover, that the recorded claim was true, or that a secret was never written somewhere else in the same request.

Constrain metadata shape with the event schema, then apply the agent-specific rules in Agent events.