Skip to Content
PoliciesTrusted Action Fields

Trusted Action Fields

A policy is only as trustworthy as the facts it reads. Keel exposes two kinds of policy field, and the difference between them matters more than any individual field name:

  • keel_derived — Keel established this fact itself, from the governed action.
  • caller_asserted — the caller supplied this fact on the request.

The agent your policy is meant to constrain is normally the caller. This page documents the context._keel.action_envelope.* fields Keel supports as an authoring contract, which of them are Stable, and — for the three that are not — exactly where the value comes from. It also states what Keel does and does not do to the record of those facts once a decision is made.

Changed: financial.amount_usd_micros is no longer Stable. It was published here as a keel_derived Stable path. On Keel’s canonical payment rail Keel does not establish it — it multiplies two caller-supplied values, and the fact it emits there is stamped request_bound, not keel_derived. The authoring catalog now labels the field caller_asserted. Both .value and .state move to Preview.

If you have a spend cap keyed on this field, read amount_usd_micros before your next policy review. The threshold still evaluates and still fires; on the canonical rail it constrains a number whose inputs the caller chose.

That leaves connector.identity.value and connector.tool_name.value as the entire Stable set. financial.currency.value and financial.operation.value were demoted earlier for the same reason. Also corrected on this page: the availability table (no field on this page is available on realtime), the recommended absence guard, which did not fire on the canonical payment rail, and how these facts are recorded, which now states plainly that the permit signature does not cover them. See what is in the contract and known limitations.

For condition syntax and the operator catalog, see Policy Conditions. For rule actions and evaluation order, see Policy Reference.

Provenance: who established the fact

Every authorable field carries a provenance label in the policy-authoring catalog. Four values exist:

ProvenanceWho established the valueSound to gate enforcement on, against the caller?
keel_derivedKeel, from the governed action and its own registriesYes
connector_assertedThe downstream connector, relayed through KeelOnly with the connector inside your trust boundary
caller_assertedThe caller, on the requestNo
customFree-form values the caller places under context.*No

“Sound to gate enforcement on” is a claim about where the value came from, not about what Keel does to the record afterwards. A keel_derived fact is one the caller could not choose; it is not a signed attestation, and this page’s recording section says exactly what protects it once the decision is made.

Provenance is coarser than the fact’s own trust label

Provenance is a property of the field, fixed across every request. Each fact in the envelope also carries its own per-request trust label, and that label has a different and finer vocabulary:

trustWhat it means for that one fact
keel_verifiedRead back from a Keel-verified record, such as the MCP decision trace or server registry.
keel_derivedKeel computed it from the governed action.
request_boundThe value came from the request. Keel bound it to the decision and will not accept a different value at dispatch, but Keel did not independently establish it.
caller_declaredThe caller stated it and Keel neither verified nor bound it.
unavailableThe fact is not present; there is no value.

The two axes do not always agree, and a field carries one provenance for every surface it appears on. financial.amount_usd_micros emits request_bound on the canonical payment rail and keel_derived on an MCP payment connector; financial.currency does the same. The catalog resolves that split by declaring the weaker reading — caller_asserted — so an author who assumes the caller supplied the value is never wrong, and one who assumes Keel established it is wrong only on the surface where money moves. The field reference below spells out which path gives which for amount_usd_micros, currency, and operation.

Where the two disagree, the fact’s own trust label is the one that describes what happened on that request. Read it in the decision snapshot before you weight a value as evidence against the caller — and read how these facts are recorded for what that snapshot is and is not.

trust is reported on the fact, but it is not an authorable policy path — you cannot branch on it in a rule. It is there for you to inspect when deciding, at authoring time, how much weight a field deserves.

Do not rely on a caller_asserted field alone when the caller is inside your threat model. An agent that can choose the value of a field can choose the branch of your policy that field selects. attrs.* paths, action.name, and everything under context.* other than context._keel.* are caller-asserted: useful for describing intent, logging, and advisory rules, but not for proving what an action will do.

Keel enforces part of this for you. The policy authoring API attaches a caller_asserted_field warning to any condition that reads a caller-asserted field, and it rejects outright — with identity_field_not_enforceable — any hard allow or deny rule keyed on a caller-asserted identity claim. The warning is advisory; the rule below is not automated, and is yours to apply.

