Governed change API
A host application that mutates a row and then separately “logs” the change produces two artifacts that can disagree. The governed-change builders remove that gap: one call at the mutation boundary derives the change identity, the activity identity, the minimized state commitment, the changed paths, the event set, the edge set, and a single outbox entry to append. This page is the signature and semantics reference for those builders across the three SDKs.
The six exported symbols
Section titled “The six exported symbols”| Symbol | Role |
|---|---|
defineEntity |
Declares one governed entity type and its per-field capture policy. |
createGovernedActionDraft |
Host-facing helper. Derives ids, idempotency hash, and changed paths, then delegates. |
createGovernedChangeDraft |
Lower-level builder. Owns event, edge, revision, and outbox semantics. |
governedRevisionId |
Derives the deterministic revision id from entity, state digest, and change id. |
mergeVeritioMetadata |
Merges caller metadata with SDK-owned context keys, rejecting shadowing. |
refKey |
Formats an authority-qualified EvidenceRef into a stable join key. |
createGovernedActionDraft is the one most applications call. It is a derivation layer only — every protocol decision belongs to createGovernedChangeDraft, which it calls.
Signatures
Section titled “Signatures”TypeScript
Section titled “TypeScript”function defineEntity<Row extends Record<string, unknown>>( definition: { authority: string type: string schemaRef: string fieldSetRef: string identity(row: Row): string fields: Partial<Record<keyof Row & string, { capture: CaptureMode }>> lineagePolicy?: 'linear' | 'dag' // RESERVED, not enforced by any SDK },): GovernedEntity<Row> // adds ref(rowOrId: Row | string): EvidenceRef
function createGovernedActionDraft<Row extends Record<string, unknown>>(input: { scope: EvidenceScope & { tenantId: string } entity: GovernedEntity<Row> before?: Row after: Row actionType: string activityType: string initiatedBy: EvidenceRef performedBy: EvidenceRef producer: EvidenceRef occurredAt?: string | Date // defaults to new Date() idempotencyKey: string changeId?: string activityId?: string changedPaths?: string[] // inferred from the field policy when absent context?: VeritioContextMetadata metadata?: Record<string, unknown> capturePolicyRef?: { id: string; version: string } expectedParentRevisionRef?: EvidenceRef mutationBinding?: 'same_transaction' | 'not_transaction_bound' | 'best_effort' digestKeys?: { keyedDigest?: { keyVersion: string; secret: string } }}): GovernedChangeDraft
function createGovernedChangeDraft<Row extends Record<string, unknown>>(input: { scope: EvidenceScope & { tenantId: string } entity: GovernedEntity<Row> before?: Row after: Row changedPaths: string[] // required here, never inferred change: { id: string type: string initiatedBy: EvidenceRef authorizationAssertionRef?: EvidenceRef delegationAssertionRef?: EvidenceRef } activity: { id: string; type: string; performedBy: EvidenceRef } producer: EvidenceRef occurredAt: string | Date // required here, no default idempotencyKeyHash: string // required here, caller-computed // context / metadata / capturePolicyRef / expectedParentRevisionRef // / mutationBinding / digestKeys as above}): GovernedChangeDraft
function governedRevisionId( entityType: string, entityId: string, stateDigest: string, changeId: string,): string
function mergeVeritioMetadata( callerMetadata?: Record<string, unknown>, context?: VeritioContextMetadata,): Record<string, unknown>
function refKey(ref: EvidenceRef): stringPython
Section titled “Python”def define_entity( *, # keyword-only authority: str, entity_type: str, # emitted as the protocol key "type" schema_ref: str, field_set_ref: str, identity: Callable[[dict[str, Any]], str], fields: dict[str, dict[str, str]], lineage_policy: str | None = None,) -> dict[str, Any] # a plain dict; there is no .ref() method
def create_governed_action_draft(input_action: dict[str, Any]) -> dict[str, Any]def create_governed_change_draft(input_change: dict[str, Any]) -> dict[str, Any]
def governed_revision_id( entity_type: str, entity_id: str, state_digest: str, change_id: str,) -> str
def merge_veritio_metadata( caller_metadata: dict[str, Any] | None = None, context: dict[str, Any] | None = None,) -> dict[str, Any]
def ref_key(ref: dict[str, Any]) -> strThe two draft builders take one dictionary whose keys are the protocol camelCase names — scope, entity, after, actionType, idempotencyKey, occurredAt, mutationBinding, digestKeys. Only define_entity uses snake_case parameters.
func DefineEntity(definition GovernedEntityDefinition) (GovernedEntityDefinition, error)
func CreateGovernedActionDraft(input GovernedActionDraftInput) (GovernedChangeDraft, error)func CreateGovernedChangeDraft(input GovernedChangeDraftInput) (GovernedChangeDraft, error)
func GovernedRevisionID(entityType, entityID, stateDigest, changeID string) string
func MergeVeritioMetadata(callerMetadata, context map[string]any) (map[string]any, error)
func RefKey(ref EvidenceRef) (string, error)
type GovernedEntityDefinition struct { Authority, Type, SchemaRef, FieldSetRef string Identity func(map[string]any) string // must be non-nil Fields map[string]EntityFieldPolicy // EntityFieldPolicy{Capture string} LineagePolicy string // RESERVED, not enforced}GovernedActionDraftInput mirrors the TypeScript field names in Go casing: ActionType, IdempotencyKey, ChangeID, ActivityID, MutationBinding, DigestKeys, plus AuthorizationAssertionRef and DelegationAssertionRef on the nested GovernedChangeDeclaration used by CreateGovernedChangeDraft.
Capture modes
Section titled “Capture modes”defineEntity is the only place field capture is decided. The v1 state-commitment builder implements four of the eight declared modes. The other four are reserved names in the type: selecting one is a draft-time failure, never a silently weaker commitment.
| Mode | Status | What lands in stateCommitment.fields[key] |
|---|---|---|
omit |
implemented | Nothing. The key is absent and can never become a changed path. |
full |
implemented | The normalized JSON value. |
content_digest |
implemented | { captureMode: 'content_digest', digest: 'sha256:…' } |
keyed_digest |
implemented | { algorithm: 'hmac-sha256', keyVersion, digest: 'sha256:…' } |
randomized_digest |
reserved | Fails closed at draft time. |
reference |
reserved | Fails closed at draft time. |
redact |
reserved | Fails closed at draft time. |
encrypt |
reserved | Fails closed at draft time. |
A reserved mode raises capture mode <mode> is not supported by the current governed-change draft helper. A keyed_digest field without key material raises digestKeys.keyedDigest is required for keyed_digest fields; the key version and secret are each checked for a non-empty string. Key material is injected per call at the host boundary and is never stored in the draft.
Fields are iterated in sorted key order and any field not declared in fields is invisible to the commitment. That is what stops the outbox from becoming a copy of the application row.
Two digests from one idempotency key
Section titled “Two digests from one idempotency key”createGovernedActionDraft consumes the raw idempotencyKey twice, with two different separators, and stores neither the key nor anything from which the key can be read back.
idempotencyKey + scope.tenantId │ ├── sha256("tenantId:idempotencyKey") → first 16 hex → id seed │ used in chg_<type>_<entityId>_<seed> │ and act_<type>_<entityId>_<seed> │ └── sha256("tenantId\0idempotencyKey") → full 64 hex stored as metadata.idempotencyKeyHashThe id seed is short and appears in publicly visible identifiers, so it is deliberately not the same value as the hash a store uses for idempotency conflict detection. The NUL separator in hashIdempotencyKey keeps the tenant/key boundary unambiguous — two different tenant-and-key splits cannot collide onto one hash. Both derivations are tenant-scoped, so the same application-level key in two tenants produces unrelated values.
The revision id is derived separately and is content-addressed plus change-scoped:
rev_<entityType>_<entityId>_<digest12>_<change8> │ │ │ └── first 8 hex of sha256(changeId) └── 12 hex after the "sha256:" prefix of the state digestContent addressing alone would merge a rollback with the earlier revision it restores, because both commit byte-identical state. Mixing in the change id keeps a rollback distinct while a replay of the same change stays idempotent.
Reserved context keys
Section titled “Reserved context keys”Nine keys belong to Veritio. mergeVeritioMetadata applies caller metadata first (sorted, with null/undefined values dropped), then writes the context keys over the top so a caller cannot shadow them.
authSessionId authContextId activityEpisodeIdtraceId correlationId causationEventIdchangeId capturePolicyId collectionSourcePassing any of them inside metadata instead of context is rejected before anything is built, with the message metadata.<key> is reserved by Veritio (a TypeError in TypeScript and Python, a returned error in Go). These keys are what Change, Trace, and Explain projections group on; a caller-supplied value under the same name would silently corrupt that grouping.
Draft output and outbox shape
Section titled “Draft output and outbox shape”Every builder returns the same structure: changeRef, activityRef, entityRef, revision, events, edges, and outboxEntry.
outboxEntry schemaVersion "2026-06-23" frozen literal mutationBinding "not_transaction_bound" default when unset expectedParentRevisionRef present only when a parent was resolved records the same three AuditEventInput values as .events edges the same EvidenceEdgeInput values as .edgesschemaVersion is a fixed string, not a version negotiated at runtime; a consumer that sees a different value is reading a different format. mutationBinding accepts same_transaction, not_transaction_bound, or best_effort and defaults to not_transaction_bound — the honest default, since the SDK cannot observe whether the host actually enrolled the append in its transaction. The value is copied into metadata.captureAssurance on all three events alongside captureMethod: 'transactional_outbox'.
The three events are always change.declared, activity.recorded, and entity.revision.created, and the base edges are always has_activity, has_output, performed_by, and generated. A fifth derived_from edge and outboxEntry.expectedParentRevisionRef appear only when before was supplied and the caller passed expectedParentRevisionRef. The builders never fabricate a placeholder parent: a synthetic parent would assert a derived_from edge to a revision that never existed and hand the store an optimistic-concurrency token its real head can never match.
Verified fixture
Section titled “Verified fixture”This example runs in CI and its output is byte-compared.
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))}{ "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" }}What each block proves:
changeIdandactivityIdshare the suffix312ca4bde590b545. One seed, two ids — a replayed request reproduces the same change/activity pair instead of forking the graph.revisionIdends_a71e2768812c_fe34f6d0, anda71e2768812cis exactly the first twelve hex characters of the state digest below it. The derivation is visible in the id itself.changedPathscontains/planand/seatCountonly.internalNoteschanged in the row but is declaredomit, andaccountEmaildid not change, so neither is a governed path.omittedFieldPresent: falseand the absence ofinternalNotesfromcommittedFieldsprove the omit policy is enforced in the commitment, not just in the diff.accountEmailCommitmentis acontent_digestobject. The address itself never enters evidence.eventsCarryStoreAssignedSequence: falseandeventsCarryRecordHash: falseare the important ones. A draft is input, not evidence.
Fail-closed behavior
Section titled “Fail-closed behavior”| Condition | Result |
|---|---|
scope.tenantId empty |
scope.tenantId is required |
actionType, activityType, or idempotencyKey empty |
<field> is required |
Any EvidenceRef missing a part |
ref.authority / ref.kind / ref.type / ref.id is required |
initiatedBy, performedBy, or producer is not kind: 'principal' |
principal ref is required |
identity(row) returns an empty string |
entity.id is required |
| No governed field changed | at least one governed field must change |
Reserved key in metadata |
metadata.<key> is reserved by Veritio |
| Reserved capture mode | capture mode <mode> is not supported… |
keyed_digest without key material |
digestKeys.keyedDigest is required for keyed_digest fields |
Unparseable occurredAt |
occurredAt must be a valid date |
| Non-finite number in a captured field | field values must be finite JSON numbers |
Nothing degrades into a partial draft. TypeScript and Python raise (Python raises ValueError for the occurredAt case and TypeError elsewhere); Go returns a zero-value GovernedChangeDraft and an error.
A timezone-naive occurredAt string is interpreted as UTC in all three SDKs rather than as host-local time, because occurredAt is hashed evidence and a host-local reading would make the same input hash differently on two machines.
API-shape drift between SDKs
Section titled “API-shape drift between SDKs”The derived bytes are identical across languages — that is pinned by spec/conformance/governed-action-draft.json and governed-revision-id.json. The call shapes are not identical, and porting code needs these differences.
- Python
define_entityis keyword-only and takesentity_type, nottype, sincetypeis a builtin. The value is emitted under the protocol keytype. Its other parameters are snake_case (schema_ref,field_set_ref,lineage_policy) while the draft-builder dictionaries stay camelCase. - Python and Go return plain data. Only the TypeScript
defineEntityattaches aref(rowOrId)method to the returned entity. In Python and Go the entity ref is resolved internally fromidentity(row); useref_key/RefKeyto format a ref you already hold. - Go
DefineEntityvalidatesIdentityis non-nil and returnserrors.New("identity is required")otherwise. TypeScript and Python do not check this at definition time; a missing identity function surfaces later, when the draft builder calls it. - Go takes
OccurredAtas a plain string. There is notime.Timeoverload. It parses RFC 3339 with optional nanoseconds, falls back to the timezone-naive layout, and formats to millisecond precision. TypeScript acceptsstring | Dateand Python acceptsstr | datetime. - Go’s capture mode is an untyped
string. TypeScript has aCaptureModeunion, so a typo fails at compile time there and at draft time in Go and Python. createGovernedChangeDraftrequires what the action helper derives. Calling it directly means supplyingchange.id,activity.id,changedPaths,occurredAt, andidempotencyKeyHashyourself.
Authorization and delegation refs
Section titled “Authorization and delegation refs”authorizationAssertionRef and delegationAssertionRef are accepted only by createGovernedChangeDraft, nested inside change. GovernedActionDraftInput has no field for them in any of the three SDKs. To attach either one, call the lower-level builder and derive the ids and idempotency hash yourself.
Both land in metadata on the change.declared event and are dropped when absent. They are references to assertion records; the builder does not resolve, fetch, or validate the assertion they point at.
What this does not prove
Section titled “What this does not prove”A returned draft is a set of well-formed inputs. It is not evidence, and several things it contains are claims rather than proofs.
- No record exists yet. The draft carries no store-assigned sequence and no record hash. Nothing is evidence until a conforming
AuditStoreappendsoutboxEntryand assigns both. mutationBindingis a declaration.same_transactionrecords what the host says it did. The SDK cannot observe the host’s transaction and does not verify the claim.- The state commitment covers declared fields only. A field with no policy, or one set to
omit, is outside the digest. Its later modification is invisible to any check against this revision. content_digestis not confidentiality for low-entropy values. An email address or a status enum can be recovered by guessing and hashing candidates. Usekeyed_digestwith host-held key material when that matters.changedPathsdescribes thebefore/afterpair the host passed in. It does not prove the database row actually changed, or that the host passed its real prior state.idempotencyKeyHashdoes not enforce idempotency. It only makes tenant-scoped conflict detection possible in a store that implements the uniqueness constraint.
Continue with Governed changes for the model these builders serve, the governed actions guide for wiring one into a mutation boundary, and the transactional outbox guide for making mutationBinding: 'same_transaction' true rather than merely declared. The hash chain page explains what the store adds once the outbox entry is appended.