Security risk assertions
A risk score is a conclusion someone reached about an operation. Six months later a reviewer needs to know what the conclusion was, which policy produced it, and that nobody edited it afterwards. Veritio answers that with a security risk assertion: a small append-only record that states the conclusion, names its producer, and hashes exactly like an audit record.
An assertion is a record, not an event
Section titled “An assertion is a record, not an event”An audit event says this happened. An assertion says this was concluded about that. They are different record types with different envelopes, and the SDK keeps them apart on purpose.
audit event assertion action: string recordType: "assertion.recorded" actor / target type: "security.risk" metadata: free-form, REDACTED producer / subject: closed refs → hashAuditRecord conclusion / factors: closed shape → hashAssertionRecordThe practical consequence is redaction. createAuditEvent runs every metadata value through redactMetadata, which recursively replaces values under keys matching password, secret, token, api_key, authorization, email, phone, or ssn with a stable marker before hashing. An assertion has no metadata bag. It has a fixed set of documented fields, and createSecurityRiskAssertion never calls the redactor.
That is safe only because every assertion field is structural: identifiers, an ISO timestamp, a score, a level, a policy version, and factor rows drawn from the closed risk-signal vocabulary. Nothing in an assertion is free-form, so nothing needs minimizing. It also means you must not smuggle personal data into a factor key or value expecting the redactor to catch it. It will not run.
Assertions are not stored by AuditStore. The conforming store owns the audit chain; an assertion is a standalone record your host persists alongside it, and it is referenced from an evidence commit as a member with recordType: "assertion.record".
The builder stamps an envelope; it never scores
Section titled “The builder stamps an envelope; it never scores”createSecurityRiskAssertion contains no scoring math. It takes a conclusion you already computed and wraps it.
RiskSignals ↓ scoreRiskSignals / rollupEpisodeRisk pure math, no cryptoRiskAssessment score, level, policyVersion, factors ├→ createSecurityRiskAssertion → assertion.recorded → hashAssertionRecord └→ buildSecurityRiskAssessedEvent → AuditEventInput → createAuditEvent → storeThis split matters. If the envelope builder re-derived the score, an assertion could disagree with the number your application acted on, and there would be no way to tell which one was authoritative. Because the builder only stamps, the assertion is a record of a decision, not a second scoring engine that may drift from the first.
The builder copies field by field. Only key, value, kind, weight, and contribution survive from each factor; only score, level, policyVersion, and assessment survive from the conclusion. Extra properties a host attaches to its own factor objects are dropped rather than carried into the canonical hash.
Three fields are constants, never inputs: recordType is assertion.recorded, schemaVersion is 2026-06-23, and recordAuthority is veritio.
Fixed producer authority, hashed idempotency key
Section titled “Fixed producer authority, hashed idempotency key”Two fields carry the security weight of the record.
producer.authority is hard-coded to veritio.detectors. You supply a producerId and nothing else; kind is principal and type is service. A detector — hosted or self-run — therefore cannot claim to be a different evidence authority. Anything in the graph asserting a risk conclusion under this record type is, by construction, attributed to the detector authority, and a reviewer reading producer.authority learns nothing that a caller was free to choose.
The raw idempotencyKey is never stored. createSecurityRiskAssertion writes idempotencyKeyHash, computed as sha256(tenantId + NUL + idempotencyKey) in lowercase hex, and the raw string is discarded with the input object. Two consequences follow: keys may safely embed host identifiers, and the same key under two tenants produces two different hashes, so a deduplication index cannot collide across tenant boundaries.
Optional scope fields are omitted when absent rather than written as empty strings. workspaceId and environment appear only when you pass truthy values. Because omission changes the canonical bytes, an assertion built with environment: '' and one built without environment are the same record with the same hash.
occurredAt defaults to now and is normalized the same way governed changes normalize it: a Date becomes an ISO string, and a zone-less string is read as UTC. An invalid date throws rather than silently becoming Invalid Date.
Hashing, and parity with hashAuditRecord
Section titled “Hashing, and parity with hashAuditRecord”hashAssertionRecord is deliberately the same rule as hashAuditRecord:
- Remove the stored
hashfield, if the record carries one. - Serialize the remaining documented fields with
veritio-json-v1canonical JSON. - SHA-256 those bytes.
- Return bare lowercase hex — no
sha256:prefix.
Step 1 is the invariant that makes a persisted record re-verifiable. A digest that fed back into its own recomputation could never be checked, because writing the hash would change the input that produced it. Every SDK enforces the exclusion its own way: TypeScript destructures hash off, Python filters on key != 'hash', and Go rebuilds the payload from the twelve documented fields, so an unknown column added by a host store cannot silently enter the digest.
Step 4 is the one that trips people up. Evidence commits reference member records as sha256:<hex>, and the assertion schema itself constrains idempotencyKeyHash to bare 64-character hex. The bare hex is the cross-language source of truth; the prefix is a commit-manifest presentation detail, added at the commit boundary.
A verified end-to-end run
Section titled “A verified end-to-end run”This example scores one irreversible production bulk delete, stamps the assertion, hashes it, derives the assessed event, and stores that event as an audit record. Every id, timestamp, and append time is pinned so the digests are reproducible; the output below is byte-compared in CI.
{ "assertion": { "recordType": "assertion.recorded", "schemaVersion": "2026-06-23", "recordAuthority": "veritio", "id": "asr_risk_sub_9f31_01", "type": "security.risk", "scope": { "tenantId": "org_acme", "environment": "production" }, "occurredAt": "2026-08-09T10:00:00.000Z", "producer": { "authority": "veritio.detectors", "kind": "principal", "type": "service", "id": "svc_risk_detector" }, "idempotencyKeyHash": "ec759d4cdacbcf8341d5e8fb83d19453503bd756c6661a8b56799ad0318cbfbc", "subject": { "authority": "acme-billing", "kind": "change", "type": "subscription", "id": "chg_subscription_sub_9f31_4d21c0" }, "conclusion": { "score": 1, "level": "critical", "policyVersion": "veritio.reference.v1", "assessment": "step" }, "factors": [ { "key": "operationType", "value": "destructive", "kind": "base", "weight": 1, "contribution": 0.85 }, { "key": "dataVolume", "value": 4200, "kind": "additive", "weight": 0.5, "contribution": 0.1953 }, { "key": "fanOut", "value": 6, "kind": "additive", "weight": 0.3, "contribution": 0.0232 }, { "key": "referenceCount", "value": 18, "kind": "additive", "weight": 0.2, "contribution": 0.0212 }, { "key": "reversibility", "value": "irreversible", "kind": "multiplier", "weight": 1.3, "contribution": 1.3 }, { "key": "envCriticality", "value": "production", "kind": "multiplier", "weight": 1, "contribution": 1 } ] }, "assertionHash": "412d24652fbef6090751f71fb61bb5012a91b0b6109f82d7fe35d5815aef1c91", "assessedEvent": { "id": "evt_risk_assessed_01", "schemaVersion": "2026-06-10", "occurredAt": "2026-08-09T10:00:00.000Z", "actor": { "type": "service", "id": "svc_risk_detector" }, "action": "security.risk.assessed", "target": { "type": "subscription", "id": "chg_subscription_sub_9f31_4d21c0" }, "metadata": { "activityEpisodeId": "aep_billing_upgrade_01", "assertionId": "asr_risk_sub_9f31_01", "riskAssessment": { "assessment": "step", "factors": [ { "contribution": 0.85, "key": "operationType", "kind": "base", "value": "destructive", "weight": 1 }, { "contribution": 0.1953, "key": "dataVolume", "kind": "additive", "value": 4200, "weight": 0.5 }, { "contribution": 0.0232, "key": "fanOut", "kind": "additive", "value": 6, "weight": 0.3 }, { "contribution": 0.0212, "key": "referenceCount", "kind": "additive", "value": 18, "weight": 0.2 }, { "contribution": 1.3, "key": "reversibility", "kind": "multiplier", "value": "irreversible", "weight": 1.3 }, { "contribution": 1, "key": "envCriticality", "kind": "multiplier", "value": "production", "weight": 1 } ], "level": "critical", "policyVersion": "veritio.reference.v1", "score": 1 }, "riskSignals": { "dataVolume": 4200, "envCriticality": "production", "fanOut": 6, "operationType": "destructive", "referenceCount": 18, "reversibility": "irreversible" } }, "scope": { "tenantId": "org_acme", "environment": "production" } }, "auditRecord": { "event": { "id": "evt_risk_assessed_01", "schemaVersion": "2026-06-10", "occurredAt": "2026-08-09T10:00:00.000Z", "actor": { "type": "service", "id": "svc_risk_detector" }, "action": "security.risk.assessed", "target": { "type": "subscription", "id": "chg_subscription_sub_9f31_4d21c0" }, "metadata": { "activityEpisodeId": "aep_billing_upgrade_01", "assertionId": "asr_risk_sub_9f31_01", "riskAssessment": { "assessment": "step", "factors": [ { "contribution": 0.85, "key": "operationType", "kind": "base", "value": "destructive", "weight": 1 }, { "contribution": 0.1953, "key": "dataVolume", "kind": "additive", "value": 4200, "weight": 0.5 }, { "contribution": 0.0232, "key": "fanOut", "kind": "additive", "value": 6, "weight": 0.3 }, { "contribution": 0.0212, "key": "referenceCount", "kind": "additive", "value": 18, "weight": 0.2 }, { "contribution": 1.3, "key": "reversibility", "kind": "multiplier", "value": "irreversible", "weight": 1.3 }, { "contribution": 1, "key": "envCriticality", "kind": "multiplier", "value": "production", "weight": 1 } ], "level": "critical", "policyVersion": "veritio.reference.v1", "score": 1 }, "riskSignals": { "dataVolume": 4200, "envCriticality": "production", "fanOut": 6, "operationType": "destructive", "referenceCount": 18, "reversibility": "irreversible" } }, "scope": { "tenantId": "org_acme", "environment": "production" } }, "sequence": 1, "previousHash": null, "hashAlgorithm": "sha256", "canonicalization": "veritio-json-v1", "appendedAt": "2026-08-09T10:00:00.500Z", "idempotencyKeyHash": "05eb1f027143d5e64e806b62ee4c0244bf873a8510b54f72ed9ea125e6658e56", "hash": "c7f1ab227c15267c459058e8f601dfb51074c18211b05396602088fad50dd523" }, "hashParity": { "note": "hashAssertionRecord and hashAuditRecord both hash the canonical JSON of the record with its own `hash` field excluded, and emit bare lowercase sha256 hex.", "rawIdempotencyKeyStored": false, "assertion": { "digest": "412d24652fbef6090751f71fb61bb5012a91b0b6109f82d7fe35d5815aef1c91", "recomputedFromPersistedForm": true, "tamperedStoredHashIgnored": true, "bareLowercaseSha256Hex": true }, "auditRecord": { "digest": "c7f1ab227c15267c459058e8f601dfb51074c18211b05396602088fad50dd523", "recomputedFromPersistedForm": true, "tamperedStoredHashIgnored": true, "bareLowercaseSha256Hex": true }, "digestsDiffer": true, "chainVerification": { "ok": true }, "tamperedChainVerification": { "ok": false, "index": 0, "reason": "hash_mismatch" } }}Read the hashParity block rather than the digests. It shows that rawIdempotencyKeyStored is false, that re-hashing each record in its persisted hash-carrying form reproduces the same digest, that overwriting a stored hash with a tampered value does not change the recomputed digest, and that the two records — assertion and audit record — produce different digests. The tampered audit record fails chain verification with hash_mismatch, which is the same failure the hash chain page describes.
Note the conclusion: the raw score saturates and the record stores 1 with level critical. The clamp happens in the scorer, not the envelope.
The assessed event and the un-shadowable episode id
Section titled “The assessed event and the un-shadowable episode id”An assertion is durable but not queryable in the audit timeline. buildSecurityRiskAssessedEvent produces the companion event:
actionissecurity.risk.assessed.targetis the subject’stypeandid.actordefaults to a service principal with theproducerId, overridable.metadata.riskAssessmentcarriesscore,level,policyVersion,assessment, andfactorswhen you supply them.metadata.riskSignalsis added bywithRiskSignalswhen normalized signals are passed.metadata.activityEpisodeIdis stamped bymergeVeritioMetadataafter your metadata.
That ordering is the point. Reserved context keys — activityEpisodeId among them — are applied last, and supplying one yourself throws metadata.activityEpisodeId is reserved by Veritio rather than being quietly overwritten. Change, Trace, and Explain read models group on that key; if a host could shadow it, one episode’s assessment could be attributed to another. See activity episodes for what the grouping buys you.
The function returns an AuditEventInput, not a finished event. Your recorder still runs createAuditEvent, which is where redaction and hashing happen — so anything you add under metadata is redacted, unlike the assertion beside it.
Where these builders live
Section titled “Where these builders live”createSecurityRiskAssertion, hashAssertionRecord, and buildSecurityRiskAssessedEvent import node:crypto. They are exported from the root @veritio/core package only.
The @veritio/core/risk-score subpath is deliberately crypto-free so risk math can run in a browser bundle: scoreRiskSignals, rollupEpisodeRisk, normalizeRiskSignals, withRiskSignals, and DEFAULT_RISK_POLICY live there. The assertion builders do not, and importing them from that subpath fails. Score in the browser if you like; stamp and hash on the server or at the edge.
Python and Go expose the same three functions as create_security_risk_assertion / hash_assertion_record / build_security_risk_assessed_event and CreateSecurityRiskAssertion / HashAssertionRecord / BuildSecurityRiskAssessedEvent, pinned by the shared conformance fixtures.
Fail-closed guards
Section titled “Fail-closed guards”Because the builders never recompute a score, a corrupt conclusion would otherwise ride straight into the canonical hash. assertRiskConclusion prevents that. Both builders throw a TypeError when:
conclusion.scoreis not a number, isNaNorInfinity, or falls outside[0,1]—conclusion.score must be a finite number in [0,1].conclusion.levelis not one ofnone,low,medium,high,critical.scope.tenantId,producerId, or (for the assertion)idempotencyKeyis missing or blank.subjectis missing anauthority,type, orid, or itskindis outside the eight supported evidence-ref kinds.
One guard is asymmetric and worth knowing: the assessment enum check — step or episode_rollup — runs in createSecurityRiskAssertion only. buildSecurityRiskAssessedEvent validates the score and level but passes assessment through to metadata unchecked. Validate it yourself if the value comes from an untrusted boundary.
What an assertion proves
Section titled “What an assertion proves”A verified assertion hash proves that the record’s bytes are unchanged since it was stamped, and that the conclusion is attributed to the detector authority with a tenant-scoped idempotency hash. It does not prove the score was correct, that the policy was appropriate, or that the detector observed everything relevant — those are questions about your policy and your capture coverage, not about the record. Veritio produces evidence about a conclusion; it does not validate the judgement inside it.
Continue with risk scoring for how the conclusion is derived, risk policy tuning for changing the constants behind it, and evidence commits for publishing an assertion digest alongside audit records.