Two properties make context._keel.* different from the rest of the request:

  • Keel authors the entire _keel namespace during evaluation. A caller-supplied _keel object on the request is discarded before any policy runs, so a caller cannot inject or overwrite a trusted fact.
  • Every fact in the action envelope is a fixed-shape object rather than a bare value, so an unavailable fact is explicitly unavailable rather than silently missing. That is what makes the absence rule below possible.

What is in the contract

Two tiers exist for these fields. There is no third.

TierMeaning
StableKeel supports this path, type, and meaning as an authoring contract. Breaking changes follow the deprecation policy in Surface Maturity.
PreviewVisible, possibly authorable, and subject to change without notice. Do not build enforcement on it.

The Stable set is exactly these two paths:

PathTypeProvenanceTier
context._keel.action_envelope.connector.identity.valuestringkeel_derivedStable
context._keel.action_envelope.connector.tool_name.valuestringkeel_derivedStable

Both were chosen because each is an extracted fact — something Keel read off the governed action or resolved from its own registry — rather than a Keel judgment about that action, and because Keel establishes it on every surface where it is available at all. Extracted facts have a meaning that does not move when Keel’s classifiers improve.

Two paths is the whole set, and it contains nothing financial. That is the honest size of the contract today, not an omission from this page. Every money-shaped field in the envelope — the amount, its normalization, the currency, the operation — is caller_asserted in the authoring catalog because on Keel’s canonical payment rail the caller supplied the inputs. You can still author against them, and the sections below say exactly what each one is worth on each rail. What you cannot do is treat one as something Keel established.

financial.amount_usd_micros.value and .state were previously published on this page as Stable, with keel_derived provenance. They are not, and both are now Preview. On the canonical payment rail Keel computes the value as requested_amount × 1,000,000 — arithmetic over two caller-supplied action attributes — and stamps the resulting fact request_bound. A derivation cannot establish more than its inputs, so the authoring catalog now declares the field caller_asserted. On an MCP payment connector Keel really does derive it, from a schema-validated tool argument; the single catalog label takes the weaker of the two readings, which is the one that holds where real money moves.

.state follows .value out of the Stable set. Keel does establish the state — the catalog still declares it keel_derived, correctly, because whether an adapter produced an amount is Keel’s own answer — but a stability tier on the availability signal for a Preview value promises a contract Keel is not holding. The pair moves together. Both paths keep working and keep their meaning; neither is covered by the deprecation policy. See amount_usd_micros.

financial.currency.value and financial.operation.value were previously published on this page as Stable. They are not, and both are now Preview. Neither held the property a Stable tier promises. On Keel’s canonical payment rail currency is taken verbatim from a caller-supplied request attribute, and operation carries three disjoint value spaces depending on which code path produced it — including, on MCP, whatever string an upstream server chose to name its tool. Policies that already read these paths keep working; they are no longer covered by the deprecation policy, and the two field-reference sections below describe what each actually does. If you gate enforcement on either one, read currency and operation before your next policy review.

Discoverable does not mean supported

Any context._keel.action_envelope.* path not listed above is Preview, even if you can see it. The envelope is a fixed-shape object, so unlisted paths appear in API responses, in the policy-authoring catalog, and in decision snapshots. Some are authorable. None of that makes them a contract: their meaning may be refined, and a refinement would silently change what your policy does. Treat the table above as the allowlist.

The same applies in reverse. Fields outside the envelope — including the flat context._keel.action_access_* family documented in Read/Write Governance — have their own support status and are not governed by this page. The flat family is keel_derived but carries no per-fact availability state, so the absence rule below cannot be expressed with it.

Absence is not zero

Every fact in the envelope has a .state alongside its .value:

StateMeaning
presentKeel established the fact. .value is meaningful.
absentThe fact does not exist for this action. .value is null.
unknownThe fact may exist, but Keel could not establish it. .value is null.
not_applicableThe fact is meaningless for this action. .value is null.

A fallible fact is one whose .state can be something other than present. amount_usd_micros is fallible: a non-USD payment, an untyped financial action, and a non-financial action all produce a null value.

A policy that gates on .value without defining behaviour for the non-present states can fail open. Two mechanics combine to cause this:

  1. .value is always a resolvable path. The envelope emits the key with a null value when the fact is unavailable, so {"op": "exists", "value": true} on a .value path is true whether or not the fact is available. exists tells you the envelope has the slot, not that Keel established the fact.
  2. A numeric comparison against null is not applicable, and inapplicable comparisons resolve to false. So a threshold rule such as amount_usd_micros.value gt 50000000 simply does not match when the amount is unavailable.

