Permissions
An App gets no access by default. Everything it may reach — its own tables, another app's capabilities, an external host — is one entry in manifest.permissions. That array is the app's access register: a single declarative list, read by the platform to enforce access at runtime and read by the merchant at install time as the consent screen.
There is no policies block. If you have seen one in an older example or a generated file, it is wrong — no shipped app has one, and the manifest schema rejects the key.
"permissions": [
{ "entity": "device_serials", "access": ["read", "create", "update"] },
{ "identity": "users", "access": ["read"] },
{ "capability": "orders.get", "compatible": "^1.0" },
{ "outbound": "api.acme-service.example", "paths": ["/v2/*"] }
]
Deny by default
There is nothing to switch off, because nothing is on. An app with no permissions array can read none of its own tables, call no other app, and reach no external host — its generated data client does not even have the methods.
That is worth understanding as a design property rather than a chore. The register is the complete, machine-readable answer to "what can this app touch?", and it is complete because there is no other way in.
How an entry is shaped
Each entry carries exactly one resource key. That key both names the kind of grant and carries its target. An entry with two resource keys, or with a key that is not one of the nine, fails validation.
| Resource key | Grants access to | Extra fields | Status |
|---|---|---|---|
entity | One of the app's own entities | access: [read, create, update, delete] | Shipped |
identity | The tenant's users, teams or sessions | access: [read, write] | Shipped |
capability | Another app's capability, through the gateway | compatible (optional) | Shipped |
outbound | An external HTTP host | paths (optional) | Declared; enforcement being built |
storage | A file bucket | access: [read, write] | Not yet proven |
ai | A model or model family | — | Not yet proven |
mail | A sending domain | — | Not yet proven |
secret | A secret path | access: [read] | Not yet proven |
inbound | An inbound webhook source | — | Not yet proven |
The first three are shipped and exercised by every production app. outbound is declared and honoured as intent, with enforcement still being built. The last five are declarable today but no shipped app uses them — treat them as unproven and expect the details to firm up.
One page per kind:
- Entity access —
entity. - Tenant identity —
identity. - Calling another app —
capability. - Outbound HTTP —
outbound. - Not yet proven —
storage,ai,mail,secret,inbound.
The register is read by a human
At install time the merchant sees this list rendered in plain language, and decides whether to say yes. That is the single most useful thing to know about the file: it is not a configuration detail, it is the text of an agreement.
Two consequences:
An entry your app never exercises makes the decision harder for no benefit. It also widens your blast radius for nothing. Claim the least you can.
A reason written for a human beats a permission written for a machine. billing.json's listing.scopes is where you explain why — "link a registered serial to the order that sold it", not "requires read on orders". See Consent.
Grant per operation, not per app
["read", "create"] on a ledger entity is a real safety property: the generated client has no update and no delete, so a bug cannot erase history. ["read", "create", "update", "delete"] everywhere throws that away and tells the merchant nothing.
The asymmetry is the useful part. Look at what each entity is for before you grant on it:
| The entity is | Grant |
|---|---|
| An append-only log or ledger | read, create |
| Reference data your app maintains | read, create, update |
| Rows an operator genuinely deletes | all four |
| Something you only ever read from | read |
Consent drift
Review the register on every upgrade. A new grant is a new thing the merchant is consenting to, and adding one is not a patch-level change in spirit, whatever the version number says.
The failure mode is gradual: a grant added for a feature that shipped, a host added for a vendor you no longer use, an identity grant left behind after the code that needed it was removed. Six versions later the consent screen describes an app that does not exist, and a merchant reviewing it cannot tell which entries are load-bearing.
So treat the register the way you treat a dependency list:
- When you add a grant, say so in the release notes. A merchant who is asked to re-consent deserves to know what changed.
- When you remove the code, remove the grant. In the same version.
- Before a major release, read the whole array and justify every line. If you cannot say which route or handler needs an entry, delete it and see what breaks in staging.
See Consent for what the merchant actually sees, and when they are asked again.
What permissions is not
Three neighbouring things are easy to confuse with this one, and all three point the other way.
provides_permissions declares what a person may do — permission keys this app defines and the gateway enforces against the acting principal. permissions is app-to-resource; provides_permissions is person-to-operation. See Person permissions.
provides_scopes declares a scope dimension this app supplies to the platform, so other apps can slice their rows by it. See Scoping.
A Cockpit view's own permissions array — ["device_serials.read"], ["device_serials.write"] — controls whether a Cockpit user sees the view. That is a UI gate on the operator, not a grant to your app. See How the Cockpit renders.
Next steps
- Entity access — the grant you will write first.
- Consent — the merchant's side of this file.
- Manifest reference — the rest of
manifest.json.