Governance and audit model
This page describes how CDC governs data movement and how it records what happened, at the altitude needed to assess it against a governance bar. It covers where enforcement sits, how the platform behaves when it is unsure, how audit is captured so that “authorised” and “actually committed” never silently diverge, and how the audit trail resists tampering. It re-states these as evaluative prose. The detailed engineering rationale lives in the platform’s internal decision records, which this site deliberately does not link into.
Governance sits at the boundaries, inside execution
Section titled “Governance sits at the boundaries, inside execution”CDC governs data at the two boundaries where it crosses the platform: ingest (data flowing in from a source system) and egress (data flowing out to a consumer). Both are governed boundaries, not mere reads and writes. An egress is where ownership, sovereignty, masking, projection, and policy decisions materialise into exactly what a consumer is allowed to see; an ingest is where an inbound record is shaped, validated, and committed under policy.
Enforcement is anchored by a pre-hook on every governed operation, paired with a post-hook wherever the operation writes:
- A pre-hook runs before the effect, on the engine, before an inbound record is published for commit, and before an outbound read is served. It decides access: is this principal, against this asset, in this region, permitted? A denial stops the operation; the effect never happens. This is symmetric across ingest and egress: both boundaries decide access the same way.
- A post-hook runs after a write, on the Master, once the commit to the governed store succeeds or definitively fails. It records the outcome. This is the ingest (write) path; an egress is a governed read, so it has nothing to commit and its audit is anchored by the pre-hook decision alone.
Because governance is expressed as reusable boundary components rather than per-process scripting, the same ownership, quality, approval, and access rules apply uniformly wherever data enters or leaves. This is an evaluation point when the alternative is governance logic duplicated across every integration.
A governed read can filter by a sub-query, still under one principal
Section titled “A governed read can filter by a sub-query, still under one principal”An egress read filters the records it returns. Most filters compare a field against a value, but one filter, the reference-match filter, compares a reference field against the result of a second query over the model that field points at. It answers “return the records whose reference points at any record that itself matches a condition” in one read, where the alternative is running the inner query first, reading back its keys, and feeding them into the outer query as a literal list that can go stale between the two calls and is bounded in size.
Three properties keep this inside the governance model rather than around it:
- The sub-query is a governed read under the same principal. It carries the referenced model’s own read scope, resolved with the identity that started the outer read. The filter can never surface a record the caller could not have read directly, so it widens what a query can express without widening what a principal can see. The pre-hook still decides access at the boundary exactly as it does for any other egress.
- It resolves in one round trip on the live path. The sub-query is independent of the outer record, so the platform expresses it as a single nested database statement rather than as two reads with a materialised list handed between them. There is no round-trip amplification and no cap on how many inner records can qualify.
- A historical read stays internally consistent. An as-of read evaluates the sub-query at the same moment as the outer read, so a point-in-time answer never mixes a current inner result into a past outer one.
The mechanism costs one new query primitive, which each of the two database backends renders in its own dialect and which the provider-parity suite holds to identical results on both. This keeps the “same observable behaviour on either backend” guarantee that the rest of the platform depends on.
Fail-closed: “unknown” is not “ungoverned”
Section titled “Fail-closed: “unknown” is not “ungoverned””A governance decision on the hot path reads asset and ownership facts from a per-engine cache rather than hitting the central database on every operation. The subtle risk is what happens on a cache miss. A naive design would treat “I don’t have this asset in my cache” as “this asset is ungoverned” and apply a default, which would let a freshly published, highly sensitive asset slip through governance in exactly the seconds after it was created, before the cache caught up.
CDC refuses that conflation. It distinguishes two states precisely:
- Ungoverned: the asset is known, and its status is explicitly “registered but not yet enriched with tags, ownership, or sovereignty.” This is a real, configurable state, handled by an explicit default mode.
- Unknown: the asset is not in the cache at all. This triggers a synchronous lookup against the Master, and if the Master cannot be reached, the operation is denied.
The consequence for an evaluator: CDC fails closed. When it is uncertain whether something is governed, it does not proceed. Denial reasons distinguish a transient outage (“unknown asset, master unreachable”) from a genuine governance event, so operators can tell an availability problem apart from a policy one. The cost is a one-time lookup the first time a new asset is seen on a given engine, precisely the moment stricter behaviour is desirable, not a tax on the common path.
Switching governance off is explicit and audited. An operator can switch
governance off for a whole deployment with Governance:Enabled=false. This does
not remove the check. Every Ingest, Egress and Governed Read still passes through
it, and it treats every model as Ungoverned on the permissive path, whatever the
default mode says. The operation is allowed, and the audit trail records it as
“ungoverned” with the reason “governance disabled”. Ownership, sovereignty and
Rules are not applied. Each host writes the mode to its startup log, so an
evaluator can tell a deployment that runs without governance from one that
enforces it. Setting the value back to true restores the full check.
Classifications and Rules that were placed earlier apply again.
The Classification index fails closed the same way. A data steward places a
Classification (an organisation-owned label such as Persondata) on a model or on a
path in it. The Placement belongs to the model lineage, not to one version, because a
governed read returns the records of every version. Each engine’s cache holds the placed
paths of every asset and an index of what each Classification carries. The engine loads
the index at startup and receives every change as a push. An engine that holds no index
and cannot reach the Master refuses every governed request rather than guessing that a
Classification carries nothing.
Sovereignty is enforced with defence in depth. Each engine declares its compute region, and the Master refuses to distribute an asset to an engine whose region is not in that asset’s allowed regions. A misconfigured engine therefore never learns the asset exists, so it hits the same fail-closed unknown-asset path. Region containment does not rest on a single configuration file being correct.
The governance mode is explicit, and a mixed setup fails closed. Each host runs in one of
two modes. In permissive mode, the default, a newly published model is Ungoverned: it is
allowed and every request on it is audited as “ungoverned” until a steward activates
governance. In strict mode no model receives data before it is governed. A new model is
governed from its first Draft, so a steward can place Classifications before it is published,
and the publish is refused until a Placement reaches every field. For a Value Model, every
Domain or Event Model that embeds that version must reach its fields. The refusal says only
that governance does not yet reach every field; the model designer carries no governance
control. Master refuses to start in strict mode while any model is still Ungoverned, and
names those models. Each engine reports its mode to Master when it registers, and Master sends
no governance cache to an engine whose mode differs, so that engine refuses every governed
request. A deployment therefore moves from permissive to strict only after every Ungoverned
model has been activated or retired.
Audit is two-phase on the write path: authorised and committed are distinct facts
Section titled “Audit is two-phase on the write path: authorised and committed are distinct facts”Every governed operation writes a decision record from the pre-hook (allowed or denied, with the reason), so an egress read is fully accounted for by the decision at its boundary. A governed write produces a second record: an outcome record, written from the post-hook once the commit succeeds or definitively fails (committed, or commit-failed). The two records share a correlation identity issued at decision time and carried through to the outcome, so joining them is trivial.
This two-phase shape on the write path exists to close a gap that a single-event audit cannot:
- Auditing only at decision time records “access granted” for operations that a downstream consumer subsequently dropped or failed to commit, so the audit trail and reality diverge silently, which defeats the purpose of an audit trail.
- Auditing only at commit time produces no record at all for denials, because the commit path never runs on a denied operation.
Two-phase captures both. Critically, a decision with no matching outcome is a first-class, queryable signal: it surfaces operations that were authorised but never committed (a consumer crash, a validation drop, a poison message) as an explicit condition rather than an invisible discrepancy. For a regulator or an internal auditor, “we said yes but the write never happened” is exactly the kind of fact that must not be lost.
The outcome vocabulary keeps the decision and any required transform separate: an “allowed” decision that mandates anonymisation or redaction records the transform as its own fact, so a query for “everything that was allowed” stays simple and the applied transform survives as first-class evidence rather than hiding inside an enum value.
One Field entry per field, never a value
Section titled “One Field entry per field, never a value”The decision record holds one Field entry for each field that a Rule changed or
marked for recording: what the requester received, the winning Rule named as
code@version#branch, every Rule tied with it, a mask that was raised to
anonymise because the value was not text, and every Demand the winner outranked
with the Rule that asked for it. A Rule whose branch could not be decided because a
fact never arrived, for example the requester’s location, is named with that fact.
Every other Rule that reached the operation is listed once with its state: it asked
for nothing, it is not active, or it could not be decided. The same Demands apply to
every record of one operation, so a batch of ten thousand records with six governed
fields gives six Field entries. The decision also carries a copy of the requester as
the gate saw it, and the request path it came through, such as mcp-server for a call
through an MCP server. An operation stored before the audit kept the path shows none.
The audit never holds a field value, changed or not.
A refusal records the refusing Rule, the requester copy and a Field entry for each
field that a record-refused Rule marks. When the refusing Rule could not be decided
because a fact never arrived, such as the requester’s Location, the refusal records
that fact beside the Rule.
A read can also be refused after its decision allowed it: it filters, sorts or groups on a field its requester may not see, a demand cannot be carried out on the rows, or the read’s audit was not stored. That read is stored as a refusal too, with its reason and the Rule whose demand could not be carried out. It keeps none of the decision’s Field entries, because the requester received nothing.
No ingest is stored without its evidence
Section titled “No ingest is stored without its evidence”The decision travels with the records to the Master, which writes the operation and its Field entries in the same database transaction as the append of the records. A record is therefore never stored without the decision that let it in, and a failed append leaves no decision behind. The outcome of each committed record confirms the ingest treatment position it passed through. A debug run that never publishes, and the impact preview a steward reads before a change, write no audit: the audit holds only operations that happened.
The trail is read without a developer
Section titled “The trail is read without a developer”A read API under the governance:read scope answers four questions: one operation by
its audit correlation id, the operations of one record, the operations on one field
over a time range, and the operations one Rule, or one Rule version, took part in over
a time range. It also answers the operations one process instance sent. It reads the
stored decisions, never runs them again, and places the governance changes that led
to them beside them on one timeline: Placements, Rule saves and deletes, the Rules a
Classification carries, and audit expiry runs. A range older than what the audit keeps
is reported as deleted at the end of the retention period, so an expired operation is
told apart from one that never happened. There is no lookup by requester, because the
trail is evidence of governance and not a tool to monitor a person, and no download,
because a file taken from the trail is outside the hash chain. A data steward reads
the same answers on the Trail page of the Governance area.
No egress row is released before its audit is stored
Section titled “No egress row is released before its audit is stored”A read is decided, treated and, when it may be, answered from the engine’s cache on the engine, and a cache hit never reaches the Master. So the engine publishes the read’s audit (its decision, its refusal if any, and its outcome) and waits until the message broker confirms that it stored the message. Only then does the process receive a row. If the broker does not confirm in time, refuses the message or cannot be reached, the read is refused and returns nothing. A Master consumer appends the audit to the audit tables and skips an audit it already holds, so a redelivery never writes a second entry. A read that runs on the Master itself is stored directly in the audit tables before its rows are returned.
The outcome confirms each treatment position the rows passed through and whether the engine’s cache answered the read.
Each view of a governed payload is traceable
Section titled “Each view of a governed payload is traceable”The operational views show what the platform stored, under a scope and not under a rule: the context of a process instance, the snapshot of one step, the debug views and the payload of a dead letter. Each detail read of such a payload writes a payload-view row to the audit when the payload comes from a run whose process pins a governed model, or when it is the dead letter of a governed model. The row names the viewer, the view, what was read (the instance, the step or the dead letter), the governed model and the time. It holds no value, it is not a governed operation, and it has no Field entries. A live stream of an instance writes one row when it opens.
A list or a summary writes no row: the step list, the dead-letter board and the counts show no values. An ungoverned model writes none, and neither does a deployment with governance switched off. A person who reads their own debug run writes none either, because that run already shows only what that person may see; another viewer of the same debug run is recorded. The payload-view rows take their place in the same hash chain as the decisions.
A Standing Rule leaves evidence too
Section titled “A Standing Rule leaves evidence too”A retain Rule has no request to be audited on, so it leaves its own evidence. The Retention
sweep on Master appends a Retention mark to governance.retention_marks when a record passes its
period, and a “cleared” row when the record is written again or the Rule no longer reaches it
(see governance rules). The rows are append-only and name each field’s
Rule as code@version(+pN)#branch, never a value. Besides the sweep, only the trail read API
reads them, by record; no Governed Read and no screen does, and version 1 deletes nothing.
Tamper-evidence: a record-level hash chain
Section titled “Tamper-evidence: a record-level hash chain”The audit trail is tamper-evident at the granularity of a single record. Each audit record carries a cryptographic hash computed over its own canonical content chained to the previous record’s hash. Altering, deleting, or reordering any single record breaks the chain at exactly that record.
The audit is stored in append-only tables beside the governance rules, on both database backends. The database itself refuses an update of an audit row, and the platform has no path that erases one: an erasure request leaves the audit unchanged, because the record of who did what with governed data is not data a person can ask to erase. The hash is taken over a canonical form of each row, with times in UTC and numbers in plain notation, so the same row gives the same hash on either backend. A column added to the audit after rows were stored enters the canonical form only on a row that has a value for it, so every older row keeps its hash.
This is a deliberate step beyond a coarser, file- or partition-level checksum. A partition-level chain answers “was yesterday’s file wholesale-replaced?”, but an attacker with write access to today’s partition could still modify, delete, or reorder individual rows inside it undetected. Record-level chaining answers the question audit logs actually exist to answer: “was this specific record altered?” The chain also links across storage-partition boundaries: the first record of a new partition chains to the last record of the previous one, so integrity is continuous across day and file rollovers, not just within a single file.
Verifying integrity is a sequential pass over the records. The chain is a tamper-evidence mechanism: it proves whether the trail was altered. It is not, by itself, a non-repudiation or external-notarisation mechanism; those would be additional layers an organisation could build on top of the guarantee the chain already provides.
The audit is kept for one retention period
Section titled “The audit is kept for one retention period”Each deployment keeps the audit for one Audit retention period, five years unless the operator sets another. Once a day an Audit expiry deletes the operations that are older than the period, each one as a whole with its Field entries, oldest first, so the audit that remains is one unbroken range of time. Each run writes an Expiry entry: the time range it deleted, how many operations, the period it used, when it ran, and the hash of the last row it deleted. The database refuses to delete an Expiry entry, and the expiry never deletes the rows of a content package import, publish or rollback.
The chain survives the expiry. The oldest row that is kept still chains to the hash the newest Expiry entry names, so a check starts from a known point after every expiry. A deletion by the Audit expiry therefore verifies, and a row removed by anything else, including the oldest kept row, breaks the chain. An auditor can tell expiry from tampering.
Who did it: principal captured as immutable claims
Section titled “Who did it: principal captured as immutable claims”Every audit record answers who independently of what. The “what” is the process asset that ran; the “who” is the identity that caused it to run on this particular instance. CDC captures the triggering principal’s identity claims (a small, curated set such as subject, username, email, tenant, and the authorised scopes) at the moment the instance starts, and freezes them for the life of the instance.
Several properties matter for evaluation:
- The real caller is preserved. An HTTP-triggered process records the
authenticated caller; a child process invoked by a parent inherits the parent’s
captured principal; a timer- or message-triggered process records a
systemprincipal. The process asset is always recorded alongside, so “who ran this” and “what asset did it” are never conflated. - Claims are captured, not referenced. The identity travels with the operation across hosts as durable data, so the Master can attribute the audit record without a live session, and the frozen claims are a faithful fact about the state of the world when the instance began.
- Claims are immutable and out of reach of process logic. They live on the instance as a dedicated, write-once value, not as an ordinary context variable that a task could rewrite, so a buggy or malicious plugin cannot tamper with the audit “who” mid-flow.
Automated agents never hold their own identity for data access: an agent’s tool calls run on behalf of the conversing user (or, for an agent invoked as a process step, the process instance’s captured principal), with the agent recorded as attribution-only metadata. Accountability always resolves to a human or a machine credential, never to “the platform.”
Design-time actions that compute real element behaviour are attributable without being governed. When a process designer tests a single element from the editor, the platform records one append-only row naming the definition, the element, the principal, the time, what the element was run with, and what came back. It does not produce the decision and outcome pair, because no governed data was read or written. The distinction is deliberate: attribution answers “who ran what”, and the governed pair answers “was this data operation authorised and did it commit”. A design-time test needs the first and has no claim to the second.
Lineage: what the model yields
Section titled “Lineage: what the model yields”Taken together, these mechanisms produce lineage as a property of the runtime rather than as an add-on: for any governed record, the platform can answer what happened to it, when, at whose request, under which policy decision, against which model version, and whether the operation that was authorised actually committed. Because governed data is stored as an append-only event log (creations and merges preserved, never overwritten in place), the history behind current state is intact for audit and replay.
The compliance-alignment material maps these guarantees onto specific regulatory and due-diligence obligations; the security posture covers the identity, access, and secrets controls that sit around them.