Rules are evaluated in array order and the first terminal match wins, while allow is non-terminal. A threshold rule that does not match is therefore not a denial — it is a rule that stayed silent, leaving whatever allow preceded it standing. The unpriced payment is exactly the one you wanted reviewed.

The correct pattern

Pair every threshold on a fallible fact with a companion rule that decides what happens when the fact is unavailable. Gate the threshold on .state, then handle the remaining states explicitly.

The companion rule needs a way to say “this action moves money” that does not depend on the amount it is trying to guard. Use financial.moves_customer_value — Keel’s own value-movement verdict, which is keel_derived on every surface and is present on the canonical payment rail:

{ "name": "payment-review-with-absence-guard", "rules": [ { "if": { "all": [ { "field": "context._keel.action_envelope.financial.amount_usd_micros.state", "op": "eq", "value": "present" }, { "field": "context._keel.action_envelope.financial.amount_usd_micros.value", "op": "gt", "value": 50000000 } ] }, "action": "require_human_review" }, { "if": { "all": [ { "field": "context._keel.action_envelope.financial.moves_customer_value.value", "op": "eq", "value": true }, { "field": "context._keel.action_envelope.financial.amount_usd_micros.state", "op": "neq", "value": "present" } ] }, "action": "require_human_review" } ] }

The first rule reviews payments above $50.00. The second reviews any action Keel has classified as value movement whose amount it could not establish — the case the first rule cannot see. Use deny in place of require_human_review when an unpriced payment should be blocked outright.

Evaluated against the real envelope, the second rule fires on an unpriced payment on the canonical rail and on an unpriced payment through an MCP payments connector, and stays silent on priced payments and on non-financial actions:

Requestmoves_customer_value.valueamount_usd_micros.stateGuard fires
EUR payment on the canonical railtruenot_applicableyes
USD payment on the canonical railtruepresentno
EUR payment via MCP paymentstruenot_applicableyes
USD payment via MCP paymentstruepresentno
Non-financial readnull (unknown)unknownno

A previous version of this page used connector.identity.value in ["stripe", "payments"] as the companion rule’s scope. That guard does not fire on the canonical payment rail — the rail’s provider is stripe_mpp, which is not an MCP connector, so connector.identity is not_applicable and its .value is null there. The all never held, and the remediation missed the surface the hazard lives on. If you copied that pattern, replace the connector.identity condition as shown above.

Every path in this pattern is Preview. amount_usd_micros is Preview because on the canonical rail Keel derives it from caller-supplied inputs; financial.moves_customer_value is Preview because it is a Keel classifier judgment rather than an extracted fact, so its meaning can be refined as classification improves. The Stable set — connector.identity.value and connector.tool_name.value — cannot express this control at all: neither reaches present on the canonical payment rail, so a guard written only from Stable paths cannot be scoped to the surface the hazard lives on.

That is the real state of the contract, and it is the reason to write the pattern this way rather than to avoid it. moves_customer_value is keel_derived on every surface, and the guard’s job is to route the cases Keel could not price into human review — which is the outcome you want whether or not the number that was missing would have been trustworthy. Pin the behaviour you depend on with a test, and see known limitations.

The last row deserves a note. A non-financial action reports moves_customer_value.state as unknown, not present/false, so the guard above stays silent on it. If you also want to stop everything Keel could not classify, add moves_customer_value.state neq present as a third rule rather than widening the second — it is a much broader control, and it will review ordinary reads.

The .state gate in the first rule is belt-and-braces: the numeric comparison would already fail closed on its own. Write it anyway. It makes the rule’s intent legible, and it survives being copied into an any branch or negated, where the implicit behaviour would not.

Field reference

connector.identity.value

context._keel.action_envelope.connector.identity.value

PropertyValue
Typestring
Provenancekeel_derived
TierStable
Operatorseq, neq, in, not_in, contains, starts_with, ends_with, matches_regex, exists
Available onpermit and MCP surfaces (see availability)

Meaning. The Keel-registered identity of the connector that will carry out the action — for example github, slack, stripe, payments, crm, cloud, or a scoped database identity such as postgres.readonly.

