Skip to content

8. Typed views

Previous: Admission rules · Learning path

The rules in chapter 7 judge the command in front of them. Typed views (ADR-031.4) let a rule also read other components’ state, and judge every write in a batch. Kernels expose both as typed fields, so a rule never parses stored bytes itself.

  • You’ll learn: how a rule reads another component’s record, why every read needs a present guard, how writes.all and writes.exists walk a batch, and which slot each kind of rule runs in.
  • Before you start: read chapter 7. The recipes here run on a governed authenticated map; their fixtures need the context.json shipped beside them.

Typed views are experimental and need a matching host build; see the learning path for the version requirement. A node executes committed binary IR, not the YAML text.

A rule may declare up to four reads. Each reads one exact key of another component’s value view: the typed fields that component’s kernel exposes for the records it stores. asset-governed-limits.yaml keeps its transfer limit in a map, not in the profile:

- id: governed-transfer-limit
command: transfer
deny: TRANSFER_LIMIT_EXCEEDED
reads:
limits: {component: registry, namespace: settings, key: {literal: "transfer"}}
require:
- expr: 'command.amount <= reads.limits.value.max'
  • component names a declared component. namespace selects one of its views: "" is the default, and each map collection is a namespace.
  • key is a source from the rule’s key scopes: command, params, config, context and facts. A key never reads another read.
  • Reads run before the clauses, one rule at a time, in name order. For each read, the owning kernel turns the key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into the view’s fields.
  • Clauses read reads.<name>.present and the view fields, as reads.<name>.<field>, or reads.<name>.value.<member> for a map value’s schema member.

An absent record has only present == false. Reading any other field of it is an evaluation error, so the rule fails closed with ADMISSION_RULE_ERROR and no deny code. Check present first, as tier-limit does below. A read that cannot be made at all, for example because its key is refused, records clause -1.

The limit lives in the registry, so raising it is an approved map write, not a profile change: the next block reads the new value. A rule with reads is never static, so its refusals come at block time, with a finalized, provable receipt. A fifth read does not compile: validate prints “expected a map of at most 4 reads”, and validate --report lists it with the code EXPECTED_OBJECT.

asset-governed-limits.yaml attaches three rules to a token balances component. governed-transfer-limit reads the limit above; tier-limit and lock-up each read the sender’s holder record. Each scenario below is one of the recipe’s fixture blocks.

Rules that read stateIllustrative data

What you're seeing: the asset-governed-limits recipe. A token component’s rules read a governed limit and the sender’s holder record from a registry map. Each scenario is one of the recipe’s fixture blocks.

  1. Submit a transfer Height 4. Member f553… transfers 100 tokens. Its holder tier is 500, and it acquired its holding at height 0. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 100 is within the governed maximum, so the rule holds. The limit lives in the map, so an approved proposal can change it and the next block reads the new value, with no profile change.
    • passes: command.amount <= reads.limits.value.max (100 ≤ 1,000)
  4. Read the holder tier-limit reads holder, keyed by {context: sender} in the holders namespace. Member f553… has a holder record.
    Value view returned for holder
    FieldValue
    reads.holder.presenttrue
    reads.holder.statusACTIVE
    reads.holder.value.maxTransfer500
    reads.holder.value.acquiredHeight0
  5. Check the tier Both clauses hold: the record is active and 100 is within the holder’s 500.
    • passes: reads.holder.present && reads.holder.status == "ACTIVE"
    • passes: command.amount <= reads.holder.value.maxTransfer (100 ≤ 500)
  6. Check the lock-up lock-up declares its own holder read: each rule evaluates its reads before its clauses. The holder must have held the asset for params.blocks (2) blocks. At height 4 that holds.
    • passes: context.height >= reads.holder.value.acquiredHeight + params.blocks (4 ≥ 0 + 2)
  7. Transfer commits All three rules held, so the rule trace is [3, null]. The balances kernel then decides the transfer with its own checks, and the receipt is ACCEPTED.

What if: Over the governed limit

  1. Submit a transfer Height 5. Member f553… transfers 2,000 tokens. The governed maximum is 1,000. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 2,000 is over the governed maximum. The step is refused with TRANSFER_LIMIT_EXCEEDED. Later rules are neither evaluated nor charged.
    • fails: command.amount <= reads.limits.value.max (2,000 ≤ 1,000) TRANSFER_LIMIT_EXCEEDED
  4. Refused The receipt is REJECTED with ADMISSION_RULE_DENIED and the trace [0, [governed-transfer-limit, 0, TRANSFER_LIMIT_EXCEEDED, null]]. No balance changes. The receipt names the rule, clause and deny code, but never the values the rule read.

