# Changes, activities, and revisions

Kind

concept

For

developer · governance

Verified against

@veritio/core@0.4.7

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”](#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.

```text
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, generatedBy
```

They 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](/docs/concepts/evidence-commits/) protocol does not yet provide:

```text
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”](#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:

```text
host row  ──► field policy ──► fields (post-policy) ──► canonicalJson ──► sha256
                                     │
                                     └── this object is what ships in evidence
```

`stateCommitment.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”](#how-each-capture-mode-renders)

Four modes are implemented. They render into `fields` in distinguishable shapes:

```text
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 entirely
```

Note 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:

verified output: governed/capture-modes

```json
{
  "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:

-   `changedPaths` lists `/billingCity`, `/email`, and `/plan`. The row’s `internalNote` also changed, but it is an `omit` field, so it is not a governed change path. The row’s `id` is not declared in `fields` at all, so it is not governed either.
-   `plan` is committed in the clear because it is `full`. Enum-like governed state is usually worth reading directly in an export.
-   `billingCity` is a `content_digest`. Two revisions with the same city produce the same digest, which is what makes equality checks possible without the value.
-   `email` is a `keyed_digest`: the HMAC is computed with a host-supplied secret injected at the capture boundary. The `keyVersion` travels with the digest so a rotation is legible later.
-   `stateCommitmentDigest` is `sha256(canonicalJson(fields))` over exactly the object above.
-   `leakProofs` are 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”](#how-governedrevisionid-is-derived)

The revision id is a pure function of four inputs:

```text
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:

verified output: governed/lineage-and-revision-id

```json
{
  "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:

-   `rollbackStateDigestEqualsCreate` is `true`: the rollback really did restore byte-identical governed state, and `contentOnlyIdWouldCollide` confirms the two ids share the same `stateDigest12` component.
-   `rollbackRevisionIdDiffersFromCreate` is `true` anyway, because `changeScope8` differs (`2a5b99a0` vs `7a8fa2a9`). The two revisions stay distinct nodes while the shared digest still proves the states are identical.
-   `replayChangeIdIsStable` and `replayRevisionIdIsStable` are `true`: 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.matchesDraftIds` is `true`: a host that stored only `(entityType, entityId, digest, changeId)` can call the exported `governedRevisionId` and get the same string back.

## Lineage is never fabricated

[Section titled “Lineage is never fabricated”](#lineage-is-never-fabricated)

`revision.parents` is populated only when the caller supplied **both** a `before` row and an `expectedParentRevisionRef`:

```text
before   expectedParentRevisionRef   parents      derived_from   outbox expectedParentRevisionRef
absent   absent                      []           no             absent
absent   present                     []           no             absent
present  absent                      []           no             absent
present  present                     [parent]     yes            present
```

Earlier 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”](#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](/docs/guides/governed-actions/) to wire a mutation boundary, [Transactional outbox](/docs/guides/transactional-outbox/) for the delivery half, or [Evidence graph](/docs/concepts/evidence-graph/) to see how these refs and edges are queried. The [hash chain](/docs/concepts/hash-chain/) covers what happens to these events once appended.

[Edit page](https://github.com/getveritio/veritio-website/edit/main/src/content/docs/docs/concepts/governed-changes.mdx)

Last updated: Aug 23, 2026

[Previous  
Evidence commits](/docs/concepts/evidence-commits/)[Next  
Risk scoring](/docs/concepts/risk-scoring/)

Veritio provides evidence support, not legal advice or automatic compliance.

This site uses cookieless, anonymous analytics (Umami) by default. With your consent, we also enable Google Analytics, which sets cookies and sends usage data to Google. [Privacy Policy](/legal/privacy/)