How Keel derives it. When Keel resolves a verified connector binding for the request, the identity comes from Keel’s own MCP server registry, not from anything on the request. Two properties give it its trust:

  • It is never the MCP server slug. The slug is customer-renamable; the connector identity is a registry value. Renaming a server in the dashboard cannot change this field, and because unmapped tools default to review, a rename can only make classification stricter.
  • A managed connector identity with no Keel connector contract is rejected rather than passed through, and a binding whose stored contract hash, contract version, or semantic-adapter version has drifted from the current contract does not produce facts — it challenges.

States. present when a verified binding exists. unknown when the request runs through MCP but no verified connector identity was resolved — which is always the case on realtime. not_applicable when the action does not execute through a connector at all, such as a direct model call or a payment on the canonical rail, whose provider (stripe_mpp, x402) is a payment rail rather than an MCP connector.

Security caveats. An identity proves which connector, not what the call does. It does not prove the connector version is pinned — version pinning is tracked separately and is frequently unknown. Tool names are only unique within a connector, so pair this field with tool_name rather than matching a tool name alone.

{ "field": "context._keel.action_envelope.connector.identity.value", "op": "in", "value": ["stripe", "payments"] }

connector.tool_name.value

context._keel.action_envelope.connector.tool_name.value

PropertyValue
Typestring
Provenancekeel_derived
TierStable
Operatorseq, neq, in, not_in, contains, starts_with, ends_with, matches_regex, exists
Available onpermit and MCP surfaces (see availability)

Meaning. The exact tool name Keel bound to this decision, verbatim.

Tool names are not lower-cased, and matching on them is case-sensitive. Keel normalizes a bound tool name by trimming surrounding whitespace and nothing else, so a server that advertises Send-Money-NOW publishes Send-Money-NOW in this field. An eq or in match against send-money-now will not fire.

This is asymmetric with the permit’s requested_action, which is lower-cased during normalization. Do not assume the two are comparable case-insensitively, and do not assume a lower-case allowlist covers a mixed-case tool. Enumerate tool names exactly as the connector advertises them — check the tool contract rather than guessing the casing.

How Keel derives it. The name comes from the connector’s own tools/list response, pinned into a tool contract, and read back from the verified decision trace — not from a label on the request. Before any fact is produced, Keel re-hashes the pinned input schema and compares it against both the stored contract hash and the observed hash; any mismatch challenges the call. A contract that has not been re-verified within 24 hours challenges as stale, and call arguments that do not validate against the pinned schema are denied.

Matching is on exact names only, and exact includes exact case. A newly introduced tool never inherits a meaning from a name prefix or a caller-supplied label.

States. present when a verified binding exists. unknown when the request runs through MCP but no verified tool name was recorded — which is always the case on realtime. not_applicable on provider execution that does not involve a tool call.

Security caveats. This field is available on the MCP surface and on the permit surface when the permit cites an MCP decision; it is never available on realtime, where a policy matching on it can never match. A tool name is an identifier, not a capability claim — an unmapped name still classifies as unknown access, and knowing the name does not tell you what the tool does. Because the value space is open-ended, prefer positive matches (eq, in) over negative ones: a not_in list silently admits every tool nobody has thought of yet.

{ "all": [ { "field": "context._keel.action_envelope.connector.identity.value", "op": "eq", "value": "github" }, { "field": "context._keel.action_envelope.connector.tool_name.value", "op": "in", "value": ["merge_pull_request", "create_repository"] } ] }

financial.amount_usd_micros.value and .state

context._keel.action_envelope.financial.amount_usd_micros.value context._keel.action_envelope.financial.amount_usd_micros.state

Preview — demoted from Stable. This is the field a spend cap is most likely to be written against, so read this section before relying on one. On the canonical payment rail the number is Keel’s arithmetic over two caller-supplied values, and the fact is stamped request_bound. Keel will not accept a different amount at dispatch than the one it decided on, but it never checked the amount against anything outside the request. On an MCP payment connector it is genuinely keel_derived. See what the amount actually is below.

PropertyValue
Type.value integer, .state enum
Catalog provenance.value caller_asserted; .state keel_derived
Fact trust (.value)request_bound on the canonical rail, keel_derived on MCP
TierPreview (both paths)
Operators.value: eq, neq, gt, gte, lt, lte, exists. .state: eq, neq, in, not_in, exists
UnitsUSD micros. 1 USD = 1,000,000. $50.00 is 50000000.
Available onpermit and MCP surfaces (see availability)

