Skip to content

7. Admission rules

Previous: Studio editor · Learning path · Next: Typed views

Declarative bindings are experimental and still undergoing qualification. The examples here explain the current implementation, not a production compatibility promise. A node executes committed binary IR, not the YAML text.

Bindings say what should happen next. Admission rules say what a component must refuse. A rule is attached to a component and checked for every command that reaches it: commands submitted directly and commands a binding derives, at every depth of a cascade. Rules only forbid. They never grant authority, change a command, or replace the component’s own admission and decision. They are part of the committed composite profile, so every member evaluates them identically.

This chapter uses the rule recipes in examples/bindings/: balances-transfer-limit.yaml, procurement-admission.yaml, dpp-role-gated.yaml, feed-slot-rules.yaml and balances-holding-cap.yaml. Chapter 8 covers rules that read other components’ state and judge each write of a batch. The integration harness BindingRecipesIT runs every recipe on three real nodes and replays every finalized block offline with byte-identical receipts.

A rule lives in the document’s rules section and takes effect only where a component’s admission list attaches it:

composite:
components:
- id: points
machine: balances
admission:
- rule: transfer-limit
params: {maxAmount: 10000}
rules:
- id: transfer-limit
command: transfer
deny: TRANSFER_LIMIT_EXCEEDED
params:
maxAmount: {type: integer}
require:
- expr: 'command.amount <= params.maxAmount'
bindings: []
  • id names the rule; deny is the code a refusal records. Deny codes are upper-case identifiers and cannot start with ADMISSION_RULE_, which the engine reserves.
  • command (optional) selects one command of the attached component. A rule without a selector applies to every command. A selector needs a command-selectable kernel: every command is an opcode-prefixed array with a distinct opcode, or the kernel has exactly one command that is not raw bytes.
  • params declares typed parameters (integer, text, bytes, boolean, binding), optionally with a default. Each attachment supplies values; stating a default and omitting it compile to identical bytes. A binding parameter must name a binding that targets the attached component.
  • reads (optional) declares up to four named reads of other components’ state (see Typed views).
  • require is an ordered list of clauses. Each is a restricted CEL expr or a lookup (exists, absent, or eq against a source). The first clause that does not hold refuses the command with the rule’s deny code.

A rule that no component attaches is rejected (RULE_UNATTACHED). A rule is type-checked separately against every component it is attached to.

Each use site has its own scopes. Bindings read the event that triggered them; rules read the command they judge.

ScopeBindings (conditions, mappings)Rules
event.*yesno
context.*yes (the producing step)yes (the step being admitted)
command.*noonly with a command selector; DATA fields only
params.*noyes
config.*noyes (the component’s normalized configuration)
facts.*noyes, if the kernel declares facts
reads.*noyes, for the rule’s declared reads (chapter 8)
writesnoonly as writes.all(w, …) or writes.exists(w, …), if the kernel declares a write view (chapter 8)

context.* has exactly five fields: height (integer), sender (bytes, the source message’s sender), derived (boolean), depth (integer, 0 for the source command), and binding (text, the binding that produced the step, empty for the source). There is no timestamp: rules and bindings are deterministic.

Evidence fields, such as signatures, are never readable (RULE_EVIDENCE_READ). Reading an unavailable scope is RULE_SCOPE_INVALID, with the position of the offending identifier. The YAML source forms mirror the scopes: {context: …}, {command: …}, {param: …}, {config: …} and {fact: …} next to {field: …} for events. in tests text membership and is available only in rules; a fact of type text set can be read only on the right-hand side of in.

Lookup keys and read keys use a narrower set: command, params, config, context and facts, never reads or writes, so one read can never choose another read’s key.

The engine checks a component’s rules in two slots, keeping attachment order within each slot:

  1. Admission slot — rules that read neither facts.* nor write coverage. They run after the component’s own admission checks and before any work is reserved, so a refused command costs no crypto work. Their reads see the state this cascade left.
  2. Verified-fact slot — rules that read facts.* or a write’s verified coverage (w.coverage, w.actorId, …). They run only after the kernel approved the command, with the exact facts that produced the approval. A kernel rejection runs no fact rule and reveals no fact.

The first failing rule wins; later rules are neither evaluated nor charged. Step through both slots with the feed-slot-rules recipe. Its two rules are attached in one order but run in the other, because only one of them needs the kernel’s verified facts:

When admission rules runIllustrative data

