Changes, activities, and revisions
A single audit row that says subscriber.updated cannot answer who authorized the mutation, which process actually executed it, or what state the row was left in. Those are three different facts with three different actors and three different lifetimes. Veritio’s governed-change model records them as three linked records instead of one, and commits to the resulting state through a minimized projection rather than the raw row.
Three records, not one
Section titled “Three records, not one”createGovernedActionDraft turns one host mutation into a Change, an Activity, and a Revision. Each has its own authority-qualified EvidenceRef ({ authority, kind, type, id }) so it can be referenced from anywhere in the evidence graph.
Change chg_... declared intent what was asked, by whom (initiatedBy), under which authorization/delegation assertion, with a tenant-scoped idempotency-key hash
Activity act_... execution who or what actually performed it (performedBy): a service, an operator session, an AI agent
Revision rev_... resulting governed state stateCommitment, changedPaths, parents, generatedByThey separate because they vary independently. A support agent initiates; a billing service performs. One change can drive several activities, and one activity can produce revisions of several entities. Collapsing them into one event would force the initiating human and the executing service into a single actor field and lose the distinction that most access reviews are actually asking about.
In the current protocol the draft is not a new record kind. It is three ordinary audit events plus evidence edges, so a v1 store can append it without claiming atomicity that the EvidenceCommit protocol does not yet provide:
events edges change.declared change -has_activity-> activity activity.recorded change -has_output-> revision entity.revision.created activity -performed_by-> principal activity -generated-> revision revision -derived_from-> parent (conditional)All three events carry the same idempotencyKeyHash, the same captureAssurance, and the same merged Veritio context metadata, so a replay collapses cleanly at the store’s idempotency boundary rather than half-appending.
The state commitment covers the post-policy fields, never the row
Section titled “The state commitment covers the post-policy fields, never the row”defineEntity declares an allowlist. Only keys present in fields can enter a commitment or become a changed path; everything else in the host row is invisible to Veritio by construction. The builder walks the declared keys in sorted order, applies each field’s capture mode, and produces a fields object. The digest is taken over that object:
host row ──► field policy ──► fields (post-policy) ──► canonicalJson ──► sha256 │ └── this object is what ships in evidencestateCommitment.digest is therefore a commitment to the minimized projection, not to the database record. Anyone holding the evidence can recompute it from fields alone — no access to the source system required. The commitment also carries schemaRef and fieldSetRef, so a later widening or narrowing of the governed field set shows up as a different fieldSetRef rather than as a silent change in what the digest covers.
changedPaths is derived from the same allowlist: JSON Pointer segments, sorted, compared with canonical JSON on both sides. A field whose capture mode is omit is excluded from change detection entirely, and a draft in which no governed field changed fails with at least one governed field must change rather than emitting an empty revision.
How each capture mode renders
Section titled “How each capture mode renders”Four modes are implemented. They render into fields in distinguishable shapes:
full key: <the canonicalized JSON value itself>content_digest key: { captureMode: "content_digest", digest: "sha256:..." }keyed_digest key: { algorithm: "hmac-sha256", keyVersion, digest: "sha256:..." }omit key absent from the object entirelyNote the asymmetry: the keyed_digest shape carries algorithm and keyVersion but no captureMode key. Read the mode from the shape — an algorithm member means a keyed digest — not from the presence of a single field name.
randomized_digest, reference, redact, and encrypt are reserved. Selecting one throws at draft time instead of degrading to a weaker commitment, which is the point: a capture policy that quietly downgrades is worse than one that stops.
The fixture below defines one entity exercising all four implemented modes, then attempts each reserved mode:
{ "changedPaths": [ "/billingCity", "/email", "/plan" ], "stateCommitmentFields": { "billingCity": { "captureMode": "content_digest", "digest": "sha256:5cf1d78a3ca4f9f4f5bde60556503ad5158370821e20196bc104c822aeb71478" }, "email": { "algorithm": "hmac-sha256", "keyVersion": "billing-2026-01", "digest": "sha256:23822cf1c6dda29e44732233cfe4f0534b6b3302e703cc2baf17256eb408b555" }, "plan": "growth" }, "stateCommitmentDigest": "sha256:e34168aa49510393d39157292b5f6c221296f27cb1933aa6ff7d5f85fb675380", "leakProofs": { "rawEmailAppearsInDraft": false, "digestSecretAppearsInDraft": false, "omittedFieldValueAppearsInDraft": false }, "reservedCaptureModes": [ { "mode": "randomized_digest", "failedClosed": true, "error": { "name": "TypeError", "message": "capture mode randomized_digest is not supported by the current governed-change draft helper" } }, { "mode": "reference", "failedClosed": true, "error": { "name": "TypeError", "message": "capture mode reference is not supported by the current governed-change draft helper" } }, { "mode": "redact", "failedClosed": true, "error": { "name": "TypeError", "message": "capture mode redact is not supported by the current governed-change draft helper" } }, { "mode": "encrypt", "failedClosed": true, "error": { "name": "TypeError", "message": "capture mode encrypt is not supported by the current governed-change draft helper" } } ]}Reading it field by field:
changedPathslists/billingCity,/email, and/plan. The row’sinternalNotealso changed, but it is anomitfield, so it is not a governed change path. The row’sidis not declared infieldsat all, so it is not governed either.planis committed in the clear because it isfull. Enum-like governed state is usually worth reading directly in an export.billingCityis acontent_digest. Two revisions with the same city produce the same digest, which is what makes equality checks possible without the value.emailis akeyed_digest: the HMAC is computed with a host-supplied secret injected at the capture boundary. ThekeyVersiontravels with the digest so a rotation is legible later.stateCommitmentDigestissha256(canonicalJson(fields))over exactly the object above.leakProofsare computed by scanning the fully serialized draft — events, edges, revision, and outbox entry. The raw email, the HMAC secret, and the omitted note are all absent from the whole draft, not just from the commitment.
One property to understand before choosing modes: content_digest is an unsalted SHA-256 over canonical bytes. For a low-entropy value — a city, a boolean, a status enum — a holder of the evidence can confirm a guess by recomputing. That is often acceptable and sometimes desirable. When it is not, keyed_digest moves the guessing barrier behind a key that never enters evidence. The trade is symmetric: without that key nobody, including you, can recompute the digest from a candidate value.
How governedRevisionId is derived
Section titled “How governedRevisionId is derived”The revision id is a pure function of four inputs:
rev_<entityType>_<entityId>_<digest12>_<change8>
digest12 = first 12 hex chars of stateCommitment.digest (after "sha256:")change8 = first 8 hex chars of sha256(changeId)The change id, in turn, is derived from the tenant and the caller’s idempotency key: chg_<entityType>_<entityId>_<sha256(tenantId + ":" + idempotencyKey)[:16]>. Both derivations are byte-identical across the TypeScript, Python, and Go SDKs, pinned by spec/conformance/governed-revision-id.json.
The change-scoped suffix is the part worth dwelling on. A purely content-addressed id would give a rollback the same id as the state it restored, merging two genuinely different lineage nodes into one and producing a cycle in Explain views. The fixture below walks create → enable → rollback → replay to show both halves of the invariant:
{ "entity": { "ref": { "authority": "self-hosted-example", "kind": "entity", "type": "release_flag", "id": "flag_checkout" }, "refKey": "self-hosted-example:entity:release_flag:flag_checkout", "schemaRef": "release_flag@1", "fieldSetRef": "release_flag.governed@1" }, "lineage": [ { "label": "v1 create", "changedPaths": [ "/enabled", "/flagId", "/rolloutPercent" ], "parentRevisionIds": [], "derivationInputs": { "entityType": "release_flag", "entityId": "flag_checkout", "stateDigest": "sha256:e1c0e0b122f0f28071c7d23b2f50767f26849075ca2d07537a8d172b2fc84f21", "changeId": "chg_release_flag_flag_checkout_9fa0702552cde9a3" }, "committedFields": { "enabled": false, "flagId": "flag_checkout", "rolloutPercent": 0 }, "revisionId": "rev_release_flag_flag_checkout_e1c0e0b122f0_2a5b99a0", "revisionIdParts": { "stateDigest12": "e1c0e0b122f0", "changeScope8": "2a5b99a0" } }, { "label": "v2 enable at 25%", "changedPaths": [ "/enabled", "/rolloutPercent" ], "parentRevisionIds": [ "rev_release_flag_flag_checkout_e1c0e0b122f0_2a5b99a0" ], "derivationInputs": { "entityType": "release_flag", "entityId": "flag_checkout", "stateDigest": "sha256:6e562f57d9e4829b562917253bb24b9f13d78a2eda9d0c2d1f8293899fb2eb48", "changeId": "chg_release_flag_flag_checkout_e5f7cf832187d1b8" }, "committedFields": { "enabled": true, "flagId": "flag_checkout", "rolloutPercent": 25 }, "revisionId": "rev_release_flag_flag_checkout_6e562f57d9e4_3de08e1c", "revisionIdParts": { "stateDigest12": "6e562f57d9e4", "changeScope8": "3de08e1c" } }, { "label": "v3 rollback to the v1 state", "changedPaths": [ "/enabled", "/rolloutPercent" ], "parentRevisionIds": [ "rev_release_flag_flag_checkout_6e562f57d9e4_3de08e1c" ], "derivationInputs": { "entityType": "release_flag", "entityId": "flag_checkout", "stateDigest": "sha256:e1c0e0b122f0f28071c7d23b2f50767f26849075ca2d07537a8d172b2fc84f21", "changeId": "chg_release_flag_flag_checkout_a44893a9f3af30c5" }, "committedFields": { "enabled": false, "flagId": "flag_checkout", "rolloutPercent": 0 }, "revisionId": "rev_release_flag_flag_checkout_e1c0e0b122f0_7a8fa2a9", "revisionIdParts": { "stateDigest12": "e1c0e0b122f0", "changeScope8": "7a8fa2a9" } }, { "label": "v3 replay (same change, retried)", "changedPaths": [ "/enabled", "/rolloutPercent" ], "parentRevisionIds": [ "rev_release_flag_flag_checkout_6e562f57d9e4_3de08e1c" ], "derivationInputs": { "entityType": "release_flag", "entityId": "flag_checkout", "stateDigest": "sha256:e1c0e0b122f0f28071c7d23b2f50767f26849075ca2d07537a8d172b2fc84f21", "changeId": "chg_release_flag_flag_checkout_a44893a9f3af30c5" }, "committedFields": { "enabled": false, "flagId": "flag_checkout", "rolloutPercent": 0 }, "revisionId": "rev_release_flag_flag_checkout_e1c0e0b122f0_7a8fa2a9", "revisionIdParts": { "stateDigest12": "e1c0e0b122f0", "changeScope8": "7a8fa2a9" } } ], "invariants": { "rollbackStateDigestEqualsCreate": true, "contentOnlyIdWouldCollide": true, "rollbackRevisionIdDiffersFromCreate": true, "changeScopeSuffixDiffers": true, "replayChangeIdIsStable": true, "replayRevisionIdIsStable": true, "lineageParents": { "v1": [], "v2": [ "rev_release_flag_flag_checkout_e1c0e0b122f0_2a5b99a0" ], "v3": [ "rev_release_flag_flag_checkout_6e562f57d9e4_3de08e1c" ] } }, "recomputedOffline": { "v1": "rev_release_flag_flag_checkout_e1c0e0b122f0_2a5b99a0", "v3": "rev_release_flag_flag_checkout_e1c0e0b122f0_7a8fa2a9", "matchesDraftIds": true }}What each part proves:
rollbackStateDigestEqualsCreateistrue: the rollback really did restore byte-identical governed state, andcontentOnlyIdWouldCollideconfirms the two ids share the samestateDigest12component.rollbackRevisionIdDiffersFromCreateistrueanyway, becausechangeScope8differs (2a5b99a0vs7a8fa2a9). The two revisions stay distinct nodes while the shared digest still proves the states are identical.replayChangeIdIsStableandreplayRevisionIdIsStablearetrue: re-running the same change with the same tenant and idempotency key yields the same ids, so a retried outbox delivery is idempotent rather than a second lineage node.recomputedOffline.matchesDraftIdsistrue: a host that stored only(entityType, entityId, digest, changeId)can call the exportedgovernedRevisionIdand get the same string back.
Lineage is never fabricated
Section titled “Lineage is never fabricated”revision.parents is populated only when the caller supplied both a before row and an expectedParentRevisionRef:
before expectedParentRevisionRef parents derived_from outbox expectedParentRevisionRefabsent absent [] no absentabsent present [] no absentpresent absent [] no absentpresent present [parent] yes presentEarlier versions invented a rev_<type>_<id>_previous placeholder. That was wrong twice over: it asserted a derived_from edge to a revision that never existed, and it handed the host store an optimistic-concurrency token its real head could never match, so every update would either fail or force the check to be ignored. Leaving lineage open is the honest outcome.
The consequence for readers: parents: [] means lineage was not asserted here, not this is the first revision. A genuine creation and an update whose caller omitted the parent ref are indistinguishable in that field alone. Authoritative ordering has to come from the store, not from the draft.
lineagePolicy ('linear' | 'dag') sits in the entity definition and is read by nothing. No SDK and no reference store branches on it today. It records declared intent so hosts can state it without a later schema change; enforcement is host-side work tied to the host-assigned revision-ordinal design. Treat it as documentation, not as a guardrail.
What this does not prove
Section titled “What this does not prove”The revision id is derived, not an authoritative ordering key. It contains no ordinal and no timestamp. Sorting revision id strings tells you nothing about sequence; ordering comes from the per-tenant record sequence in a conforming store. The truncated components (48 bits of state digest, 32 bits of change scope) make it a stable join and display key, not a cryptographic commitment — the full commitment is stateCommitment.digest. The design target remains a host-assigned ordinal suffix, which will change every revision id string when it lands, so consume revision.ref.id as an opaque key and never parse it in production code.
A digest match proves projection equality, not row equality. Two rows differing only in omitted or undeclared fields commit to the same digest. That is the intended minimization, and it is also the limit of what the commitment can attest.
Drafting is not durability. createGovernedActionDraft is a pure function. It reads no environment, touches no database, and proves nothing until the returned records are appended. mutationBinding (same_transaction, not_transaction_bound, best_effort) is a declared label recorded in captureAssurance; the SDK cannot verify that your transaction actually enclosed the append. Whether the evidence is genuinely bound to the mutation is a property of your outbox, not of the draft.
Continue with Governed actions to wire a mutation boundary, Transactional outbox for the delivery half, or Evidence graph to see how these refs and edges are queried. The hash chain covers what happens to these events once appended.