Meaning. The amount of customer value the action moves, normalized to USD micros. This is a payment amount, not a model cost. Inference spend is a separate concept with its own public field, estimated_cost_usd_micros; the two are never interchangeable and should never be summed.

What amount_usd_micros actually is

How Keel derives it is path-dependent, and the two paths differ in how much they are worth. Two typed adapters produce it, and nothing else does:

  • MCP payment connectors. For the payments and stripe connector identities, Keel’s semantic adapter reads the amount out of a schema-validated tool argument: the argument must be declared in the pinned tool schema and present in the call, must be a non-negative integer, and is read in minor units, then multiplied by 10,000. On this path the fact’s trust is keel_derived — Keel read the number out of a verified, schema-validated tool call.
  • Canonical payment rails. When the request matches one of Keel’s canonical payment rails — the action name, resource type, operation, provider, and model must all line up — Keel takes the caller’s requested_amount action attribute, which is expressed in whole USD units on this path, and multiplies by 1,000,000. The result is present only when the caller’s requested_currency is usd. On this path the fact’s trust is request_bound: both inputs to the multiplication are caller-supplied attributes, stamped request_bound themselves.

In both cases the number is extracted from the action Keel is authorizing, not from a side channel the caller controls independently, and on both Keel binds it to the decision — it will not accept a different amount at dispatch than the one it decided on.

This is why the field is no longer Stable. A keel_derived label implied Keel established the number. On the canonical rail it did not: it did the arithmetic over values the caller chose. Keel doing the multiplication earns the fact’s sourcekeel.payment_adapter — not its trust. A derived fact carries the weakest trust among the inputs it passes through, so amount_usd_micros inherits request_bound from amount and currency, and the authoring catalog declares the field caller_asserted.

What that does and does not cost you:

  • It does not mean the threshold is decorative. An agent that inflates requested_amount to get a bigger payment trips a gt rule harder, and the bound value is the one that must be dispatched.
  • It does mean the number is not a floor. An agent that wants to stay under a cap can declare a small requested_amount, or a non-USD requested_currency that makes the field not_applicable entirely. On the canonical rail a spend cap keyed on this field constrains what the caller declared, not what Keel measured.
  • The remedy is the absence rule: pair the threshold with a companion rule that fires when the amount is not present, so side-stepping the normalization routes to review rather than to silence.

A previous version of this page claimed Keel requires the permit’s action name to equal the bound tool name before it will build typed payment facts at all. It does not, and you should not rely on that equality as a control. One projection — the one that carries verified MCP financial facts onto a permit — does enforce it. But when that check fails, Keel falls back to reading the amount and currency straight off the verified connector facts, and that fallback performs no such comparison. A permit whose requested_action does not match the bound tool name still yields a present amount_usd_micros. Treat the amount as bound to the tool call Keel verified, not to the action name the caller wrote on the permit. If you need the two to agree, compare connector.tool_name.value yourself in the policy.

States. present when an adapter produced an amount. not_applicable when the payment is not in USD — there is no conversion step, so a non-USD payment has no USD normalization — or when the action is not classified as value movement at all. unknown when the action may move value but no typed adapter could produce an amount — including every realtime turn.

Security caveats. This is the fallible field the absence rule exists for; read that section before writing a threshold. Three specific traps:

  • On the canonical payment rail this is not evidence against the caller. The caller chose both inputs. A cap of gt 50000000 is a control on the declared amount; it is not a statement that Keel measured $50.00. Treat it as a tripwire on what the agent asked for, and put the enforcement weight on the companion rule that catches a non-present amount.
  • Non-USD payments are not_applicable, not zero. A USD-only threshold rule ignores a €10,000 transfer entirely, and on the canonical rail the caller picks the currency that decides this. If your control is about size, pair the threshold with a rule that reviews or denies non-present states, as in the correct pattern. Do not reach for a currency allowlist instead: on that rail the currency is caller-supplied too, so an agent can choose the branch that allowlist selects.
  • exists does not mean available. {"op": "exists", "value": true} on the .value path is true even when the amount is null. Compare .state to present instead.
{ "all": [ { "field": "context._keel.action_envelope.financial.amount_usd_micros.state", "op": "eq", "value": "present" }, { "field": "context._keel.action_envelope.financial.amount_usd_micros.value", "op": "gt", "value": 50000000 } ] }

financial.currency.value

context._keel.action_envelope.financial.currency.value