What if: Over the holder’s tier

  1. Submit a transfer Height 6. Member f553… transfers 700 tokens. That is within 1,000 but over its tier of 500. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 700 is within the governed maximum, so the rule holds. The limit lives in the map, so an approved proposal can change it and the next block reads the new value, with no profile change.
    • passes: command.amount <= reads.limits.value.max (700 ≤ 1,000)
  4. Read the holder tier-limit reads holder, keyed by {context: sender} in the holders namespace. Member f553… has a holder record.
    Value view returned for holder
    FieldValue
    reads.holder.presenttrue
    reads.holder.statusACTIVE
    reads.holder.value.maxTransfer500
    reads.holder.value.acquiredHeight0
  5. Check the tier The record is active, but 700 is over the holder’s tier of 500. The step is refused at clause 1.
    • passes: reads.holder.present && reads.holder.status == "ACTIVE"
    • fails: command.amount <= reads.holder.value.maxTransfer (700 ≤ 500) TIER_LIMIT_EXCEEDED
  6. Refused The receipt is REJECTED with ADMISSION_RULE_DENIED and the trace [1, [tier-limit, 1, TIER_LIMIT_EXCEEDED, null]]. No balance changes. The receipt names the rule, clause and deny code, but never the values the rule read.

What if: Still locked up

  1. Submit a transfer Height 7. Member 12c1… transfers 10 tokens. Its tier is 5,000, but it acquired its holding at height 1,000,000. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 10 is within the governed maximum, so the rule holds. The limit lives in the map, so an approved proposal can change it and the next block reads the new value, with no profile change.
    • passes: command.amount <= reads.limits.value.max (10 ≤ 1,000)
  4. Read the holder tier-limit reads holder, keyed by {context: sender} in the holders namespace. Member 12c1… has a holder record.
    Value view returned for holder
    FieldValue
    reads.holder.presenttrue
    reads.holder.statusACTIVE
    reads.holder.value.maxTransfer5,000
    reads.holder.value.acquiredHeight1,000,000
  5. Check the tier Both clauses hold: the record is active and 10 is within the holder’s 5,000.
    • passes: reads.holder.present && reads.holder.status == "ACTIVE"
    • passes: command.amount <= reads.holder.value.maxTransfer (10 ≤ 5,000)
  6. Check the lock-up lock-up declares its own holder read: each rule evaluates its reads before its clauses. The holder must have held the asset for params.blocks (2) blocks. Acquired at 1,000,000, it is still locked at height 7.
    • fails: context.height >= reads.holder.value.acquiredHeight + params.blocks (7 ≥ 1,000,000 + 2) LOCKED
  7. Refused The receipt is REJECTED with ADMISSION_RULE_DENIED and the trace [2, [lock-up, 0, LOCKED, null]]. No balance changes. The receipt names the rule, clause and deny code, but never the values the rule read.

What if: Not a holder

  1. Submit a transfer Height 8. Member 890e… transfers 10 tokens. It has no holder record. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 10 is within the governed maximum, so the rule holds. The limit lives in the map, so an approved proposal can change it and the next block reads the new value, with no profile change.
    • passes: command.amount <= reads.limits.value.max (10 ≤ 1,000)
  4. Read the holder tier-limit reads holder, keyed by {context: sender} in the holders namespace. Member 890e… has none, so the view holds only present == false.
    Value view returned for holder
    FieldValue
    reads.holder.presentfalse
    every other fieldabsent
  5. Check the tier Clause 0 is false, so the step is refused with TIER_LIMIT_EXCEEDED. The clause checks present before any other field, so the absent record never causes an error. lock-up never runs.
    • fails: reads.holder.present && reads.holder.status == "ACTIVE" TIER_LIMIT_EXCEEDED
    • not reached: command.amount <= reads.holder.value.maxTransfer
  6. Refused The receipt is REJECTED with ADMISSION_RULE_DENIED and the trace [1, [tier-limit, 0, TIER_LIMIT_EXCEEDED, null]]. No balance changes. The receipt names the rule, clause and deny code, but never the values the rule read.

What if: Without the present guard

