# Governed actions

Kind

tutorial

For

developer · operator · governance

Verified against

@veritio/core@0.4.7

Audit trails scraped from application logs are reconstructions. They are written after the fact, by a process that did not hold the transaction, from text that was never designed to answer “who changed which field, from what, to what, under whose authority”. A governed action inverts that: the mutation boundary itself produces the evidence, from the same before and after rows the database write uses, in the same transaction.

`defineEntity` and `createGovernedActionDraft` are the two calls that do it. Both are pinned across the TypeScript, Python, and Go SDKs by `spec/conformance/governed-action-draft.json`, so the same row change produces the same ids, the same changed paths, and the same state digest in every language.

## Where this code lives

[Section titled “Where this code lives”](#where-this-code-lives)

The helper belongs in the host application’s server-side mutation path: the route handler, server action, or service method that has already authorized the request, resolved the tenant, and loaded the row for update.

```text
browser            → intent only (form state, validation, optimistic UI)
adapter            → passes request/auth context through
host mutation path → defineEntity + createGovernedActionDraft   ← evidence is produced here
                     db write + outbox insert, one transaction
outbox dispatcher  → createAuditEvent + AuditStore.append
```

It does not belong in an adapter package. Adapters translate framework shapes; they do not own changed-path derivation, idempotency, storage, or protocol semantics. It does not belong in browser code at all: the browser cannot be trusted for the tenant id, the actor identity, the before row, or the transaction, and any digest key material passed to the helper must never leave the server.

## Declaring the entity and its capture policy

[Section titled “Declaring the entity and its capture policy”](#declaring-the-entity-and-its-capture-policy)

`defineEntity` registers what a governed row _is_ for evidence purposes. It stores a declaration; it never reads your database or your environment.

Five things are required, and each one carries weight later:

-   `authority` — who is authoritative for this identifier namespace. It qualifies every ref the entity produces, so ids stay safe to join across systems.
-   `type` — the entity type, used in refs, event targets, and derived ids.
-   `schemaRef` and `fieldSetRef` — stable pointers to the row schema version and the governed field-set version. They are recorded inside the state commitment so a later reader can tell which field set a digest was computed over.
-   `identity(row)` — extracts the entity id from a row.
-   `fields` — the per-field capture policy. This is the only place capture is decided.

Four capture modes are implemented in the v1 state-commitment builder: `full` commits the value, `content_digest` commits `sha256` of the field’s canonical JSON, `keyed_digest` commits an HMAC-SHA-256 under a caller-supplied key version and secret, and `omit` excludes the field entirely. The remaining declared modes (`randomized_digest`, `reference`, `redact`, `encrypt`) are reserved: selecting one throws at draft time rather than silently producing a weaker commitment.

The consequence that matters most is negative. The commitment loop iterates `entity.fields` only. A column you did not declare cannot enter the state commitment, cannot become a changed path, and cannot leak into the outbox — which is what keeps the outbox from becoming a shadow copy of your production table.

## Building the draft

[Section titled “Building the draft”](#building-the-draft)

src/examples/governed/action-draft.ts

```ts
import {
  type GovernedChangeDraft,
  createGovernedActionDraft,
  defineEntity,
} from '@veritio/core'

/**
 * Proves the governed-action draft invariants a host application depends on at
 * its mutation boundary:
 *
 * - `defineEntity` is the only place field capture is decided: an `omit` field
 *   can never reach the state commitment, and a `content_digest` field is
 *   committed as a digest rather than a raw value, so the outbox never becomes
 *   a copy of the application row.
 * - `createGovernedActionDraft` derives the change id and the activity id from
 *   ONE seed — sha256(`tenantId:idempotencyKey`) — so a replayed mutation
 *   reproduces the same change/activity pair instead of forking the graph.
 * - Changed paths are inferred only from governed fields, and the draft emits
 *   three event actions plus four edge relations.
 * - The draft is INPUT ONLY. It carries no store-assigned sequence and no
 *   record hash; nothing exists as evidence until a conforming `AuditStore`
 *   appends it.
 *
 * Every id, timestamp, and idempotency key is pinned so the printed output is
 * byte-stable across runs and across the TS/Python/Go SDKs.
 */
type SubscriptionRow = {
  id: string
  accountEmail: string
  plan: string
  seatCount: number
  status: string
  internalNotes: string
}

export const governedScope = { tenantId: 'org_acme', environment: 'production' } as const

const subscription = defineEntity<SubscriptionRow>({
  authority: 'acme-billing',
  type: 'subscription',
  schemaRef: 'acme://schemas/subscription@3',
  fieldSetRef: 'acme://fieldsets/subscription-governed@1',
  identity: (row) => row.id,
  fields: {
    id: { capture: 'full' },
    plan: { capture: 'full' },
    seatCount: { capture: 'full' },
    status: { capture: 'full' },
    // PII stays out of evidence as a value; only its digest is committed.
    accountEmail: { capture: 'content_digest' },
    // Ungoverned operator prose never enters the commitment or changed paths.
    internalNotes: { capture: 'omit' },
  },
})

const before: SubscriptionRow = {
  id: 'sub_9f31',
  accountEmail: 'billing@acme.example',
  plan: 'team',
  seatCount: 12,
  status: 'active',
  internalNotes: 'renewal call scheduled',
}

const after: SubscriptionRow = {
  ...before,
  plan: 'enterprise',
  seatCount: 25,
  internalNotes: 'upgrade approved on the renewal call',
}

/**
 * Builds the pinned upgrade draft shared by the documentation page and this
 * fixture's byte-compared output. Kept as an exported function so the docs can
 * show one call site while CI re-runs the identical derivation.
 */
export function buildSubscriptionUpgradeDraft(): GovernedChangeDraft {
  return createGovernedActionDraft({
    scope: governedScope,
    entity: subscription,
    before,
    after,
    actionType: 'subscription.upgraded',
    activityType: 'billing.plan_change',
    initiatedBy: { authority: 'acme-billing', kind: 'principal', type: 'user', id: 'usr_owner' },
    performedBy: { authority: 'acme-billing', kind: 'principal', type: 'service', id: 'svc_billing_api' },
    producer: { authority: 'acme-billing', kind: 'principal', type: 'service', id: 'svc_billing_api' },
    occurredAt: '2026-08-09T10:00:00.000Z',
    idempotencyKey: 'subscription.upgraded:sub_9f31:req_7c2a',
    mutationBinding: 'same_transaction',
  })
}

if (import.meta.main) {
  const draft = buildSubscriptionUpgradeDraft()
  const idSeed = draft.changeRef.id.slice(draft.changeRef.id.lastIndexOf('_') + 1)

  const output = {
    derivedIds: {
      changeId: draft.changeRef.id,
      activityId: draft.activityRef.id,
      entityId: draft.entityRef.id,
      revisionId: draft.revision.ref.id,
      idSeed,
      // Both ids are `<prefix>_<entityType>_<entityId>_<seed>` off the same
      // sha256(tenantId:idempotencyKey) seed, so replay is idempotent.
      changeAndActivityShareOneSeed:
        draft.changeRef.id === `chg_subscription_${draft.entityRef.id}_${idSeed}` &&
        draft.activityRef.id === `act_subscription_${draft.entityRef.id}_${idSeed}`,
    },
    changedPaths: draft.revision.changedPaths,
    stateCommitment: {
      algorithm: draft.revision.stateCommitment.algorithm,
      canonicalization: draft.revision.stateCommitment.canonicalization,
      schemaRef: draft.revision.stateCommitment.schemaRef,
      fieldSetRef: draft.revision.stateCommitment.fieldSetRef,
      digest: draft.revision.stateCommitment.digest,
      committedFields: Object.keys(draft.revision.stateCommitment.fields).sort(),
      // `internalNotes` is declared `omit`, so it is absent from the commitment
      // and can never become a governed changed path.
      omittedFieldPresent: 'internalNotes' in draft.revision.stateCommitment.fields,
      accountEmailCommitment: draft.revision.stateCommitment.fields.accountEmail,
    },
    eventActions: draft.events.map((event) => event.action),
    edgeRelations: draft.edges.map((edge) => edge.relation),
    draftIsInputOnly: {
      note: 'createGovernedActionDraft returns evidence INPUTS, not persisted records. Append draft.outboxEntry through a conforming AuditStore inside the same mutation to make it evidence.',
      eventsCarryStoreAssignedSequence: draft.events.some((event) => 'sequence' in event),
      eventsCarryRecordHash: draft.events.some((event) => 'hash' in event),
      outboxMutationBinding: draft.outboxEntry.mutationBinding,
      outboxSchemaVersion: draft.outboxEntry.schemaVersion,
    },
  }

  console.log(JSON.stringify(output, null, 2))
}
```

Run it with:

Terminal window

```sh
bun src/examples/governed/action-draft.ts
```

### One seed, two ids

[Section titled “One seed, two ids”](#one-seed-two-ids)

`createGovernedActionDraft` derives a single 16-hex seed and uses it for both generated ids:

```text
idSeed   = sha256("<tenantId>:<idempotencyKey>")[0..16]
changeId = chg_<entityType>_<entityId>_<idSeed>
activityId = act_<entityType>_<entityId>_<idSeed>
```

Because both ids come from one seed, a replayed request — the same tenant, the same idempotency key — reproduces the same change/activity pair instead of forking a second branch into the evidence graph. You can override either id explicitly when the host already owns a change identifier.

The seed is deliberately _not_ the idempotency hash that goes into the records. That value is `hashIdempotencyKey(tenantId, idempotencyKey)`, a full SHA-256 over `tenantId`, a NUL byte, and the key. It is stamped on all three events and is what a store uses to reject a conflicting replay. The seed is a short public id suffix; the hash is the integrity token. Neither is reversible to the raw key.

The revision id is derived differently again, from content plus cause:

```text
revisionId = rev_<entityType>_<entityId>_<digest12>_<change8>
             digest12 = first 12 hex of the state digest
             change8  = first 8 hex of sha256(changeId)
```

Content-addressing alone would collapse a rollback that restores a byte-identical earlier state into the earlier revision. Mixing in the change makes that rollback a distinct revision node while keeping a replay of the _same_ change idempotent.

### Changed paths

[Section titled “Changed paths”](#changed-paths)

When you do not pass `changedPaths`, the helper infers them. It walks the declared fields in sorted key order, skips `omit` fields and fields absent from the after row, and compares canonical JSON of the before and after values. A create (no `before`) marks every declared, present, non-omitted field as changed. The lower-level builder then sorts the result, so `revision.changedPaths` is always a sorted array of top-level JSON Pointers.

Two limits are worth stating plainly. Paths are top-level only: a change deep inside a `full`\-captured object registers as one pointer for that field, not a pointer to the nested leaf. And the escape rules are JSON Pointer’s own — `~` becomes `~0`, `/` becomes `~1` — so a field name containing a slash still produces one valid segment.

## What the verified output proves

[Section titled “What the verified output proves”](#what-the-verified-output-proves)

verified output

```json
{
  "derivedIds": {
    "changeId": "chg_subscription_sub_9f31_312ca4bde590b545",
    "activityId": "act_subscription_sub_9f31_312ca4bde590b545",
    "entityId": "sub_9f31",
    "revisionId": "rev_subscription_sub_9f31_a71e2768812c_fe34f6d0",
    "idSeed": "312ca4bde590b545",
    "changeAndActivityShareOneSeed": true
  },
  "changedPaths": [
    "/plan",
    "/seatCount"
  ],
  "stateCommitment": {
    "algorithm": "sha256",
    "canonicalization": "veritio-json-v1",
    "schemaRef": "acme://schemas/subscription@3",
    "fieldSetRef": "acme://fieldsets/subscription-governed@1",
    "digest": "sha256:a71e2768812cca4e3609ce7e24460967d253e1ddab13d9a8898f4fd31a3564cb",
    "committedFields": [
      "accountEmail",
      "id",
      "plan",
      "seatCount",
      "status"
    ],
    "omittedFieldPresent": false,
    "accountEmailCommitment": {
      "captureMode": "content_digest",
      "digest": "sha256:aa69b0bc0b21a0ca7b5b5d1beb7556eb7d598f21d638db3eb9770ddd4fb30e45"
    }
  },
  "eventActions": [
    "change.declared",
    "activity.recorded",
    "entity.revision.created"
  ],
  "edgeRelations": [
    "has_activity",
    "has_output",
    "performed_by",
    "generated"
  ],
  "draftIsInputOnly": {
    "note": "createGovernedActionDraft returns evidence INPUTS, not persisted records. Append draft.outboxEntry through a conforming AuditStore inside the same mutation to make it evidence.",
    "eventsCarryStoreAssignedSequence": false,
    "eventsCarryRecordHash": false,
    "outboxMutationBinding": "same_transaction",
    "outboxSchemaVersion": "2026-06-23"
  }
}
```

Read it field by field:

-   `changeAndActivityShareOneSeed: true` proves the two ids were derived from one `sha256(tenantId:idempotencyKey)` value, so replay is idempotent rather than graph-forking.
-   `changedPaths` is `["/plan", "/seatCount"]`. `internalNotes` also changed in the after row, but it is declared `omit`, so it is invisible to the derivation. `status`, `id`, and `accountEmail` are governed but unchanged, so they are committed without being listed as changed.
-   `committedFields` lists the five declared, non-omitted fields, and `omittedFieldPresent: false` confirms the `omit` decision held all the way into the commitment.
-   `accountEmailCommitment` shows a `content_digest` field: the evidence carries `{ captureMode, digest }`, never the address. The digest is over the field’s canonical JSON, so it is stable across languages and comparable across revisions without disclosing the value.
-   `digest` is `sha256` over the canonical JSON of the whole committed field map, labelled with the `veritio-json-v1` canonicalization, plus the `schemaRef` and `fieldSetRef` that say what was committed.
-   `eventActions` is exactly `change.declared`, `activity.recorded`, `entity.revision.created` — one event for the intent, one for the execution, one for the resulting state.
-   `edgeRelations` is exactly `has_activity`, `has_output`, `performed_by`, `generated`.

The graph those four edges describe:

```text
change ──has_activity──▶ activity ──performed_by──▶ principal
   │                        │
   └────has_output─────────▶│
                            └──generated──▶ revision ──▶ entity
```

A fifth edge, `derived_from`, is appended only when you supply both a `before` row and an `expectedParentRevisionRef`. The helper never fabricates a placeholder parent: a synthetic one would assert lineage to a revision that never existed and hand the store an optimistic-concurrency token its real head can never match. When the parent is unknown, lineage is left open for the host store to resolve.

## The draft is input, not evidence

[Section titled “The draft is input, not evidence”](#the-draft-is-input-not-evidence)

`draftIsInputOnly` is the part most easily misread. `eventsCarryStoreAssignedSequence` and `eventsCarryRecordHash` are both `false`, and that is correct: the helper returns `AuditEventInput` values. Nothing here has a tenant-local sequence number, a previous hash, a record hash, or an append time. Those belong to the store.

```text
createGovernedActionDraft → AuditEventInput[] + EvidenceEdgeInput[] + outboxEntry
createAuditEvent          → validated, redacted AuditEvent
AuditStore.append         → AuditRecord (sequence, previousHash, hash, appendedAt)
```

Until a conforming [`AuditStore`](/docs/storage/overview/) has appended the records, nothing has been proven — a draft is a well-formed proposal, not a tamper-evident fact. The `outboxEntry` exists to make that hop recoverable: it carries `schemaVersion`, the records, the edges, the `mutationBinding`, and the expected parent when one was supplied. Insert it in the same transaction as the row write.

```ts
await db.transaction(async (tx) => {
  await tx.subscription.update(after)
  await tx.veritioOutbox.insert(draft.outboxEntry)
})
```

`mutationBinding` records how strong that coupling actually was — `same_transaction`, `best_effort`, or the default `not_transaction_bound`. It is a claim about your delivery path, stamped into each event’s capture assurance. Declare what is true; the value is evidence about the evidence.

## Three principals, three different questions

[Section titled “Three principals, three different questions”](#three-principals-three-different-questions)

The helper takes three principal refs because they answer three questions that routinely have different answers.

-   `initiatedBy` — whose authority the change runs under. It becomes the actor on `change.declared`.
-   `performedBy` — who executed it. It becomes the actor on `activity.recorded` and the target of the `performed_by` edge. This may be an `ai_agent`, a `service`, or a `system`, not just a `user`.
-   `producer` — which component emitted the evidence. It becomes the actor on `entity.revision.created`.

An agent acting under a user’s delegated authority is the case this shape exists for: `initiatedBy` stays the human, `performedBy` becomes the agent, `producer` stays the API service. Collapsing all three into one field destroys exactly the distinction an after-the-fact reviewer needs. Each ref is authority-qualified, and the audit actor id is written as `<authority>:<id>` so identifiers from different identity systems never silently collide.

Two further conventions apply to metadata. Reserved context keys — `traceId`, `correlationId`, `activityEpisodeId`, `changeId`, `capturePolicyId`, `collectionSource`, and the auth-session keys — are applied after caller metadata and cannot be shadowed; passing one inside `metadata` throws. And `occurredAt` defaults to `new Date()`, which is fine in production and fatal for reproducibility, so pin it whenever you need byte-stable output.

## Fail-closed behavior

[Section titled “Fail-closed behavior”](#fail-closed-behavior)

A governed mutation either produces a complete, attributable draft or throws before any evidence exists. There is no partial draft and no degraded mode.

src/examples/governed/fail-closed.ts

```ts
import {
  type EvidenceRef,
  type GovernedActionDraftInput,
  createGovernedActionDraft,
  defineEntity,
} from '@veritio/core'

/**
 * Proves that `createGovernedActionDraft` fails closed: a governed mutation
 * either produces a complete, attributable draft or throws before any evidence
 * exists. Each case below removes exactly one required guarantee from an
 * otherwise valid draft input and prints the thrown error verbatim, so the
 * documented guard messages stay pinned to real 0.4.7 behavior. Every id,
 * timestamp, and key is fixed, keeping the output byte-stable for CI.
 */

type BillingPlanRow = {
  id: string
  planTier: string
  seatLimit: string
}

const billingPlan = defineEntity<BillingPlanRow>({
  authority: 'acme.example',
  type: 'billing_plan',
  schemaRef: 'acme.billing_plan.v1',
  fieldSetRef: 'acme.billing_plan.governed.v1',
  identity: (row) => row.id,
  fields: {
    planTier: { capture: 'full' },
    seatLimit: { capture: 'content_digest' },
  },
})

const owner: EvidenceRef = {
  authority: 'acme.example',
  kind: 'principal',
  type: 'user',
  id: 'usr_owner',
}

const producer: EvidenceRef = {
  authority: 'acme.example',
  kind: 'principal',
  type: 'service',
  id: 'svc_billing_api',
}

const currentPlan: BillingPlanRow = {
  id: 'plan_9001',
  planTier: 'team',
  seatLimit: '25',
}

/**
 * Returns the fully valid governed-action input used as the control. Each guard
 * case starts from this object and breaks a single required guarantee, so a
 * thrown error can only be attributed to that one removed guarantee.
 */
function validInput(): GovernedActionDraftInput<BillingPlanRow> {
  return {
    scope: { tenantId: 'org_acme', environment: 'production' },
    entity: billingPlan,
    before: currentPlan,
    after: { ...currentPlan, planTier: 'enterprise' },
    actionType: 'billing.plan.upgraded',
    activityType: 'billing.plan.upgrade',
    initiatedBy: owner,
    performedBy: owner,
    producer,
    occurredAt: '2026-08-09T10:00:00.000Z',
    idempotencyKey: 'billing:plan_9001:upgrade:1',
  }
}

/**
 * Applies one mutation to the control input and reports whether the draft
 * builder threw. Reporting `thrown: false` instead of silently passing keeps an
 * unenforced guard visible in the expected output rather than hidden.
 */
function guardResult(
  guard: string,
  mutate: (input: GovernedActionDraftInput<BillingPlanRow>) => GovernedActionDraftInput<BillingPlanRow>,
): Record<string, unknown> {
  try {
    createGovernedActionDraft(mutate(validInput()))
    return { guard, thrown: false }
  } catch (error) {
    const failure = error as Error
    return { guard, thrown: true, errorName: failure.name, message: failure.message }
  }
}

const control = createGovernedActionDraft(validInput())

const guards = [
  guardResult('missing tenant scope', (input) => ({
    ...input,
    scope: { environment: 'production' } as GovernedActionDraftInput<BillingPlanRow>['scope'],
  })),
  guardResult('empty idempotencyKey', (input) => ({ ...input, idempotencyKey: '   ' })),
  guardResult('principal ref missing authority', (input) => ({
    ...input,
    initiatedBy: { kind: 'principal', type: 'user', id: 'usr_owner' } as unknown as EvidenceRef,
  })),
  guardResult('principal ref missing id', (input) => ({
    ...input,
    performedBy: { ...owner, id: '' },
  })),
  guardResult('producer ref is not a principal', (input) => ({
    ...input,
    producer: { ...producer, kind: 'entity' },
  })),
  guardResult('no governed field changed', (input) => ({
    ...input,
    after: { ...currentPlan },
  })),
]

console.log(
  JSON.stringify(
    {
      control: {
        changeId: control.changeRef.id,
        activityId: control.activityRef.id,
        revisionId: control.revision.ref.id,
        changedPaths: control.revision.changedPaths,
        eventActions: control.events.map((event) => event.action),
      },
      guards,
      allGuardsFailClosed: guards.every((guard) => guard.thrown === true),
    },
    null,
    2,
  ),
)
```

verified output

```json
{
  "control": {
    "changeId": "chg_billing_plan_plan_9001_2d4e4ac92afa4a8a",
    "activityId": "act_billing_plan_plan_9001_2d4e4ac92afa4a8a",
    "revisionId": "rev_billing_plan_plan_9001_85271b1aa8f9_172321d5",
    "changedPaths": [
      "/planTier"
    ],
    "eventActions": [
      "change.declared",
      "activity.recorded",
      "entity.revision.created"
    ]
  },
  "guards": [
    {
      "guard": "missing tenant scope",
      "thrown": true,
      "errorName": "TypeError",
      "message": "scope.tenantId is required"
    },
    {
      "guard": "empty idempotencyKey",
      "thrown": true,
      "errorName": "TypeError",
      "message": "idempotencyKey is required"
    },
    {
      "guard": "principal ref missing authority",
      "thrown": true,
      "errorName": "TypeError",
      "message": "ref.authority is required"
    },
    {
      "guard": "principal ref missing id",
      "thrown": true,
      "errorName": "TypeError",
      "message": "ref.id is required"
    },
    {
      "guard": "producer ref is not a principal",
      "thrown": true,
      "errorName": "TypeError",
      "message": "principal ref is required"
    },
    {
      "guard": "no governed field changed",
      "thrown": true,
      "errorName": "TypeError",
      "message": "at least one governed field must change"
    }
  ],
  "allGuardsFailClosed": true
}
```

Each case removes exactly one guarantee from an otherwise valid input, so the thrown error is attributable to that removal alone:

-   **Missing tenant scope.** Without `scope.tenantId` there is no chain to append to and no idempotency scope. `scope.tenantId is required`.
-   **Empty idempotency key.** A blank or whitespace key would make replays indistinguishable from new changes. `idempotencyKey is required`.
-   **Refs missing `authority` or `id`.** An unqualified reference cannot be joined safely across systems, so refs are validated before they cross the SDK boundary.
-   **Producer that is not a principal.** Passing an `entity` ref where a principal belongs throws `principal ref is required` rather than writing an actor that cannot be resolved.
-   **No governed field changed.** An update that touches nothing governed throws `at least one governed field must change`. A no-op must not mint a new revision. If you genuinely need to record a change the field policy cannot see, pass `changedPaths` explicitly and accept that you are asserting it.

Two more failures live in the commitment builder: a `keyed_digest` field without `digestKeys.keyedDigest` throws, and a reserved capture mode throws instead of downgrading to a weaker commitment.

## What this does not prove

[Section titled “What this does not prove”](#what-this-does-not-prove)

A well-formed draft proves that the values you passed were derived deterministically under the declared field policy. It does not prove any of the following.

It does not prove the mutation happened. The draft is built from rows you supplied; if the database write later rolls back and the outbox insert rolls back with it, no evidence is created — but if you insert the outbox row outside the transaction, you can produce evidence for a write that never landed. That is what `mutationBinding` is for, and it is a self-declared value.

It does not prove the before row was the true prior state. The helper compares what you hand it. A stale read produces a truthful record of a false comparison. Load the row for update inside the same transaction.

It does not prove tamper-evidence on its own. Sequence numbers, previous hashes, and record hashes are added by the store; a draft that is never appended is only a proposal. See [Hash chain](/docs/concepts/hash-chain/).

It does not prove the actors were who they claimed. `initiatedBy` and `performedBy` are asserted by your server from its own authenticated context. Veritio records the assertion; your authorization layer is what makes it credible.

And it does not prove that omitted fields were unchanged — only that they were never governed. `omit` is a decision to have no evidence about a field, which is a legitimate minimization choice and a permanent one for records already written. Choose the field set before you start recording.

This is evidence tooling. It supports compliance work and legal review; it is not legal advice, and a valid draft is not a legal conclusion.

## Next

[Section titled “Next”](#next)

-   [Evidence graph](/docs/concepts/evidence-graph/) — how change, activity, revision, and entity nodes connect once the edges are appended.
-   [Hash chain](/docs/concepts/hash-chain/) — what the store adds to turn a draft into tamper-evident evidence.
-   [Redaction](/docs/concepts/redaction/) — deterministic minimization for the metadata that travels alongside a governed change.
-   [Storage overview](/docs/storage/overview/) — which adapters can own authoritative ordering for the append.

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

Last updated: Aug 23, 2026

[Previous  
Retention](/docs/concepts/retention/)[Next  
Transactional outbox](/docs/guides/transactional-outbox/)

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/)