Preview — demoted from Stable. On the canonical payment rail this value is taken verbatim from a caller-supplied request attribute and is not validated there. Do not use it as evidence against the caller on that path. See what it actually is below.

PropertyValue
Typestring, lower case
Catalog provenancecaller_asserted
Fact trustrequest_bound on the canonical rail, keel_derived on MCP
TierPreview
Operatorseq, neq, in, not_in, contains, starts_with, ends_with, matches_regex, exists
Available onpermit and MCP surfaces

Meaning. The currency the financial action is denominated in — for example usd or usdc. Always lower case in the envelope.

What currency actually is

How Keel derives it is path-dependent, and the two paths differ in how much they are worth.

  • MCP payment connectors. Keel’s semantic adapter reads a schema-validated currency argument off the tool call, requires it to match a three-to-twelve-letter code, and normalizes it to lower case. Because the pattern is not restricted to ISO 4217, stablecoin and token symbols are representable alongside fiat codes. On this path the fact’s trust is keel_derived — Keel read it out of a verified, schema-validated tool call.
  • Canonical payment rails. The value is the caller’s requested_currency action attribute, trimmed and lower-cased. On this path the fact’s trust is request_bound, and Keel applies no format validation at that site — no ISO check, no length check, no allowlist. Any non-empty string the caller sends is published verbatim as a present fact. A request carrying requested_currency: "zzz" yields currency.value == "zzz" with state == "present".

Keel binds the rail value to the decision — it will not accept a different currency at dispatch than the one it decided on — so the field is meaningful as a request-binding. It is not an observation Keel made of the world, and the agent your policy constrains is normally the one who chose it.

This is why the field is no longer Stable. A keel_derived label would imply Keel established the value. On the canonical rail it did not; it bound a value the caller supplied. The catalog therefore reports the weaker cross-surface classification, caller_asserted. Read the fact’s own trust label in the decision snapshot when deciding how much weight to give a particular event.

States. present when an adapter produced a currency. unknown when the action may move value but no typed adapter produced one. not_applicable when the action is not value movement. Never present on realtime.

Security caveats. Currency is a label on the amount, never a magnitude — do not infer size from it. It is also not a conversion: usd here does not imply that a USD amount is available, and amount_usd_micros can still be not_applicable. Check the amount’s .state separately.

Above all: on the canonical payment rail, a currency allowlist is not a control against the caller. An agent that picks the currency picks which branch of your policy runs. If you want to constrain value movement by denomination, gate on amount_usd_micros.state and treat a non-present amount as the thing to review, as in the correct pattern. Keel does establish that state — it is Keel’s own answer about whether an adapter produced an amount — but be precise about what it establishes: present means Keel produced a USD figure, not that Keel checked the figure. On the canonical rail present is reached because the caller declared a USD amount. Use currency to describe a decision, not to authorize one.

{ "all": [ { "field": "context._keel.action_envelope.financial.currency.value", "op": "eq", "value": "usd" }, { "field": "context._keel.action_envelope.financial.amount_usd_micros.state", "op": "eq", "value": "present" } ] }

financial.operation.value

context._keel.action_envelope.financial.operation.value

Preview — demoted from Stable. This path carries three disjoint value spaces depending on which derivation produced it, and on MCP the value is whatever string an upstream server chose to name its tool. A field whose value space depends on the caller cannot be a stable contract.

PropertyValue
Typestring
Catalog provenancecaller_asserted
Fact trustvaries by path — request_bound, keel_verified, or keel_derived
TierPreview
Operatorseq, neq, in, not_in, contains, starts_with, ends_with, matches_regex, exists
Available onpermit and MCP surfaces

Meaning. What kind of financial operation this is — but what kind of string you get depends on which of three derivations ran.

How Keel derives it. Three paths, each producing a different flavour of value, and the three value spaces do not overlap:

PathValueFact trustValue space
Canonical payment railsthe literal payment.executerequest_boundexactly one constant
Registry-derivedthe literal refundkeel_derivedexactly one constant
MCP payment connectorsthe verified tool namekeel_verifiedopen-ended — any tool name
  • Canonical payment rails. Keel sets the literal payment.execute when the request matches a canonical rail. The value is a constant Keel chose, though the fact is labelled request_bound.
  • Registry-derived. When no typed payment adapter applies but Keel’s action registry tags the action as value movement and as a refund, the operation is the literal refund. This path emits no other value; anything else the registry knows resolves to unknown.
  • MCP payment connectors. The verified tool name from the decision trace, verbatim and case-sensitive, with no equality check against the permit’s action name. This is the open-ended one.

