Governed actions
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”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.
browser → intent only (form state, validation, optimistic UI)adapter → passes request/auth context throughhost mutation path → defineEntity + createGovernedActionDraft ← evidence is produced here db write + outbox insert, one transactionoutbox dispatcher → createAuditEvent + AuditStore.appendIt 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”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.schemaRefandfieldSetRef— 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”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:
bun src/examples/governed/action-draft.tsOne seed, two ids
Section titled “One seed, two ids”createGovernedActionDraft derives a single 16-hex seed and uses it for both generated ids:
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:
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”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”{ "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: trueproves the two ids were derived from onesha256(tenantId:idempotencyKey)value, so replay is idempotent rather than graph-forking.changedPathsis["/plan", "/seatCount"].internalNotesalso changed in the after row, but it is declaredomit, so it is invisible to the derivation.status,id, andaccountEmailare governed but unchanged, so they are committed without being listed as changed.committedFieldslists the five declared, non-omitted fields, andomittedFieldPresent: falseconfirms theomitdecision held all the way into the commitment.accountEmailCommitmentshows acontent_digestfield: 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.digestissha256over the canonical JSON of the whole committed field map, labelled with theveritio-json-v1canonicalization, plus theschemaRefandfieldSetRefthat say what was committed.eventActionsis exactlychange.declared,activity.recorded,entity.revision.created— one event for the intent, one for the execution, one for the resulting state.edgeRelationsis exactlyhas_activity,has_output,performed_by,generated.
The graph those four edges describe:
change ──has_activity──▶ activity ──performed_by──▶ principal │ │ └────has_output─────────▶│ └──generated──▶ revision ──▶ entityA 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”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.
createGovernedActionDraft → AuditEventInput[] + EvidenceEdgeInput[] + outboxEntrycreateAuditEvent → validated, redacted AuditEventAuditStore.append → AuditRecord (sequence, previousHash, hash, appendedAt)Until a conforming AuditStore 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.
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”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 onchange.declared.performedBy— who executed it. It becomes the actor onactivity.recordedand the target of theperformed_byedge. This may be anai_agent, aservice, or asystem, not just auser.producer— which component emitted the evidence. It becomes the actor onentity.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”A governed mutation either produces a complete, attributable draft or throws before any evidence exists. There is no partial draft and no degraded mode.
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, ),){ "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.tenantIdthere 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
authorityorid. 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
entityref where a principal belongs throwsprincipal ref is requiredrather 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, passchangedPathsexplicitly 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”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.
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.
- Evidence graph — how change, activity, revision, and entity nodes connect once the edges are appended.
- Hash chain — what the store adds to turn a draft into tamper-evident evidence.
- Redaction — deterministic minimization for the metadata that travels alongside a governed change.
- Storage overview — which adapters can own authoritative ordering for the append.