A hypothetical edit: tier-limit’s first clause reads status without checking present.

  1. Submit a transfer Height 8. Member 890e… transfers 10 tokens. It has no holder record. The token component has three rules attached, all selecting transfer: governed-transfer-limit, tier-limit, then lock-up. Each declares a read, so none is static: they run at block time, in this order.
  2. Read the limit Before its clause runs, governed-transfer-limit reads limits: key "transfer" in the settings namespace of registry. The map’s kernel turns that key into its own state key, the engine reads it through the state this cascade left, and the kernel decodes the record into typed fields.
    Value view returned for limits
    FieldValue
    reads.limits.presenttrue
    reads.limits.statusACTIVE
    reads.limits.value.max1,000
  3. Check the limit 10 is within the governed maximum, so the rule holds. The limit lives in the map, so an approved proposal can change it and the next block reads the new value, with no profile change.
    • passes: command.amount <= reads.limits.value.max (10 ≤ 1,000)
  4. Read the holder tier-limit reads holder, keyed by {context: sender} in the holders namespace. Member 890e… has none, so the view holds only present == false.
    Value view returned for holder
    FieldValue
    reads.holder.presentfalse
    every other fieldabsent
  5. Read an absent field The clause reads.holder.status == "ACTIVE" reads a field the absent record does not have. That is an evaluation error, not a false clause: the step fails closed with ADMISSION_RULE_ERROR at clause 0, and the receipt carries no deny code. Guard every read with present first.
    • fails: reads.holder.status == "ACTIVE" ADMISSION_RULE_ERROR
  • member
  • component
  • rule evaluation
  • state that rules read
  • refused

The refusals in the other scenarios are the ones BindingRecipesIT checks on three nodes: TRANSFER_LIMIT_EXCEEDED over the governed limit, TIER_LIMIT_EXCEEDED over the holder’s tier or without a holder record, and LOCKED for a holder still inside its lock-up. The receipt names the rule, clause and deny code. It never contains the values a rule read.

KernelNamespace (key)Fields
balances"" (account id text, for example {fn: hex, args: [{context: sender}]})balance; an absent account means zero
kv-registry"" (key bytes)owner, value, valueLength; valueText with value-format: utf8
doc-trail"" (entity id)count, headHash
approvals"" (item id)status (PENDING, APPROVED, REJECTED, EXPIRED), required, approverCount, proposer, payloadHash
authenticated-map-componenteach collection id (application key)status (ACTIVE, REVOKED), revision, createdHeight, lastMutationHeight, valueLength, controller; value.<member> for the first 32 scalar top-level members of a schema-typed value

Text and bytes over 4096 bytes are absent, though their length stays readable, and an integer outside int64 fails closed with ADMISSION_RULE_INPUT. Map members are exposed only when their key is a CEL identifier, and a revoked entry exposes no value.<member>, so an unguarded member read of a revoked record fails closed. A catalog exported with appchain bindings catalog lists each instance’s views as ruleValueViews.

The authenticated map also declares a write view: one element per mutation of a command, in command order. Each element has index, collection, key, keyText, op, hasValue, valueLength, expectedRevision and the schema members as value.<member>. After the map has verified the command’s evidence, each element also carries its coverage: direct with the covering actor’s actorId, actorOrganizationId and actorRoles, or approval, or none.

dpp-namespace-isolation.yaml requires every direct write to product-versions or events to be keyed under the covering actor’s organization:

- id: manufacturer-owns-product
deny: FOREIGN_PRODUCT
require:
- expr: 'writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/")))'

writes.all(w, …) and writes.exists(w, …) are the only quantifiers. They visit the writes in index order and stop at the first element that decides: all at the first false element, exists at the first true one. The receipt records where a quantifier stopped as the deciding write. Watch it on a two-write batch:

Stepping through writes.allIllustrative data

What you're seeing: the dpp-namespace-isolation recipe. A direct write to product-versions or events must be keyed under the covering actor’s organization. Watch writes.all visit a batch one write at a time.

  1. One batch, two writes logistics-a signs one map command with two puts to events. One direct authorization covers both. The map’s write view has one element per write, in command order, with the engine’s index.
    Write view before verification: content only
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsswift-logistics/p1/e3PUT––
    1eventsgreen-labs/p1/e4PUT––
  2. Verify, then cover The map verifies the signature and approves. Only that approval supplies coverage: direct, with the actor’s organization and roles. A rule that reads coverage is a fact rule, so it runs only now, never on unverified evidence.
    Write view with coverage
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsswift-logistics/p1/e3PUTdirectswift-logistics
    1eventsgreen-labs/p1/e4PUTdirectswift-logistics
  3. Element 0 writes.all visits index 0. The collection is events, so the second part decides: the coverage is direct and swift-logistics/p1/e3 starts with swift-logistics/. True, so it moves on.
    • passes: index 0: key under swift-logistics/
    Visiting index 0
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsswift-logistics/p1/e3PUTdirectswift-logistics
    1eventsgreen-labs/p1/e4PUTdirectswift-logistics
  4. Element 1 green-labs/p1/e4 does not start with swift-logistics/. The body is false, so writes.all stops here and returns false. The index where it stopped, 1, is the deciding write.
    • passes: index 0: key under swift-logistics/
    • fails: index 1: key under swift-logistics/ FOREIGN_PRODUCT
    Visiting index 1
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsswift-logistics/p1/e3PUTdirectswift-logistics
    1eventsgreen-labs/p1/e4PUTdirectswift-logistics
  5. Refuse the batch The step fails with ADMISSION_RULE_DENIED and the trace [0, [manufacturer-owns-product, 0, FOREIGN_PRODUCT, 1]]. Write 0 is refused too: a command commits whole or not at all. The signature work stays charged, because the rule ran after verification. bindings dry-run reports “at write 1”.
    • fails: writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/"))) FOREIGN_PRODUCT