The MCP value space collides with the constants. Nothing stops an upstream MCP server naming a tool payment.execute or refund. If it does, this field publishes that name and an allowlist such as operation in ["payment.execute", "refund"] matches a third-party tool call as though it were Keel’s own canonical rail or a registry-typed refund. The allowlist cannot tell the two apart, because at this path the value is the tool name.

Pair the operation with connector.identity.value whenever you match on it, so a rule meant for the canonical rail cannot be satisfied by a connector tool that happens to share the name.

States. present when one of those paths produced a value. unknown when the action may be financial but no typed operation could be established. not_applicable when the action is not value movement. Never present on realtime.

Security caveats. This is not a closed enum. One derivation path returns a connector tool name, so the value space grows with the tools your project connects. That has two hard consequences. First, never build an allow-shaped rule on a negative match: a not_in or neq gate admits every operation string that does not yet exist, including the next money-moving tool somebody adds. Enumerate the operations you intend to permit with in, and treat anything else — including unknown — as needing review. Second, because matching is case-sensitive and the MCP path is verbatim, an allowlist entry that differs only in case will not match.

{ "all": [ { "field": "context._keel.action_envelope.connector.identity.value", "op": "in", "value": ["stripe", "payments"] }, { "field": "context._keel.action_envelope.financial.operation.value", "op": "in", "value": ["payment.execute", "refund"] }, { "field": "context._keel.action_envelope.financial.amount_usd_micros.state", "op": "eq", "value": "present" } ] }

Availability by surface

Availability is not the same as trust, and the two are independent. A field that is not_applicable on a surface keeps whatever provenance it has; it simply has no value to give there. Nothing in this table promotes a caller_asserted field.

Generated from the committed Keel API contract 4daab88ced66610d3e8ae9c47caecc75842d5eb7 using artifacts/keel-api/4daab88ced66610d3e8ae9c47caecc75842d5eb7/action-envelope-availability.json (SHA-256 872256a1ab3696850283da50dca2ee8ec300dc1490ad22e005b63f1730a72355). Do not edit this table by hand.

FieldPermitMCPRealtime
action.access_level.valueyesyesyes
action.risk_tags.valueyesyesyes
connector.identity.valueyesyesno
connector.tool_name.valueyesyesno
connector.tool_schema_hash.stateyesyesyes
financial.moves_customer_value.stateyesyesyes
financial.moves_customer_value.valueyesyesyes
financial.operation.valueyesyesno
financial.amount.stateyesyesyes
financial.amount.valueyesyesno
financial.amount_usd_micros.stateyesyesyes
financial.amount_usd_micros.valueyesyesno
financial.currency.valueyesyesno
financial.destination_digest.stateyesyesyes
financial.destination_digest.valueyesyesno
authority.is_mutation.stateyesyesyes
authority.is_mutation.valueyesyesyes
authority.compute_lineage_only.valueyesyesyes
pricing.estimated_cost_usd_micros.stateyesyesyes
pricing.estimated_cost_usd_micros.valueyesnoyes

Availability is per leaf. In particular, financial.amount_usd_micros.state is matchable on Realtime because it resolves to unknown or not_applicable, while financial.amount_usd_micros.value cannot reach present there. The two rows are intentionally different.

A field marked available on a surface can still resolve to unknown or not_applicable for any individual request. Availability describes the contract; .state describes the request.

How these facts are recorded

Provenance answers where a value came from. This section answers a different question that policy authors and auditors ask next: once Keel decides, what protects the record of the facts it decided on?

Keel records these facts and protects them against ordinary database writes. The permit signature does not cover them. That is the whole classification: recorded, with local write protection. Not attested by the permit signature, and not independently anchored. The rest of this section is the detail behind that sentence.

Where the envelope goes

The envelope is built during evaluation and lands in two places:

  • The policy evaluation context, at context._keel.action_envelope — the paths this page documents, which your rules read.
  • The decision snapshot, at GovernanceDecisionSnapshot.engine_context_json.trusted_action_envelope, alongside a frozen capture of the authorizer input.

It does not land in the permit’s resource_attributes_json.

What the permit signature covers

