PermDock
Decisions

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/permit requires a resource object for every action, so create needs a placeholder.
  • permix types dataType per 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 where clause is never evaluated against undefined.
  • filter and where are only available on instance actions, list is a collection action; the distinction mirrors RLS SELECT ... USING versus 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 data loader in protect and registerTool.
  • 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 create in collection but also defines check (RLS WITH CHECK, the next row) for create. Either create accepts an optional boundary-validated body when a check condition exists, or apps declare create as 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() and collection() 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.

On this page