Access Control
Storage access control is enforced by the PISA v0.8.0 runtime. Logics compiled for earlier targets are executed by the legacy runtime and are not affected.
On PISA v0.8.0 a logic may not write into an arbitrary actor's state. The runtime checks every actor-state write and defaults to deny — the actor whose state is written has to grant permission first.
endpoint dynamic Transfer(to Identifier, amount U256):
mutate sender_bal <- Token.Sender.balance: // always allowed — the sender's own state
sender_bal -= amount
mutate to_bal <- Token.Actor(to).balance: // denied unless `to` granted access
to_bal += amount
error: actor is not allowed to write into other actor's storage
In Cocolab, the grant command stands in for the actor's wallet: it is how you simulate an actor
authorizing a logic to write their state.
What Is Gated
| Operation | Gated |
|---|---|
mutate Module.Sender.* | No — the sender is the origin of the interaction |
mutate Module.Actor(id).* where id is the sender | No — same actor |
mutate Module.Actor(id).* where id is anyone else | Yes — needs a grant |
mutate Module.Logic.* | No — logic state belongs to the logic |
Every observe (own, other actor, logic, cross-logic) | No — reads are never gated |
Asset engine operations (asset.Transfer(), …) | No — the asset engine has its own rules |
Only the storage-mutate action on actor state is policed.
Commands
| Command | Description |
|---|---|
grant storage_mutate to callers(<c>) origins(<o>) through <logic> [as <participant>] | Grant write access to the participant's state for <logic> |
get storage_mutate [through <logic>] [as <participant>] | Show one grant, or every grant of a participant |
wipe storage_mutate through <logic> [as <participant>] | Revoke a grant |
as <participant>is the actor doing the granting — it is their state that is being opened up. Policies are owned by users, so this must be a registered user, by name or by address. Defaults todefault.sender.through <logic>is the logic whose writes the grant covers. It must already be compiled.<c>is the keywordanyor a comma-separated list of compiled logics;<o>isanyor a list of registered users. Names and 32-byte addresses are both accepted, and anything unknown — or of the wrong kind — is rejected when the grant is issued.
>>> grant storage_mutate to callers(any) origins(alice) through Points as bob
storage_mutate granted through logic Points on bob
>>> get storage_mutate as bob
storage_mutate grants on bob:
through logic Points: caller=any, origin=set{alice}
>>> wipe storage_mutate through Points as bob
storage_mutate access through logic Points on bob is wiped
# any caller, any origin — the widest possible grant
grant storage_mutate to callers(any) origins(any) through Points as bob
# several named logics and users
grant storage_mutate to callers(Points, Router) origins(alice, carol) through PointsB as bob
Callers and Origins
A policy answers one question: may this caller, acting for this origin, write this target's state through this logic?
| Identity | Meaning |
|---|---|
| target | The actor whose state is written — the Actor(id) in the mutate. The target owns the policy (as <target>) and is always a registered user. |
| resource | The logic whose state slot is written (through <logic>). Each logic keeps a separate storage area under every actor. |
| caller | Whoever called the logic that performs the write. |
| origin | The account that signed the interaction — the as <sender> of the whole call. |
The write is allowed when caller == target, or origin == target, or a matching policy exists on
the target for that logic and both constraints accept.
The caller is not always the sender. It is the immediate caller of the logic that performs the write:
| Call shape | caller | origin |
|---|---|---|
alice invokes Points.Credit(bob) directly | alice | alice |
alice invokes Points.CreditVia(...), which calls PointsB.Credit(bob) | Points (the logic) | alice |
Cocolab enforces that split when the grant is issued: callers(...) may only name compiled
logics, origins(...) only registered users.
So a cross-logic write is granted through the writing logic, naming the calling logic:
# alice → Points → PointsB, and PointsB writes bob's state
grant storage_mutate to callers(Points) origins(alice) through PointsB as bob
On a direct call the caller is the signing user, which callers(...) cannot name. Cover that
case with callers(any) and pin the user through the origin instead:
# alice invokes Points.Credit(bob) herself
grant storage_mutate to callers(any) origins(alice) through Points as bob
That also lets any logic acting on alice's behalf through Points write bob's slot. Where that
matters, grant through the logic that actually performs the write and keep its caller set narrow.
Full Session
compile Points from manifest(points.yaml)
compile PointsB from manifest(points.yaml)
register alice
register bob
set default.sender alice
enlist Points.Register() as alice
enlist Points.Register() as bob
# the sender's own state — always allowed
invoke Points.Claim(amount: 10) as alice
# → balance: 10
# someone else's state — denied until bob grants
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → error: actor is not allowed to write into other actor's storage
grant storage_mutate to callers(any) origins(alice) through Points as bob
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → balance: 5
# cross-logic — the grant names the writing logic and the calling logic
grant storage_mutate to callers(Points) origins(alice) through PointsB as bob
invoke Points.CreditVia(remote: PointsB, to: bob, amount: 7) as alice with bob
# → balance: 7
# revoking restores the deny
wipe storage_mutate through Points as bob
invoke Points.Credit(to: bob, amount: 5) as alice with bob
# → error: actor is not allowed to write into other actor's storage
Rules
| Rule | Detail |
|---|---|
| Default deny | No policy means the write fails. There is no permissive fallback. |
| Callers are logics | A user can never be named in callers(...), even though a direct call's caller is a user. Cover that case with callers(any) and a narrow origins(...). |
| Origins are users | A logic signs nothing, so it can never be named in origins(...). |
| Set members are validated | Every name in callers(...)/origins(...) must resolve, and to the right kind. One bad entry rejects the whole grant — a typo is an error at grant time, not a silent deny later. |
any, not all | The wide-open keyword is any. all parses as an ordinary name and fails as an unknown caller. |
| An empty set denies | callers() parses and the grant succeeds, but it matches nobody. |
| Compile the logic first | Policies are keyed by resolved address. grant and get report invalid logic '<name>' when the logic doesn't exist; wipe only reports no matching access policy found, so an unknown logic there looks like an already-clean slate. |
| One policy per pair | Only one policy may exist per (participant, logic). Re-granting fails — wipe it first. |
| Policies are per-participant | A grant on bob says nothing about alice's state. |
| Both constraints must pass | caller and origin are checked; either one failing denies the write. |
| The policy is the only gate | Cocolab resolves the touched participants itself, so the write lands with or without with <actor>. The grant is what authorizes it. |
| Access levels are a second gate | with bob/read or /none refuses the write with cannot access actor dynamically even when the grant is wide open. /write (the default) leaves the policy in charge. |
Every as is validated | grant, both forms of get, and wipe reject a name that isn't a registered user. A typo never comes back as an empty grant list or a missing policy. The as <sender> of deploy/invoke/enlist/create is checked the same way, except that it also accepts a compiled logic. |
| Grants are persisted | Policies are stored in the session snapshot and restored on the next start, after logics and users so that both resolve. |
Errors
| Message | Cause | Fix |
|---|---|---|
actor is not allowed to write into other actor's storage | mutate Module.Actor(id).* with no matching policy | Have id grant access through this logic, with callers(any) and the sender in origins(...) |
error in external call: pisa.Exception [builtin.AccessError] … | The same, but the write happened inside a cross-logic call | Grant through the called logic, naming the calling logic in callers(...) |
invalid logic '<name>' | through <name> before the logic was compiled | compile <name> from manifest(...) first |
invalid user '<name>': not a registered user | as <name> on grant/get/wipe storage_mutate | register <name> first |
invalid user '<name>': access policies are owned by registered users, but '<name>' is a compiled logic | A logic in the as clause of a storage_mutate command | Name the user whose state is written |
invalid user '<name>': not a registered user or a compiled logic | as <name> on deploy/invoke/enlist/create | register <name>, or compile the logic, first |
unknown caller '<name>' | callers(<name>) names neither a compiled logic nor a known address | compile <name> first, or fix the typo |
invalid caller '<name>': callers must be compiled logics, but '<name>' is a registered user | A user in callers(...) | Use callers(any) and name the user in origins(...) |
unknown origin '<name>' | origins(<name>) names neither a registered user nor a known address | register <name> first, or fix the typo |
invalid origin '<name>': origins must be registered users, but '<name>' is a compiled logic | A logic in origins(...) | Name the signing user instead |
access policy already exists; wipe it before granting again | A grant already exists for that (participant, logic) pair | wipe storage_mutate through <logic> as <participant>, then grant |
no matching access policy found | get/wipe for a policy that does not exist | List them with get storage_mutate as <participant> |
cannot access actor dynamically | The participant was listed with <actor>/read or /none — the access level, a separate gate | Use /write (the default) or drop the level |
sender not configured for environment | No as clause and no default sender | set default.sender <user> |
Designing For It
- Prefer sender-writes. An endpoint that only touches
Module.Sender.*never needs a grant. Where the design allows it, let the beneficiary call the logic themselves. - Pull instead of push. Instead of crediting the recipient's actor state during a transfer, park the amount in logic state — which is never gated — and let the recipient claim it with an endpoint that writes only their own actor state.
- Ask for a grant when a push is required. Token ledgers, airdrops and settlement logics
genuinely need to write other actors' state. Treat the grant as part of onboarding, next to
enlist. - Keep the constraints tight.
callers(any) origins(any)lets any interaction through that logic touch the actor's state. Name the specific router logic incallers(...); for a direct write, where the caller set cannot help, pin the signer withorigins(<user>). - Grant the writing logic, not the entry point. In a router → implementation chain, the grant
goes
throughthe logic that performs themutate, with the router incallers(...).
Testing Both Paths
The deny is the branch that regresses silently, so script both. In coco.nut:
[lab.scripts]
test-access = [
"compile Points from manifest(points.yaml)",
"register alice",
"register bob",
"set default.sender alice",
"enlist Points.Register() as alice",
"enlist Points.Register() as bob",
# denied before the grant
"invoke Points.Credit(to: bob, amount: 5) as alice with bob",
# allowed after it
"grant storage_mutate to callers(any) origins(alice) through Points as bob",
"invoke Points.Credit(to: bob, amount: 5) as alice with bob",
"invoke Points.BalanceOf(account: bob)",
]
coco lab run test-access --new-session
See Running Scripts for the wider scripting setup.