What you're seeing: the feed-slot-rules recipe on a governed map. Step through an accepted observation, then try the “What if” refusals, including a static rule at ingress and a refusal at depth 1 from other recipes.

  1. Submit Actor logistics-a signs one map write: PUT_IF_ABSENT observations/logistics-a/1 with price 150. A member submits it to the registry component. Two rules are attached, in this order: own-insert-only-slot, then feed-open-and-in-range.
  2. Ingress Neither rule is static: one declares a read, the other reads write coverage. So the ingress member runs only the codec and the kernel’s stateless admission, and answers 202. Both rules wait for the block.
    • passes: Static rules attached to registry: none
  3. Admission slot At block time, the admission slot runs after the kernel’s admit hooks and before any work is reserved. feed-open-and-in-range runs first even though it is attached second, because it reads neither facts nor coverage. Its read feed finds feeds/main: OPEN, min 100, max 200. Price 150 is in range.
    • passes: reads.feed.present && reads.feed.value.status == "OPEN"
    • passes: writes.all(w, …price between reads.feed.value.min and max)
  4. Kernel decides The map reserves crypto work, verifies logistics-a’s signature against the collection’s policy, and approves. That approval’s facts give the write its coverage: direct, actor logistics-a, organization swift-logistics.
  5. Fact slot own-insert-only-slot reads coverage, so it runs only now, with the exact facts of that approval. A forged or unverified signature never reaches it. The key logistics-a/1 starts with logistics-a/.
    • passes: writes.all(w, …PUT_IF_ABSENT, coverage direct, key starts with actorId + "/")
  6. Commit Both rules held, so the step’s rule trace is [2, null]. The observation commits and the receipt is ACCEPTED.

What if: Price out of range

An admission-slot refusal: nothing was verified, so no crypto work was spent.

  1. Submit Actor logistics-a signs one map write: PUT_IF_ABSENT observations/logistics-a/3 with price 250. A member submits it to the registry component. Two rules are attached, in this order: own-insert-only-slot, then feed-open-and-in-range.
  2. Ingress Neither rule is static: one declares a read, the other reads write coverage. So the ingress member runs only the codec and the kernel’s stateless admission, and answers 202. Both rules wait for the block.
    • passes: Static rules attached to registry: none
  3. Admission slot At block time, the admission slot runs after the kernel’s admit hooks and before any work is reserved. feed-open-and-in-range runs first even though it is attached second, because it reads neither facts nor coverage. Its read feed finds feeds/main: OPEN, min 100, max 200. Price 250 is not.
    • passes: reads.feed.present && reads.feed.value.status == "OPEN"
    • fails: writes.all(w, …price between reads.feed.value.min and max) OBSERVATION_OUT_OF_RANGE
  4. Refused before work The step fails with ADMISSION_RULE_DENIED. The kernel never verified the signature and no work was reserved. Only the receipt is written, with trace [0, [feed-open-and-in-range, 1, OBSERVATION_OUT_OF_RANGE, 0]]: the rule, the clause, the deny code and the deciding write.

What if: Someone else’s slot

A fact-slot refusal: the signature was verified first, and that work stays charged.

  1. Submit Actor logistics-a signs one map write: PUT_IF_ABSENT observations/maker-a/1 with price 150. A member submits it to the registry component. Two rules are attached, in this order: own-insert-only-slot, then feed-open-and-in-range.
  2. Ingress Neither rule is static: one declares a read, the other reads write coverage. So the ingress member runs only the codec and the kernel’s stateless admission, and answers 202. Both rules wait for the block.
    • passes: Static rules attached to registry: none
  3. Admission slot At block time, the admission slot runs after the kernel’s admit hooks and before any work is reserved. feed-open-and-in-range runs first even though it is attached second, because it reads neither facts nor coverage. Its read feed finds feeds/main: OPEN, min 100, max 200. Price 150 is in range.
    • passes: reads.feed.present && reads.feed.value.status == "OPEN"
    • passes: writes.all(w, …price between reads.feed.value.min and max)
  4. Kernel decides The map reserves crypto work, verifies logistics-a’s signature against the collection’s policy, and approves. That approval’s facts give the write its coverage: direct, actor logistics-a, organization swift-logistics.
  5. Fact slot own-insert-only-slot reads coverage, so it runs only now, with the exact facts of that approval. A forged or unverified signature never reaches it. The key maker-a/1 does not start with logistics-a/.
    • fails: writes.all(w, …PUT_IF_ABSENT, coverage direct, key starts with actorId + "/") OBSERVATION_REJECTED
  6. Work stays charged The step fails with ADMISSION_RULE_DENIED, trace [1, [own-insert-only-slot, 0, OBSERVATION_REJECTED, 0]]. The write is rolled back, but the crypto work the kernel reserved to verify the signature is not refunded. Only fact-slot refusals keep that charge.

