Skip to content

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.

  • 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 as SALES-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.

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": [] }
]
}
  • trigger is Ingest, Egress, Inbound, Outbound, or none. Ingest is a commit to the governed store and Egress a read from it. Inbound and Outbound are 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 all or any.

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.

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.

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 example PD-MASK@2+p3#keep-short. A customer Rule has no +p part.
  • 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 copiedFrom names both, for example PD-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 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.

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 no retain reaches 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 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 encrypt reaches 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 $encrypted form 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_custody table, 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. With KeyVault:Provider set to AzureKeyVault it uses Azure Key Vault Keys (RSA-OAEP-256, one RSA key per Group), so a main key never leaves the vault; the identity needs the create, wrapKey and unwrapKey key permissions. Otherwise the local custodian derives each Group’s main key from KEYVAULT__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 the from Group holds, its own and any it received, to the to Group, which must exist in Keycloak; the from Group 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 a Rewrap row and a handed-over custody row to governance.key_custody, with the receiving Group in holder_group and the administrator as the actor; the Access audit gets a handover row. 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 is 400 for a blank or equal pair, 404 for an unknown receiving Group (not-found) or a handing Group that holds no key (no-keys), and 502 when 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 and groupExists. 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 encrypt with the same Group and searchable are 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 marked Identity, 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 storage entry with its Group and encryptedSince, and the Model mirror shows “Encrypted since . Values stored before this date are plain.” The impact preview names an encrypt conflict, values stored plain before an encrypt begins, values that become readable when it ends, and a field that becomes searchable.

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_index column of domain_model_records, never in an event. An ingest writes the hash of each searchable value 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 searchable Placement 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-only governance.blind_index_rebuilds table 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 EQ or IN filter on the whole path of a searchable field becomes a filter on the blind index. EQ and IN match a record with a matching hash, and NOT EQ a 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 not searchable, any other operator, a path below or above the field, an asOf read, 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 searchable fields of one Group have equal hashes. A Rule sets searchable only 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 searchable Placement 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 value 42.0 does not find the number 42, which CDC stores as 42.

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.

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.

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.

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.

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.

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.

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.

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].ssn reads the ssn field of the model the Egress read Read_1 returns.
  • 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.ssn on 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.

  • 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 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.