A v6 permit binds resource_attributes_canonical_hash — an RFC 8785 digest of resource_attributes_json — into the signed preimage. Because the envelope is not in that object, that digest does not cover it. The v7 required field set is resource_attributes_canonical_hash, authority_chain_digest, quota_reservation_id, subject_id, subject_type, account_id, and org_id: no envelope field, and no decision-snapshot hash.

The signed preimage does carry policy_snapshot_hash, and it is easy to read that as covering the evaluation. It does not. It hashes the policy identity and version, the outcome, the reason, the constraints, and the budgets — the decision and the policy that produced it. Evaluation input is not in it. Its purpose is to make a post-hoc policy edit break re-verification, not to bind the facts the policy read.

So: a verifier that checks a permit signature has checked the decision, the policy that produced it, and the request attributes hashed into resource_attributes_json. It has not checked a single field on this page.

What does protect the record

The decision snapshot carries its own snapshot_hash over its stored content, and the row is write-protected at two layers:

  • ORM guards. before_update and before_delete hooks on GovernanceDecisionSnapshot raise rather than let application code modify or remove a snapshot.
  • Database triggers. PostgreSQL triggers raise on UPDATE and on DELETE against governance_decision_snapshots. The update trigger permits exactly one narrow case — a foreign-key SET NULL that blanks project_id while every other column stays byte-identical — and rejects everything else.

That is real protection, and it is the reason this page keeps telling you to read the fact’s trust label in the decision snapshot: the snapshot is the record of what actually happened, and ordinary writes cannot rewrite it.

What it is not

The snapshot_hash is a local integrity hash over stored content. It is not signed, not hash-chained, and not externally anchored. It is not in the permit preimage, so a permit signature does not vouch for it, and nothing else signs it either. Its guarantee is scoped to Keel’s own database: it detects a snapshot that no longer matches its own hash, and the triggers stop the writes that would cause that. It says nothing to a party who does not trust that database.

Keel does run a signed, Rekor-anchored integrity checkpoint — but over the governance event chain, not over decision snapshots. The trusted action envelope is not in that chain, so the anchoring that exists elsewhere in the product does not reach these facts.

What this means in practice. Treat the envelope as high-quality operational evidence, and cite it as such: it is durable, write-protected, and it records what Keel saw. Do not describe it to an auditor, a counterparty, or a customer as signed or attested, and do not build a dispute-resolution story that depends on a third party verifying these facts from a permit signature. If that is what you need, the material that is signed is the permit’s own decision fields and resource_attributes_json — see Threat Model for what the signature is designed to establish.

Known limitations

These are open inaccuracies in the product, not in this page. They are listed here so a policy author can plan around them. Each was re-checked against main when this page was last revised.

Resolved since the previous revision. The catalog used to report financial.currency.value and financial.operation.value as keel_derived while the envelope stamped them request_bound on the canonical rail. Both are now declared caller_asserted, and so is financial.amount_usd_micros.value. The catalog and the envelope agree on these three; the limitation is gone and its entry has been removed rather than left standing.

Still open:

  • On the MCP surface the catalog understates provenance for five financial fields. financial.amount.value, amount_usd_micros.value, currency.value, operation.value, and destination_digest.value are all declared caller_asserted, but on a connector-bound MCP call Keel’s semantic adapter genuinely derives them and the envelope stamps keel_derived. A field carries one provenance across every surface, so the catalog declares the weaker reading deliberately. The consequence is only ever conservative: these fields are worth more on MCP than the label admits, never less. Keel’s provenance check treats an overclaim as a build failure and an underclaim as a recorded contradiction; there are currently five underclaims and no overclaims.
  • The Stable set contains no surface-independent “is this value movement?” predicate — and, since the amount was demoted, nothing financial at all. financial.moves_customer_value is the right field for the job and is keel_derived on every surface, but it is a classifier judgment rather than an extracted fact, so it sits in Preview. The absence guard in the correct pattern uses it deliberately and marks the trade-off.
  • The trusted action envelope is not covered by the permit signature. It is recorded in the decision snapshot and write-protected there, but it is not signed, not hash-chained, and not externally anchored. See how these facts are recorded — this is a property of the design as it stands, not a defect awaiting a fix, and it is listed here because it is the assumption most often made in the other direction.
  • Bound MCP tool names are case-preserving while permit requested_action is lower-cased. The asymmetry is deliberate at this point: changing normalization would alter matching for every existing policy at once. Author tool-name matches with the exact casing the connector advertises.
Last updated on Edit this page on GitHub