Governance Rules as data
A governance Rule in CDC is versioned data, not code. It is a JSON document over a closed catalogue of Conditions and Actions that ships with the platform. A customer’s own Rule is live when it is saved, and each save makes a new, immutable Rule version. The trail names the version that decided each request, so a past decision can always be read back as it was made.
For where the Gate sits and how decisions are audited, see the governance and audit model.
Identity and versions
Section titled “Identity and versions”- A Rule has a Rule code, such as
SALES-ONLY. It is unique in the installation and never reused, not even after the Rule is deleted. - A Rule has a display name that can change. Renaming a Rule makes no new version.
- Each save appends one Rule version, written
code@version, such asSALES-ONLY@3. A stored version is never changed. - Deleting a Rule retires it. Its versions stay, so the trail still resolves, and every Placement of the Rule ends at once.
The rule document
Section titled “The rule document”A Rule version holds a document of this shape:
{ "formatVersion": 1, "trigger": "Egress", "branches": [ { "id": "outsiders", "join": "all", "conditions": [ { "subject": "requester.group", "operator": "is-not-one-of", "operands": ["Sales"] } ], "actions": [ { "id": "refuse" } ] }, { "id": "else", "actions": [] } ]}triggerisIngest,Egress,Inbound,Outbound, or none.Ingestis a commit to the governed store andEgressa read from it.InboundandOutboundare crossings at an Exchange element: data that enters the process context, and data that leaves CDC. A Rule with no trigger holds only Standing Actions, which never sit under a Condition.- Branches are ordered. Each branch has an id that stays across the Rule’s versions, so a
reorder does not change what the trail means. A branch id that an earlier version dropped is
never used again. The last branch is always
else, and it has no Condition. - A branch joins its Conditions with
allorany.
Building a Rule without code
Section titled “Building a Rule without code”A data steward builds a Rule in a form on the Rules page of the Governance area: the trigger,
the ordered branches with their Conditions, the Otherwise branch, and the Actions. The form
offers only what the catalogue holds for the chosen trigger, and it reads the Rule back as one
sentence while it is built. The Rules page lists every Rule as that sentence, grouped by
package, with its code@version and the number of fields its Placements reach.
A Combine Rule package
Section titled “A Combine Rule package”Combine delivers its Rules as a signed Content package
(ADR-0084).
An operator imports it under content:import
(ADR-0087).
Master checks the ES256 signature at import, against the Root keys the release embeds, and
never at run time. The import stores the package version as Delivered, with the pinned
code@version list and the complete signed file, and it changes nothing that runs: a Combine
Rule takes part only once its package version is published. An import reuses a stored Rule
version with the same content and refuses a changed code@version. Every attempt, accepted or
refused, writes an audit row.
A data steward publishes a Delivered version under governance:write, behind an impact
preview, with an optional reason. A rollback publishes a Superseded version again, needs the
same right and always needs a reason. Each publish and rollback appends one publication row,
which is its audit row: the publisher, the package, the versions before and after, publish or
rollback, the reason and the time. The newest publication of a package names its Published
version; every version an earlier publication named is Superseded. The active rule set holds
the Rule versions each Published version pins, and a publish or rollback pushes it to every
engine at once. The preview settles every governed model before and after, and lists each Rule
the new version drops with the Placements that lose it. A version signed by a key that the
newest Key list marks Compromised can be neither published nor rolled back to, and nothing that
runs changes. The Group reference index counts the Rule versions of every Published and
Superseded version, because a rollback can bring a Superseded one back.
Parameters of a Combine Rule
Section titled “Parameters of a Combine Rule”A Combine Rule version declares its parameters in the package, each with Combine’s default
and, for a number, its limits (ADR-0096). Its definition names a parameter where a value
belongs with { "$parameter": "<name>" }, and the engine runs the definition with the values
written in. The customer tunes a Combine Rule without copying it:
- Each Combine Rule starts at Rule parameters version 1, written
code@version+p1, with Combine’s defaults when its package version is first published in the deployment. The counter is per Rule code in the deployment, not Combine’s number. - A parameter save, under
governance:write, is live when saved. It writes the next Rule parameters version and pushes the active rule set. Each value records whether the customer set it; a value the customer resets follows Combine’s default again. - A new package version or a rollback runs the carry-over check before it binds. A value the customer set carries over; a value the customer never set follows the new default; a parameter the Rule version no longer has is dropped, and a new one takes the default. A customer value that no longer fits blocks the step until the steward sets a value that fits, which the step then records as set by the customer. Any change writes a new Rule parameters version in the same transaction as the publication.
- The trail names a Combine Rule as
code@version+pN#branch, for examplePD-MASK@2+p3#keep-short. A customer Rule has no+ppart. - A copy of a Combine Rule in force becomes a customer Rule. Its first version is the Combine
Rule’s definition with the parameters in force written in, and
copiedFromnames both, for examplePD-MASK@2+p3. The Rules page shows how far the source has moved since.
The form never decides whether a Rule is valid. Master reads every save against the catalogue of the running release and names each problem, and the form shows each one against the branch or field it names.
The catalogue
Section titled “The catalogue”The Condition catalogue reads the request, never the record:
| Subject | Operators | Reads |
|---|---|---|
requester.group |
is-one-of, is-not-one-of |
the Requester’s Groups, by top-level name |
requester.where |
is-one-of, is-not-one-of |
the Requester’s Location, a country code or Country group |
request.activation |
is |
person, api-client or declared |
request.path |
is-one-of, is-not-one-of |
sdk-read, agent-tool, process-egress, process-ingest, data-browser, operational-view, http-start, http-end, plugin-task, message, agent-task, mcp-server, tool-end, conditional-start |
source.system |
is-one-of, is-not-one-of, owns-attribute, does-not-own-attribute |
the source system of the data (Ingest and Inbound only) |
destination.system |
is-one-of, is-not-one-of |
the key of the system the data goes to (Outbound and Egress only) |
destination.where |
is-one-of, is-not-one-of |
that system’s Location, a country code or Country group (Outbound and Egress only) |
An element that declares no destination leaves both destination subjects unknown, so a branch that reads them stays undecided and its Actions compete as if it had fired.
At Egress only the agent-tool path has a destination. The rows an agent’s domain-query tool
reads go to the agent’s LLM provider in the next model call, so the Gate reads the provider
system the agent names as destination.system, and that system’s Location as
destination.where. This holds in agent chat and in an Agent Task. An agent that names no
provider system leaves both subjects unknown. Every other Egress read returns its rows to the
Requester and sends them to no system, so a destination Condition answers “no” there, whatever
its operator, and the branch does not apply. A refused read reaches the agent as a denied tool
result with the reason, and the audit records the denial as it does for any Egress read.
A read by a tool of an MCP server comes through the mcp-server path, not agent-tool, so a
Rule on “an agent tool” does not match it. An organisation that wants a Rule for both names both
paths. The mcp-server path has a destination too: its rows go on to an LLM that the external
AI client chose, which CDC cannot check. Both destination subjects are therefore unknown, and
the strictest branch still in play runs. To refuse MCP calls from API Clients only, combine
request.path is-one-of mcp-server with request.activation is api-client.
An Action enters the catalogue in the same release as the Treatment that carries it out. In this release the catalogue holds seven Actions:
| Action | Parameters | Boundaries | What the Requester receives |
|---|---|---|---|
refuse |
none | Ingest, Egress, Inbound, Outbound | nothing: the whole request stops |
mask |
keep, a whole number from 1 to 8 |
Egress, Inbound, Outbound | at least three *, then the last keep characters |
anonymise |
none | Egress, Inbound, Outbound | ********, eight characters whatever the length |
remove |
none | Egress, Inbound, Outbound | [removed] |
record |
none | Ingest, Egress, Inbound, Outbound | the value as it is; the field gets a Field entry in the audit |
record-refused |
none | Ingest, Egress, Inbound, Outbound | the value as it is; the field gets a Field entry, also when the request is refused |
No Treatment carries out a visibility Action at an Exchange element yet, so the Gate refuses a
crossing where mask, anonymise or remove wins at Inbound or Outbound, as it refuses an
Egress read whose Demand it cannot carry out.
| retain | months, a whole number from 1 to 1200 | none (Standing) | the value as it is; the Retention sweep marks the record |
| encrypt | group, the Key custodian group; searchable, false when left out | none: a Standing Action | the value to a member of the Group; remove to every other Requester |
A value that is not text, for example a number, a date or an embedded object, becomes null
under mask, anonymise and remove. A text value too short to keep keep characters behind
three * keeps fewer, so a short value is never shown whole. Every output is ASCII, so an
older integration can read it. A save refuses mask with no keep or a keep outside 1 to 8,
a parameter on anonymise, remove, record or record-refused, a visibility Action on a
Rule whose trigger is Ingest, retain with no months or a months outside 1 to 1200, and
retain on a Rule that has a trigger or on a branch with a Condition. Any other id, for example deliver-encrypted, is unknown until its Treatment is released.
record and record-refused never compete and change no value. Every governed operation is
audited whatever the Rules say; the two Actions only mark a field for a Field entry, so the audit
names it even when it goes out or is stored unchanged.
Retention: retain and the Retention sweep
Section titled “Retention: retain and the Retention sweep”retain(months) is the first Standing Action (ADR-0079). Its Rule names no trigger, and its only
branch is else:
{ "formatVersion": 1, "branches": [ { "id": "else", "actions": [ { "id": "retain", "params": { "months": 24 } } ] } ] }It reaches fields through Placements like every Rule, and the settling function puts it in the
field’s Effective rule set only when it settles without a request: a Standing Rule takes no part
at Ingest or Egress. On one field the shorter period wins, and two retain with the same months
are one Demand that names both Rules. A record is inactive as a whole, so its period is the
shortest period on any of its fields, and it starts at the record’s last event. A read does not
reset it.
The Retention sweep is one background service on Master. It runs shortly after Master starts
and then once a day; Governance:RetentionSweep:Interval (a time span, default 1.00:00:00)
changes the interval, and a zero or negative interval stops Master at startup. Each pass settles
today’s Effective rule set of every lineage without a request, visits only the lineages a
retain reaches, and reads their live records in aggregate order. It appends one row to
governance.retention_marks per change:
- a mark when a record’s last event is older than its period: the lineage, the record, its
last event (version and time), each retained field with its period and its Rules as
code@version(+pN)#branch, the shortest period, and the time of the mark; - a cleared row when a marked record has a newer event (
newer-event), when the period that applies today is longer and has not passed (period-not-passed), or when noretainreaches its lineage any more (no-retain-demand).
A mark that still holds gets no new row, unless today’s Rules give the record another period or
other fields: then a new mark names them. A mark on a record the pass did not visit is cleared
with no-retain-demand. The current marks are the marks with no later row for the
same record. The table is append-only: a trigger refuses every UPDATE and DELETE on both providers.
Version 1 deletes and anonymises nothing. A mark changes no read: a Governed Read, the Data Browser
and every management endpoint return the record as before, and no screen shows a mark. Only the
sweep and the trail read API (by record, through IRetentionMarkRepository.ListForRecordAsync) read the table.
encrypt and the Key custodian group
Section titled “encrypt and the Key custodian group”encrypt is a Standing Action: its Rule names no trigger and holds no Condition, so what it
does is known before a request. A save refuses encrypt under a trigger, a group that is
blank or names a subgroup path, any other parameter, and a searchable that is not true or
false.
- Storage. Master encrypts the raw value of each field an
encryptreaches at the Ingest Treatment position, between validation and the append, from the time the Placement begins. The encrypted value and the ingest audit are written in one transaction. The stored form says that the value is encrypted, and under which Group and key generation:{"$encrypted":{"group":"Finance","generation":1,"ciphertext":"…"}}. A value stored before the Placement began stays plain, and an ingest payload that writes the$encryptedform itself is refused. The ciphertext is AES-256-GCM over the value’s JSON text. - Keys. Each Key custodian group holds one data key per key generation. The key custody
record, the append-only
governance.key_custodytable, holds each data key only wrapped under the Group’s main key, and a custody row for each change of custody. The first value stored for a Group creates its first key. The key custody seam wraps and unwraps a data key; it is separate from the secret provider. WithKeyVault:Providerset toAzureKeyVaultit uses Azure Key Vault Keys (RSA-OAEP-256, one RSA key per Group), so a main key never leaves the vault; the identity needs thecreate,wrapKeyandunwrapKeykey permissions. Otherwise the local custodian derives each Group’s main key fromKEYVAULT__MASTERKEY. The local custodian is not for production: whoever holds that variable holds every Group’s keys, and Master logs a warning when it starts one. Only Master holds the seam. - Handover. Keys belong to a Group, never to a person, and a Handover is the only way
they leave it. Under
identity:write,POST /api-internal/identity/key-handovers{ "from", "to" }moves every key thefromGroup holds, its own and any it received, to thetoGroup, which must exist in Keycloak; thefromGroup need not, so the keys of a Group deleted in Keycloak outside CDC can be handed over. Master unwraps each data key under the handing Group’s main key, wraps it again under the receiving Group’s, and appends aRewraprow and ahanded-overcustody row togovernance.key_custody, with the receiving Group inholder_groupand the administrator as the actor; the Access audit gets ahandoverrow. No stored value changes: a value still names its key by the Group it was created for, and Master reads it through the key’s latest wrap. The answer is400for a blank or equal pair,404for an unknown receiving Group (not-found) or a handing Group that holds no key (no-keys), and502when the custodian cannot wrap a key again (key-custodian-unavailable); keys moved before such a failure stay moved.GET /api-internal/identity/key-custody(identity:read) lists every holder with its keys andgroupExists. A Handover changes no Rule: the key custody check reads the Group the Rule names, so change the Rule to name the receiving Group. A Rule that still names the handing Group keeps it a Key custodian group, and its next value takes a new key generation that the handing Group holds. - The Group guard. A Group that holds a key joins the Group reference index: the Access area
refuses to rename or delete it (
409 group-in-use), and names each key, such as “Key custody: data key of ‘Finance’, generation 1 (hand it over first)”. - Reads. Master decrypts every stored value before the Region transform, whatever the record’s history, so values stored plain and encrypted read as one. No ciphertext leaves Master on a Governed Read, and a value Master cannot decrypt refuses the read. An aggregate whose groups meet an encrypted value is refused. At the Principal position a Requester who is not a member of the Group, or whose Groups never reached the decision, gets the field removed, and the Field entry says “not a member of the Key custodian group”. The result never depends on the date of a value.
- One setup per field. Two
encryptwith the same Group andsearchableare the same setup. Any other pair on one field is a conflict: a Placement, a Classification taking a Rule or a Rule version that would make one is refused (409), and so is a change that encrypts a field that identifies the record (422): a property markedIdentity, or a key field of the model’s identity strategy. An ingest that still meets a conflict is refused. - What a steward sees. The effective policy view gives each encrypted field a
storageentry with its Group andencryptedSince, and the Model mirror shows “Encrypted since. Values stored before this date are plain.” The impact preview names an encryptconflict, values stored plain before anencryptbegins, values that become readable when it ends, and a field that becomessearchable.
searchable and the blind index
Section titled “searchable and the blind index”A filter runs on the stored value, so on an encrypted field it would compare ciphertext. A field
whose encrypt sets searchable: true therefore gets a blind index: a keyed hash
(HMAC-SHA256) of each value under the index key of the Key custodian group. The index key is
derived from the data key of the Group’s newest key generation. The match is exact: CDC does not
normalise a value, so Karen and karen are different. A text value is hashed as its text, and
a number or a boolean as its JSON text.
- Only in the record projection. The index lives in the
blind_indexcolumn ofdomain_model_records, never in an event. An ingest writes the hash of eachsearchablevalue in the same transaction as the record. An append that writes the field while no index is kept for it drops the field’s entry and marks the index stale in that transaction. - Rebuild. Master rebuilds the index of a field from every stored record, values stored plain
before the Placement began included, when a
searchablePlacement begins, after a key change and after the index went stale. A rebuild that began before the field’s current setup began, for example before its Placement ended and began again, no longer counts. A background pass on Master runs every minute. After a key change it waits ten minutes, the longest a Master keeps a key in memory, so no Master still writes under the old key when the rebuild starts. The append-onlygovernance.blind_index_rebuildstable records when each rebuild started and completed, under which Group and key generation, and when an index went stale. - Filters. On a Governed Read, Master checks every selection against the fields it stores
encrypted, from its own governance cache. An
EQ,NOT EQorINfilter on the whole path of asearchablefield becomes a filter on the blind index.EQandINmatch a record with a matching hash, andNOT EQa record with none, a record without the field included. Every other selection on an encrypted field is refused and names the field: a filter on a field that is notsearchable, any other operator, a path below or above the field, anasOfread, and an aggregate. A filter is also refused until a rebuild under the Group’s newest key generation has completed. A Requester who is not a member of the Group is refused at the Pre-hook, because the field is removed for them. - What a database reader sees. A blind index shows which records share a value, and for a
field with few values counting shows the values. The index key belongs to the Group, so equal
values in two
searchablefields of one Group have equal hashes. A Rule setssearchableonly on a field that needs a filter, and the impact preview says so. - Known limits in version 1. A Master whose governance cache has not yet received a new
searchablePlacement stores a record without an index entry; a filter can miss that record until the next rebuild. The match is on the stored text, so the filter value42.0does not find the number 42, which CDC stores as42.
A key change
Section titled “A key change”An Access administrator starts a key change of a Key custodian group by hand, in the Access
area, under identity:write. Version 1 has no schedule. The key change starts a new key
generation: the custodian starts a new main key (a new version of the Group’s Key Vault key, or
a new derivation under the local custodian), a new random data key is wrapped under it, and every
older data key is wrapped again under it and appended to the key custody record. The old data
keys stay usable for reads and are never deleted, and no event changes. New values take the new
data key, the custody record names the administrator, and the blind index of every searchable
field of the Group is rebuilt under the new index key. Until then a filter on such a field is
refused.
How a Rule is evaluated
Section titled “How a Rule is evaluated”Each Condition answers yes, no or unknown. “Unknown” means a fact never reached the decision,
for example the Groups of an unattended run. The Rule walks its branches in order: a “no” drops
out, an “unknown” stays in play and the walk goes on, and the first “yes” fires and ends the walk.
Every branch in play competes as if it had fired, so a missing fact never makes a Rule ask for
less. Between Rules the strictest outcome wins, and one refuse wins over everything. On one
field remove beats anonymise, anonymise beats every mask, and between two mask Demands
the smaller keep wins. Two Demands of equal strength give the same value, so a tie only names
both Rules. A masking Rule never becomes a refusal because a fact is missing.
The missing fact stays with the Action it put in play. Each Action carries the facts of its own
branch and of every undecided branch above it, because it runs only when those branches might not
have held. A Rule whose first branch waits for requester.where and whose else refuses is
therefore refused as code@version#else with requester.where missing. The 403 response names
it, and so does the refusal of the stored trail operation. An operation stored before the trail
kept these facts names the refusing Rules without them.
A Condition on requester.where is decided on the Requester’s Location, also when an operand
names a Country group such as EEA; only a Requester with no Location answers “unknown”. A run
that acts for a Declared group answers request.activation is declared and takes the Engine’s
Location. A run that acts for no one answers “no” for every request.activation is … value. A
Condition on source.system is decided on the source system the Ingest element declares,
compared without regard to case, and a dead-letter replay is decided on the same one. At an HTTP
Start it is the calling API Client’s system key (the system_key claim), else the source system
the HTTP Start declares. An element that declares none answers “unknown”, so the strictest Action
still in play runs; CDC never derives a source system from identity mapping.
owns-attribute and does-not-own-attribute are decided per field, on that field’s Owning
systems and the declared source system. A field’s Owning systems are those placed on its path or
on any path above it, on its model or on a base model. A field with no Owning system answers
“unknown”, so the strictest Action in play decides.
Where a Rule applies
Section titled “Where a Rule applies”A Rule applies to a field only through a Placement: placed on a path of a Domain or Event Model directly, or carried by a Classification that is placed there. Adding a Rule to a Classification is a Placement change, and like every governance change it is live when saved. Every governance change records who made it: a person by their email, and an API Client by its client name, the same name the trail gives that client as a Requester.
At Ingest, a Rule applies only to the Written fields: the fields the payload carries, also
with null, and every field below them. Payload keys match the model’s fields without regard to
case, and system properties such as $identity are never fields. A bulk ingest writes the union
of the fields its records carry, because the Gate decides once for the whole ingest. An ingest
whose payload writes no model field settles no field Rule, and its trail names only the Written
fields. A dead-letter replay is judged on the fields the replayed payload carries, also after an
edit. Egress judges every field of the model.
The Effective rule set
Section titled “The Effective rule set”One settling function decides what governs a field, and three callers share it: the Gate on every request, the effective policy view a steward reads, and the impact preview shown before a Placement change. Because the view, the preview and the run use the same function, they can differ only while an engine has not yet received a change.
The function collects every Rule that reaches the field. When one Rule reaches it by several Placements, the duplicates collapse to the most specific route: a longer path beats a shorter one, a Rule placed directly beats a Classification on the same path, and the model’s own Placement beats one inherited from a base model. The route never changes the outcome, only what the view shows. A placed Rule with no active version stays visible as “not active” and decides nothing, so a loss of protection is never silent.
Without a request, the function settles only what does not depend on one. A Rule that reads
the Requester is listed with its Conditions as “applies when”. With a request, a Rule whose
fact never arrived is marked “cannot be decided” and names the fact. A mask that meets a
value that is not text is raised to anonymise, never weakened.
The impact preview settles the model and every model that extends it before and after the proposed change, and counts the fields whose list of Rules or outcome changes. It writes nothing: no Placement, no cache push and no audit. Removing a Combine Rule from a path or a Classification requires the steward’s reason.
Where a Demand is carried out
Section titled “Where a Demand is carried out”The Gate never sees a value. It hands the winning Demand of each field to Treatment, which is
the only part of governance that does. A Rule that reads anything about the Requester gives a
Principal Demand, and the engine’s Principal transform carries it out on every read after
the EgressCache. A Rule that reads nothing about the Requester, which in this release is a
Rule with no Condition, gives a Region Demand: it is the same for every read on one engine. The
query message from the engine to Master carries the winning Region Demands as JSON text under
RegionDemands, and Master’s Region transform carries them out before the rows go on the
bus, so an engine never holds a value its region may not have. A Master host that answers a
read in its own process runs the same transform.
The cache therefore holds the rows as Master sent them, already Region-treated. Its key is the query, the engine and the Region Demands, never who reads. The engine keeps a Principal Demand only when it is stricter than the Region winner on the same field. Every visibility Action is absorbing, so the Principal transform on Region-treated rows gives the value that the stricter Action gives on the raw one.
Either transform refuses the whole read, and the Post-hook records the reason, when it cannot
carry out a Demand: a field missing from a row, or a path through a value that is not an object.
Master also refuses an aggregate read under a Region Demand, whose group keys and totals it
computes from raw values, and a query whose Region Demands it cannot read, for example an Action
it does not know. A refusal from Master comes back as a reply with no row and a Refusal text,
and the engine never caches it. A reply to a query with Region Demands also confirms them with
RegionTreated: true; the engine refuses a reply without it, because a Master of an earlier
release ignores the Region Demands and sends the values unchanged. A field is never returned
unchanged when a Demand said to change it.
No read selects on a hidden field
Section titled “No read selects on a hidden field”A filter, a sort, a group-by and an aggregate run on the raw value, before any Treatment. A
startsWith filter on a masked field would otherwise find the value letter by letter. So on every
Governed Read (a BPMN egress step, an aggregate read, the OData plugin and the agent tool) a
selection on a field with a visibility Demand (mask, anonymise or remove) is a Refusal. A
field is hidden when the Demand sits on it, on an object above it or on a field below it, at
any array position (lines[*].sku meets a Demand on lines.sku). A group-by entry that names no
field CDC can read is refused under any such Demand. The
refusal happens after the Pre-hook and before the EgressCache or Master is asked, and its reason
names the Rule and the field:
selection: PD-SALES@1#outsiders demands 'mask' on 'Phone', so a filter on 'Phone' is refused
A selection on a visible field runs as before, so an aggregate read whose filters, aggregates and
group keys are all visible returns its groups. The SDK read answers Denied with this reason in
EgressQueryServiceResult.Reason, and the agent tool passes it to the model. A matches filter is
checked on its own reference field; its sub-query filters address another model and are not
checked in this release.
Exchange elements
Section titled “Exchange elements”An Exchange element is an element where data crosses the CDC edge outside the governed store.
Each crossing runs the Pre-hook before the data crosses and the Post-hook after it, on the host
that runs the process. The Pre-hook judges the Inbound or Outbound Model the element binds, at
the Inbound or Outbound boundary, with request.path set to the element kind. It stores an
ExchangeAudit (the AccessDecided with its Field entries, and for a refusal the
AccessDenied and the refused outcome) with broker confirmation before it answers, so an allowed
crossing whose audit is not stored does not cross. Master appends it to the audit tables as an
operation named inbound or outbound. The Post-hook runs once the element has moved the data,
or failed to, and publishes ExchangeCompleted with the real outcome: an allowed crossing whose
data then did not cross, such as a plugin call that failed, is Succeeded = false.
An element with no model follows the Governance mode. In permissive mode it crosses and the
decision is audited as ungoverned. In strict mode publish refuses it and names the element, and
the Pre-hook refuses it too, for a version published while the deployment was permissive.
A refusal never retries. The step log reads Failed (Refused), and the process monitor shows it
as a governance refusal. An Agent Task also logs Failed (Refused) on a refusal, but its run
goes on along its one outgoing flow, with the refusal in its result.
| Element | request.path |
Refusal |
|---|---|---|
| HTTP Start | http-start |
Inbound: the caller gets 403, and no instance starts |
| HTTP End | http-end |
Outbound: the caller gets 403 with no body, and the instance fails |
| Plugin Task | plugin-task |
Outbound: the plugin is not called, and the step fails. Inbound: the output never enters the context, and the step fails |
| Message Start | message |
Inbound: no instance starts, and the message goes to the dead-letter queue |
| Intermediate Catch | message |
Inbound: the step fails, and the message goes to the dead-letter queue |
| Message End, Intermediate Throw, Send Task | message |
Outbound: nothing is published, and the instance fails |
| Agent Task | agent-task |
Outbound: the prompt is not sent. Inbound: the answer never enters the context. Either way the task stores success: false with status refused and continues on its outgoing flow, so a gateway after it routes the refusal |
| Tool Process End | tool-end |
Outbound: no tool output is stored, and the instance fails |
An HTTP Start decides its crossing before the instance exists. The audit names the id the instance would have had, and a refused request leaves no instance behind. An HTTP Start that binds a value model binds no Inbound Model, so it follows the Governance mode.
A plugin Task binds no model of its own. Each direction is typed by the contract its plugin
generated: the input by the Outbound contract, the output by the Inbound contract. A contract
starts Ungoverned, so in strict mode the Task is refused, naming the contract, until a steward
activates it; in permissive mode it runs and is audited as ungoverned. A method whose contract
could not be generated crosses with no model. The Connection’s system is the destination of the
input (destination.system, destination.where from the system’s Location) and the source of
the output. The input also answers to the Outbound Rules of the source fields its input mapping
and its option mapping read, since both reach the plugin. The output is a governed key of the
Inbound contract, so a mapping that reads it passes its fields on; the output of a method whose
contract was not generated is ungoverned and brings no Rules.
A message element binds its Event Model: as its Inbound Model at a Message Start or an
Intermediate Catch, and as its Outbound Model at a Message End, an Intermediate Throw or a Send
Task. The five share the request.path value message. The boundary tells a received message
from a sent one, and the audit names the element, so one value is enough. A Message Start
decides its crossing before the instance exists, as an HTTP Start does. A refused received
message is dead-lettered with the category GovernanceRefused and the Event Model as its model;
it cannot be replayed, since the same Rule would refuse it again, so an operator discards it.
An Agent Task binds an Outbound Model for the prompt and the input data, and an Inbound Model for the answer. The provider system the agent names is the destination of the prompt and the source of the answer. The prompt answers to the Outbound Rules of the source fields its prompt and input data mappings read, and the answer is a governed key of the Inbound Model, so a mapping that reads it passes its fields on. In strict mode publish refuses an Agent Task that binds no model in either role.
The Outbound Model limits the input data to the fields it defines. It cannot shape the prompt, which is one string. So when an Agent Task binds an Outbound Model, its prompt must be plain text that reads no value from the process, and every process value goes in the input data. Publish refuses a task that breaks this rule, in every Governance mode.
The top-level End of a Tool Process binds an Exchange Model as its Outbound Model, and its Input Mapping produces the tool output. The output is an Outbound crossing with the rules of an HTTP End: the Rules on the Outbound Model and the Outbound Rules of each source field the mapping reads apply. The engine stores an allowed output in the instance context under the End’s element id. The destination comes from the channel whose tool started the run:
| Channel | destination.system |
destination.where |
|---|---|---|
Agent process tool |
the provider system the agent version names | that system’s Location |
MCP server process tool |
unknown | unknown |
| Manual start | unknown | unknown |
The Agent’s tool stamps the agent version among the run’s captured claims
(agent.revision_id), and the End reads the provider of that version. An agent that names no
provider leaves the destination unknown. An unknown destination makes a destination Condition
answer unknown, so the strictest branch still in play runs. A Rule written for http-end does not match this exit; name both to
govern both. In strict mode publish refuses a Tool Process End that binds no model, even when the
End carries no config.
Outbound Rules follow the source fields
Section titled “Outbound Rules follow the source fields”At an Outbound crossing the Gate also applies the Outbound Rules of each governed source field
the payload was mapped from, so one Rule on Customer.ssn acts at every exit. The two rule sets
settle together: one Refusal from either refuses the crossing, and a Demand from either fails
closed. A crossing whose element binds no model, or binds an Ungoverned one, still answers to
the Rules of its source fields before it crosses.
Publish derives the source fields of each mapping from its $context references and stores the
derivation with the process version; the runtime never derives it again.
- A literal path into a governed key names one source field.
$context.Read_1.items[0].ssnreads thessnfield of the model the Egress readRead_1returns. - A path publish cannot read names the whole source Data Asset, for example a path that ends at
items, filters the records, or binds them to a variable. - A mapping publish cannot read at all counts as a read of every governed key of the process:
$context,$context.*,$context[<expr>], or a mapping that does not parse. - A key that a mapping derives from a governed key carries the source fields of that mapping, so
a JSONata task that copies the ssn to a new key passes
Customer.ssnon to every reader of that key.
A governed key is a key that an element bound to a governed model writes: the Egress read, the
received message of a Message Start or an Intermediate Catch, the answer of an Agent Task, the
output of a plugin Task, and the request of an HTTP Start. In the request envelope of an HTTP
Start the record is the body, so $context.Start_1.body.ssn reads one field; any other path,
and every path into an HTTP Start whose input mapping shaped the key, reads the whole Data
Asset. A key that an Intermediate
Catch’s input mapping projects the message into holds the whole Event Model, and whatever else
the mapping reads. A Rule on a field the payload does not read takes no part, and a source model that
the governance cache does not know refuses the crossing.
A process version whose derivation cannot be read fails closed. When its diagram cannot be read, each of its mappings counts as a read of every governed key. When its element configs cannot be read, its governed keys cannot be known, so the Pre-hook refuses each Outbound crossing whose payload a mapping computed.
Fail-closed behaviour
Section titled “Fail-closed behaviour”- A save refuses a document with an unknown id, a bad parameter or operand, an Action outside the trigger’s boundary, a Condition on a Standing Rule, or a subgroup operand, and names each problem.
- Every engine holds the active rule set in its governance cache, keyed by
code@version, and receives each change as it is saved. An engine that has not loaded the rule set refuses every governed request. - A stored Rule version that the running engine cannot read, for example after a downgrade, makes the Gate refuse each request it applies to, and the reason names the unknown id.
A Rule keeps its Groups
Section titled “A Rule keeps its Groups”A Rule names a Group by its Keycloak name, so renaming or deleting that Group would leave the
Rule reading a Group that no Requester has. The Access area therefore asks the Group reference
index before it renames or deletes a Group, and refuses with 409 group-in-use while any use
exists. The index reads the newest version of every Rule that is not deleted: each
requester.group operand and each Action’s group parameter. It reads the document without
the catalogue, so a stored version the running engine can no longer read still keeps its Groups.
A Draft or Published start node’s Declared group is the other use the index holds today: a
Draft counts because it can still be published with that Group, while a Superseded or Archived
version does not count. The refusal names each start node with its process and version. A
rollback-reachable Rule package version and key custody join it as their own sources later.