0004: Instance actions vs collection actions
Why each resource declares actions that take an instance and collection actions that do not, with the arity encoded in the type.
Status
Accepted, September 2026.
Context
Some checks are about a specific row (update this post), others are about the type (create a post, list posts). Libraries handle the difference in three ways, all with problems:
- CASL accepts either a class, a string type name or an instance as the subject; the call site decides, and forgetting the instance silently checks the type-level rule.
@zap-studio/permitrequires a resource object for every action, socreateneeds a placeholder.- permix types
dataTypeper resource but every action of that resource accepts the optional data, so the compiler cannot tell you that an ownership rule was evaluated without a row.
The ambiguity matters more for agents than for humans: an MCP tool handler that calls can(permissions.post.update) without loading the post will pass a rule that was supposed to check ownership, and nothing flags it.
Decision
Each resource() declares two lists:
post: resource(Post, {
id: 'id',
actions: ['read', 'update', 'delete', 'publish'], // instance-level: can(permissions.post.update, post)
collection: ['create', 'list'], // type-level: can(permissions.post.create)
})The arity is part of the permission's type. permdock.can(permissions.post.update) without a post is a compile error; permdock.can(permissions.post.create, post) is one too. Schema-less resources may declare only collection actions. The same rule applies to decide, assert, usePermission, protect, registerTool (data is required for instance permissions) and allow (a where condition is only valid on an instance action).
Consequences
- Ownership and tenant conditions can only be attached to actions that receive a row, so a
whereclause is never evaluated againstundefined. filterandwhereare only available on instance actions,listis a collection action; the distinction mirrors RLSSELECT ... USINGversus the right to run the query at all.- Catalog, OpenAPI and MCP output can state whether a permission needs a resource id, which drives the
dataloader inprotectandregisterTool. - Definition authors must decide up front which bucket an action is in; renaming an action from one bucket to the other is a type-visible change in every call site.
- Open question: the plan puts
createincollectionbut also definescheck(RLSWITH CHECK, the next row) forcreate. Eithercreateaccepts an optional boundary-validated body when acheckcondition exists, or apps declarecreateas an instance action when they need it. This is decided during Phase 1 core work and tracked on the roadmap.
Alternatives considered
- Optional data on every action. Rejected: it is the CASL and permix ambiguity described above.
- Separate
resource()andcollection()definitions. Rejected as verbose; the two lists on one node keep the resource schema and id in one place. - Infer arity from whether the grant has a condition. Rejected because arity would depend on the policy, which is server-only, while the definition is shared with clients.
- Cerbos and Oso style "resource kind plus optional attributes". The well-known pattern the split is modelled on, but as strings; PermDock encodes it in the type.
Related
0003: Reference-based permissions
Why permissions are typed objects like permissions.post.update instead of string keys or template-literal unions.
0005: Naming convention
The brand is the noun; PermDock, permdock and createPermDock are used everywhere, import paths name the framework, and a small set of words is reserved.