What if: Every write holds

maker-a of acme-manufacturing writes under its own organization.

  1. One write maker-a signs one put to events at acme-manufacturing/p2/e1, covered by its own direct authorization.
    Write view with coverage
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsacme-manufacturing/p2/e1PUTdirectacme-manufacturing
  2. Run to completion Every element holds, so writes.all visits them all and returns true. A quantifier that runs to the end decides at no particular write, so a failure trace would carry no write index. Both attached rules hold: the trace is [2, null] and the write commits.
    • passes: writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/")))

What if: Revoke without the role

The second attached rule, admin-only-lifecycle-ops, needs the manufacturer role to revoke.

  1. A revoke logistics-a revokes its own events entry swift-logistics/p1/e1. The key is under its organization, so manufacturer-owns-product holds.
    • passes: writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/")))
    Write view with coverage
    indexcollectionkeyTextopcoverageactorOrganizationId
    0eventsswift-logistics/p1/e1REVOKEdirectswift-logistics
  2. Role required admin-only-lifecycle-ops checks writes.all(w, (w.op != "REVOKE" && w.op != "RESTORE") || (w.coverage == "direct" && params.role in w.actorRoles)) with role: manufacturer. logistics-a holds only operator, so element 0 is false.
    • fails: index 0: "manufacturer" in w.actorRoles ADMIN_ROLE_REQUIRED
  • business actor
  • component
  • rule evaluation
  • refused

There is no nesting and no other comprehension. A quantifier body may use the rule’s declared reads.*, but no read can be keyed by an element: there are no per-write reads. A catalog lists the write fields as ruleWriteFields and the coverage fields as ruleWriteCoverageFields.

A rule that reads…Runs inAlso at ingress?
Only command.*, params.*, config.* and write content, in expression clausesAdmission slotYes: it is static
Any reads.*, context.* or lookup clause, and no facts or coverageAdmission slotNo
facts.*, or any coverage field (w.coverage, w.actorId, w.actorOrganizationId, w.actorRoles)Verified-fact slotNo

Coverage exists only in the verified-fact slot, so forged or unverified evidence never reaches a rule that reads it. A refusal there keeps any crypto work the kernel reserved to verify that evidence; an admission-slot refusal happens before any work is reserved. Chapter 7 steps through both slots with feed-slot-rules.yaml, which combines a read with a write-content check in one rule and a coverage check in the other.

LimitMaximum
reads per rule4
writes in one write view128
fields of one decoded read or write element64

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. All of it is charged to both the cascade and the block budget, including for refusals, and none of it is refunded.

Each recipe ships with a governed context.json and one fixture per block under examples/bindings/fixtures/. Run the blocks in order, passing each result to the next with --prior-result:

Terminal window
./yano.sh appchain bindings dry-run examples/bindings/asset-governed-limits.yaml \
--plugins-directory plugins \
--context examples/bindings/fixtures/asset-governed-limits/context.json \
--fixture examples/bindings/fixtures/asset-governed-limits/fixture-1.json \
> block-1.json
./yano.sh appchain bindings dry-run examples/bindings/asset-governed-limits.yaml \
--plugins-directory plugins \
--context examples/bindings/fixtures/asset-governed-limits/context.json \
--fixture examples/bindings/fixtures/asset-governed-limits/fixture-2.json \
--prior-result block-1.json > block-2.json
RecipeShows
asset-governed-limits.yamlA governed limit and a per-holder tier and lock-up, read from a map (blocks 5 to 8 are refusals)
dpp-namespace-isolation.yamlCoverage rules on a batch; block 3 is refused at write 1
feed-slot-rules.yamlA read plus a write-content rule in the admission slot, and a coverage rule in the fact slot

These are deterministic local-demo fixtures; never use their demo keys in production. validate lists each rule’s slot, reads and whether it quantifies over writes, and dry-run names the deciding write of every refusal.

Return to the learning path, or read the reference for the complete grammar.