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
presentguard, howwrites.allandwrites.existswalk 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.jsonshipped 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.
Read another component’s state
Section titled “Read another component’s state”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'componentnames a declared component.namespaceselects one of its views:""is the default, and each map collection is a namespace.keyis a source from the rule’s key scopes:command,params,config,contextandfacts. 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>.presentand the view fields, asreads.<name>.<field>, orreads.<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.
Rules that read state, step by step
Section titled “Rules that read state, step by step”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.
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.
- Submit a transfer Height 4. Member
f553…transfers 100 tokens. Its holder tier is 500, and it acquired its holding at height 0. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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)
- passes:
- Read the holder
tier-limitreadsholder, keyed by{context: sender}in theholdersnamespace. Memberf553…has a holder record.Value view returned for holderField Value reads.holder.presenttruereads.holder.statusACTIVEreads.holder.value.maxTransfer500 reads.holder.value.acquiredHeight0 - 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)
- passes:
- Check the lock-up
lock-updeclares its ownholderread: each rule evaluates its reads before its clauses. The holder must have held the asset forparams.blocks(2) blocks. At height 4 that holds.- passes:
context.height >= reads.holder.value.acquiredHeight + params.blocks(4 ≥ 0 + 2)
- passes:
- 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
- Submit a transfer Height 5. Member
f553…transfers 2,000 tokens. The governed maximum is 1,000. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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
- fails:
- Refused The receipt is REJECTED with
ADMISSION_RULE_DENIEDand 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
- Submit a transfer Height 6. Member
f553…transfers 700 tokens. That is within 1,000 but over its tier of 500. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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)
- passes:
- Read the holder
tier-limitreadsholder, keyed by{context: sender}in theholdersnamespace. Memberf553…has a holder record.Value view returned for holderField Value reads.holder.presenttruereads.holder.statusACTIVEreads.holder.value.maxTransfer500 reads.holder.value.acquiredHeight0 - 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
- passes:
- Refused The receipt is REJECTED with
ADMISSION_RULE_DENIEDand 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
- 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. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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)
- passes:
- Read the holder
tier-limitreadsholder, keyed by{context: sender}in theholdersnamespace. Member12c1…has a holder record.Value view returned for holderField Value reads.holder.presenttruereads.holder.statusACTIVEreads.holder.value.maxTransfer5,000 reads.holder.value.acquiredHeight1,000,000 - 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)
- passes:
- Check the lock-up
lock-updeclares its ownholderread: each rule evaluates its reads before its clauses. The holder must have held the asset forparams.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
- fails:
- Refused The receipt is REJECTED with
ADMISSION_RULE_DENIEDand 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
- Submit a transfer Height 8. Member
890e…transfers 10 tokens. It has no holder record. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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)
- passes:
- Read the holder
tier-limitreadsholder, keyed by{context: sender}in theholdersnamespace. Member890e…has none, so the view holds onlypresent == false.Value view returned for holderField Value reads.holder.presentfalseevery other field absent - Check the tier Clause 0 is false, so the step is refused with
TIER_LIMIT_EXCEEDED. The clause checkspresentbefore any other field, so the absent record never causes an error.lock-upnever runs.- fails:
reads.holder.present && reads.holder.status == "ACTIVE"TIER_LIMIT_EXCEEDED - not reached:
command.amount <= reads.holder.value.maxTransfer
- fails:
- Refused The receipt is REJECTED with
ADMISSION_RULE_DENIEDand 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.
- Submit a transfer Height 8. Member
890e…transfers 10 tokens. It has no holder record. Thetokencomponent has three rules attached, all selectingtransfer:governed-transfer-limit,tier-limit, thenlock-up. Each declares a read, so none is static: they run at block time, in this order. - Read the limit Before its clause runs,
governed-transfer-limitreadslimits: key"transfer"in thesettingsnamespace ofregistry. 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 limitsField Value reads.limits.presenttruereads.limits.statusACTIVEreads.limits.value.max1,000 - 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)
- passes:
- Read the holder
tier-limitreadsholder, keyed by{context: sender}in theholdersnamespace. Member890e…has none, so the view holds onlypresent == false.Value view returned for holderField Value reads.holder.presentfalseevery other field absent - 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 withADMISSION_RULE_ERRORat clause 0, and the receipt carries no deny code. Guard every read withpresentfirst.- fails:
reads.holder.status == "ACTIVE"ADMISSION_RULE_ERROR
- fails:
- 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.
The stock value views
Section titled “The stock value views”| Kernel | Namespace (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-component | each 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.
Judge every write of a batch
Section titled “Judge every write of a batch”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:
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.
- One batch, two writes
logistics-asigns one map command with two puts toevents. One direct authorization covers both. The map’s write view has one element per write, in command order, with the engine’sindex.Write view before verification: content only indexcollectionkeyTextopcoverageactorOrganizationId0 eventsswift-logistics/p1/e3PUT– – 1 eventsgreen-labs/p1/e4PUT– – - 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 indexcollectionkeyTextopcoverageactorOrganizationId0 eventsswift-logistics/p1/e3PUTdirectswift-logistics1 eventsgreen-labs/p1/e4PUTdirectswift-logistics - Element 0
writes.allvisits index 0. The collection isevents, so the second part decides: the coverage isdirectandswift-logistics/p1/e3starts withswift-logistics/. True, so it moves on.- passes: index 0: key under
swift-logistics/
Visiting index 0 indexcollectionkeyTextopcoverageactorOrganizationId0 eventsswift-logistics/p1/e3PUTdirectswift-logistics1 eventsgreen-labs/p1/e4PUTdirectswift-logistics - passes: index 0: key under
- Element 1
green-labs/p1/e4does not start withswift-logistics/. The body is false, sowrites.allstops 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 indexcollectionkeyTextopcoverageactorOrganizationId0 eventsswift-logistics/p1/e3PUTdirectswift-logistics1 eventsgreen-labs/p1/e4PUTdirectswift-logistics - passes: index 0: key under
- Refuse the batch The step fails with
ADMISSION_RULE_DENIEDand 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-runreports “at write 1”.- fails:
writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/")))FOREIGN_PRODUCT
- fails:
What if: Every write holds
maker-a of acme-manufacturing writes under its own organization.
- One write
maker-asigns one put toeventsatacme-manufacturing/p2/e1, covered by its own direct authorization.Write view with coverage indexcollectionkeyTextopcoverageactorOrganizationId0 eventsacme-manufacturing/p2/e1PUTdirectacme-manufacturing - Run to completion Every element holds, so
writes.allvisits 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 + "/")))
- passes:
What if: Revoke without the role
The second attached rule, admin-only-lifecycle-ops, needs the manufacturer role to revoke.
- A revoke
logistics-arevokes its owneventsentryswift-logistics/p1/e1. The key is under its organization, somanufacturer-owns-productholds.- passes:
writes.all(w, (w.collection != "product-versions" && w.collection != "events") || (w.coverage == "direct" && startsWith(w.keyText, w.actorOrganizationId + "/")))
Write view with coverage indexcollectionkeyTextopcoverageactorOrganizationId0 eventsswift-logistics/p1/e1REVOKEdirectswift-logistics - passes:
- Role required
admin-only-lifecycle-opscheckswrites.all(w, (w.op != "REVOKE" && w.op != "RESTORE") || (w.coverage == "direct" && params.role in w.actorRoles))withrole: manufacturer.logistics-aholds onlyoperator, so element 0 is false.- fails: index 0:
"manufacturer" in w.actorRolesADMIN_ROLE_REQUIRED
- fails: index 0:
- 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.
Which slot a typed-view rule runs in
Section titled “Which slot a typed-view rule runs in”| A rule that reads… | Runs in | Also at ingress? |
|---|---|---|
Only command.*, params.*, config.* and write content, in expression clauses | Admission slot | Yes: it is static |
Any reads.*, context.* or lookup clause, and no facts or coverage | Admission slot | No |
facts.*, or any coverage field (w.coverage, w.actorId, w.actorOrganizationId, w.actorRoles) | Verified-fact slot | No |
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.
Limits
Section titled “Limits”| Limit | Maximum |
|---|---|
| reads per rule | 4 |
| writes in one write view | 128 |
| fields of one decoded read or write element | 64 |
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.
Try the recipes
Section titled “Try the recipes”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:
./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| Recipe | Shows |
|---|---|
asset-governed-limits.yaml | A governed limit and a per-holder tier and lock-up, read from a map (blocks 5 to 8 are refusals) |
dpp-namespace-isolation.yaml | Coverage rules on a batch; block 3 is refused at write 1 |
feed-slot-rules.yaml | A 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.