What if: A static rule at ingress

From balances-transfer-limit.yaml: a rule over command.* and params.* only.

  1. Submit a transfer A member submits a transfer of 20,000 points. The points component has transfer-limit attached with maxAmount: 10000. The rule reads only command.amount and params.maxAmount, so it is static.
  2. Refused at ingress The ingress member evaluates static rules before pooling, so the over-limit transfer is refused at once with 400. The REST body names the rule and its deny code. Nothing is pooled, and there is no block and no receipt.
    • fails: command.amount <= params.maxAmount (20,000 ≤ 10,000) TRANSFER_LIMIT_EXCEEDED
  3. Block time still decides The ingress check is advisory: block-time evaluation is authoritative, and a message that reaches a block is judged again there. bindings dry-run reproduces the ingress refusal as ADMISSION_RULE_DENIED/transfer-limit/TRANSFER_LIMIT_EXCEEDED.

What if: Refused at depth 1

From the procurement rules with the binding’s required lowered to 1.

  1. Submit an order A registered supplier puts an order into orders. Its rule registered-supplier holds, and the binding order-to-approval derives approvals.propose with required: 1.
  2. Refuse the derived step Rules run for derived commands too, at every depth. On approvals, minimum-quorum requires command.required >= params.minimum with minimum: 2, so the derived proposal is refused at step 1.
    • fails: command.required >= params.minimum (1 ≥ 2) QUORUM_TOO_LOW
  3. Roll back the cascade A refused step rejects its source message. The order put at depth 0 is rolled back with the proposal, and the receipt is REJECTED with failed step 1. A false binding condition would only have skipped the proposal.
  • ingress member
  • rule slot
  • kernel
  • committed
  • refused

Each step’s receipt records rules = [heldCount, failure], where the failure names the rule, the clause index, the deny code and, when a quantifier stopped early, the index of the write that decided. Reads run before the clauses; a read that fails records clause -1. Receipts never contain read values or write contents. Refusal codes are:

CodeMeaning
ADMISSION_RULE_DENIEDa clause did not hold; the receipt carries the deny code
ADMISSION_RULE_ERRORa clause or read could not be evaluated, for example an absent fact, an unguarded absent read, a refused read key, or a division by zero; it fails closed
ADMISSION_RULE_INPUTthe command view, the kernel’s fact values, a read’s decoded value or the write view broke its declaration; clause -1
EXPRESSION_CAPACITY_EXCEEDEDthe cascade or block expression work was exhausted

Rule work is charged to both the cascade and the block budget, including for refusals, and is never refunded. A read costs 1 + its key bytes, the stored key and value bytes and the decoded fields; a write view, and in the fact slot its coverage, cost their encoded elements once per step; a quantifier costs its body once per element it visits.

A rule whose clauses are all expressions that read only command.*, params.*, config.* and write content (not coverage) is static. A rule with reads is never static. A member evaluates the static rules of a source component when it receives a submission, so an over-limit transfer is refused before it is pooled. The REST response is HTTP 400 with a structured reason:

{"code": "ADMISSION_RULE_DENIED",
"details": {"rule": "transfer-limit", "deny": "TRANSFER_LIMIT_EXCEEDED"}}

details.write names the deciding write of a quantifier. bindings dry-run shows the same refusal as ADMISSION_RULE_DENIED/transfer-limit/TRANSFER_LIMIT_EXCEEDED. Block-time evaluation remains authoritative.

A binding condition that is false only skips that binding. A rule refusal rejects the command, and a rejected step rejects its source message: every earlier step of the same cascade, including the source command, is rolled back, and no business state is written: only the receipt, framework accounting, and any non-refundable crypto work a kernel reserved before a verified-fact rule refused it. For example, in procurement-admission.yaml:

- id: approvals
machine: approvals
admission:
- rule: minimum-quorum
params: {minimum: 2}

The order binding derives approvals.propose with required: 2. If a binding author lowered that literal to 1, every order’s cascade would be refused at depth 1 with QUORUM_TOO_LOW, and the order put at depth 0 would not commit either. bindings dry-run shows this before deployment: the receipt’s failed step is at depth 1, and stateChanges contains no order.

