Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferencePolicy

APIsPolicy

Permissions on actions

On this page

Most actions need no permission of their own: an action takes its permission from the entities it touches. Root policy.jadpo lists the few that can't, under actions::

  • an action that touches no entity, such as an operator's health report, which is otherwise refused;
  • an action that needs different people from its entity's grants, such as handing a todo to someone else, or creating a company together with its first owner's membership.

Each line names an action by its domain and name, and lists who may run it, with the same kinds of entry as a grant:

policy.jadpo
// policy.jadpopolicy {    tenant: Company    actions: {        todos.transfer_todo: [Todo.owner_id]        accounts.create_company: [Access.authenticated]        monitoring.health_report: [StaffRole.operator]    }}

The domain is part of the name, as in a call from another domain. A bare name that is one domain's action, transfer_todo:, is policy.binding_invalid with the edit that writes todos.transfer_todo:, and the use line that brought the bare name in isn't also reported unused; jadpo fmt then writes use todos in its place.

Todo.owner_id means the current owner of the todo the action changes. For that action, the listed people replace the entity grants on the creates, updates, deletes and transitions in its own body. Its reads, and the actions it calls, keep their own entities' grants, with the same caller. Field restrictions and tenancy still apply.

Calling an integration, an outside service such as a payment provider, needs nothing extra: permission to run the action covers it.

Lines that depend on a record

A user field such as Todo.owner_id, or a role held in a record such as CompanyRole.owner, is checked on the records the action itself creates, changes or deletes. On an action that changes none of them, such as a report, it lets nobody in. The action is then checked as it starts against the line's other entries: anyone signed in, or a role held across the whole application. With none left, it refuses every caller, so its route is policy.effect_ungranted.

A call the listing doesn't cover

Because a called action keeps its own grants, a listed action can call one that refuses some of the people its line lets in. Say lending.borrow_book is listed for Access.authenticated and calls lend_book, whose Book update only StaffRole.admin holds. A customer would get 403 from lend_book. jadpo check reports this at the call as policy.call_ungranted, naming the people the line lets in that the called action refuses. Fix it by making the write in the listed action's own body, or by listing the called action for exactly those people. Listing it for everyone widens it on every path that reaches it. It is an error you can decline, for a call that only some callers are meant to pass.

Listing a query

A query is listed under actions: like an action, when it needs fewer people than its entity's read:. Each listed query's line may only hold entries that depend on no record: Access.authenticated, or a role held across the whole application. Anything else is policy.binding_invalid. The line is checked as the query starts, on every request, so removing someone's role takes effect on their next request. Its records are still filtered by the entity's read:, and its output fields are proved against the line's people, not everyone the entity's read: lets in. So one entity can give staff a query with stock and sale fields restricted to StaffRole.admin, beside a catalogue query for every signed-in customer.

A route anyone may call can't run a listed query, since its line never names Access.public (policy.public_grant_missing). A schedule, subscriber or batch never passes one either: the system is let in only by a line naming Access.system, which a query's can't. Reaching one is policy.system_grant_missing.

Here every signed-in customer reads a book, and only a bookseller where it is shelved:

example
use shop/types { StaffRole }use shoppolicy {    actions: {        shop.stock: [StaffRole.bookseller]    }}
example
use shop/types { StaffRole, Title }/// A book every signed-in customer may read, with where it is shelved,/// which only a bookseller may.entity Book {    id: Uuid    title: Title    shelf_note: Title {        policy: {            read: [StaffRole.bookseller]        }    }    policy: {        create: [StaffRole.bookseller]        read: [Access.authenticated]    }}

Unlisted, stock would be policy.output_field_unproved, since anyone signed in may call it and not all of them may read shelf_note. Listed, it is proved against StaffRole.bookseller alone:

example
use shop/entities { Book }use shop/types { StockBook }/// Every book with where it is shelved, for staff.query stock() -> List<StockBook> {    from: Book    order: [Book.title asc]    limit: 50}

A customer is refused the stock with 403 not_permitted, and a member of staff whose role is removed is refused on their next request:

example
use shop/entities { Book, Member, Staff }use shop/types { CatalogueBook, StockBook }use shop/actions { add_book, appoint, register, stand_down }test "staff see where a book is shelved, and customers only the catalogue" {    var ann: Member = attempt register("ann")    var cyd: Member = attempt register("cyd")    var staff: Staff = attempt appoint(ann.id)    assert staff.member_id == ann.id    var dune: Book = add_book("Dune", "Back room")    var stock: Response = request {        method: HttpMethod.get        path: "/books/stock"        as: Principal.user(ann)    }    var shelved: StockBook = StockBook {        id: dune.id        title: "Dune"        shelf_note: "Back room"    }    assert stock.output == [shelved]    var refused: Response = request {        method: HttpMethod.get        path: "/books/stock"        as: Principal.user(cyd)    }    assert refused.status == 403    assert refused.code == "not_permitted"    var browsed: Response = request {        method: HttpMethod.get        path: "/books"        as: Principal.user(cyd)    }    var on_shelf: CatalogueBook = CatalogueBook {        id: dune.id        title: "Dune"    }    assert browsed.output == [on_shelf]}test "a role removed is refused on the next request" {    var ann: Member = attempt register("ann")    var staff: Staff = attempt appoint(ann.id)    var dune: Book = add_book("Dune", "Back room")    var before: Response = request {        method: HttpMethod.get        path: "/books/stock"        as: Principal.user(ann)    }    assert before.status == 200    attempt stand_down(staff.id)    var after: Response = request {        method: HttpMethod.get        path: "/books/stock"        as: Principal.user(ann)    }    assert after.status == 403    assert dune.title == "Dune"}

Markdown for agents: /docs/action-permissions.md.Built from Jadpo main at 7b64d64.