Pattern: accept only arrivals through a binding

Section titled “Pattern: accept only arrivals through a binding”

Rules can see how a step arrived. This rule makes an audit trail accept entries only from the approval binding, never from a direct submission:

- id: only-via-binding
deny: DIRECT_SUBMISSION_FORBIDDEN
params:
binding: {type: binding}
require:
- expr: 'context.derived && context.binding == params.binding'

It reads context, so it is not static: a direct append is pooled and refused at block time with a finalized, provable receipt.

Some kernels establish verified facts about a command, and only those kernels can feed facts.*:

KernelFacts
authenticated-map-componentsenderMember (boolean), collections (text set: the collections the batch writes)
governed authenticated-map-componentalso directActorCount, approvalCount (integers); for exactly one direct actor, actorId, organizationId, role, roles (text set) and policyId
governed-role-approvalsactorId, organizationId, roles (text set), action (PROPOSE, APPROVE, REJECT, CANCEL), policyId, policyRevision

The other stock kernels authenticate only the member sender, so they establish no identity facts; they declare the post-state facts described below. A rule that reads a fact its kernel does not declare is rejected when the profile is built (RULE_FACT_UNKNOWN). A multi-actor map batch establishes counts only, so a rule that reads facts.roles for it fails closed with ADMISSION_RULE_ERROR.

dpp-role-gated.yaml adds two rules to the DPP approval recipe:

rules:
- id: allowed-organization
deny: ORGANIZATION_NOT_ALLOWED
params:
organization: {type: text}
require:
- expr: 'facts.action != "APPROVE" || facts.organizationId == params.organization'
- id: operator-for-direct-writes
deny: ROLE_REQUIRED
params:
role: {type: text}
require:
- expr: 'facts.directActorCount == 0 || params.role in facts.roles'

An approval from an auditor outside cert-body-a is verified by the approval kernel and then refused with ORGANIZATION_NOT_ALLOWED. A forged approval fails the kernel’s own signature check, and no rule runs. A direct map write signed by an actor without operator is verified by the map and then refused with ROLE_REQUIRED. Approval-referenced writes arriving through apply-approved have directActorCount == 0; the approval policy governs them.

To role-gate a member-authenticated machine such as kv-registry, put a role-verified component in front of it and attach an only-via-binding rule to the target.

The stock kernels also declare facts about the state after an approved command. Each equals the value the kernel’s own event reports, and, like every fact, is known only after the kernel approved, so a rule that reads one runs in the verified-fact slot:

KernelFacts
balancesbalanceAfter (mint); fromBalanceAfter, toBalanceAfter (transfer)
kv-registryexisted; valueLength (put)
doc-trailcountAfter, first
approvalsapproverCountAfter, required, approvedNow, proposerIsSender (absent when no item exists)

balances-holding-cap.yaml caps what a recipient may hold:

- id: holding-cap
command: transfer
deny: HOLDING_CAP_EXCEEDED
params:
cap: {type: integer}
require:
- expr: 'facts.toBalanceAfter <= params.cap'
LimitDefaultMaximum
rules per document—64
attachments per component (maxRulesPerComponent)416
clauses per rule—8
parameters per rule—16

Rule expressions share the document’s expression limits and lookup limit. Chapter 8 lists the limits on reads and write views.

Terminal window
./yano.sh appchain bindings validate examples/bindings/procurement-admission.yaml \
--plugins-directory plugins --context context.json
./yano.sh appchain bindings dry-run examples/bindings/procurement-admission.yaml \
--plugins-directory plugins --context context.json \
--fixture examples/bindings/fixtures/procurement-admission/fixture-1.json
./yano.sh appchain bindings graph examples/bindings/procurement-admission.yaml \
--plugins-directory plugins --context context.json

validate lists each component’s rules in evaluation order with their slot, their reads and whether they quantify over writes. dry-run shows each receipt’s rule trace, the deciding write, and explains a rolled-back cascade. graph draws attached rules as guards on their components, with their reads. Run the recipe’s fixtures in order, passing each result to the next block with --prior-result.

Rules are part of the committed profile. Changing a rule, a parameter or an attachment changes the profile digest; on a running chain it is a governed profile change, like any binding change (see Operations and upgrades).

Next: Typed views. The reference has the complete rule grammar.