How it works Examples Docs Pricing Blog WP Blocks Install free

Blog /

Permission buckets: the install dialog that doesn’t lie

When a site owner installs a DesignSetGo App, they see a dialog that lists what the app is asking permission to do. The dialog is the entire trust contract between the app and the site. If we get it wrong, every other safety feature in the runtime is window dressing.

Early drafts of the dialog were honest, technical, and unusable. They listed the manifest’s permissions.read array verbatim, so a real app’s row read something like:

This app requests: posts, pages, user, abilities. Send: email. Commerce endpoints: products, cart. External origins: api.acme.com.

Technically accurate. Totally non-functional as a trust contract: a non-developer reading the row either glazes over or panics, and neither response reflects what the app actually does. We rewrote the dialog before shipping. This is what we landed on, and why.

The problem with raw permissions

The manifest’s permissions.read array can list values like posts, pages, user, abilities, ai, commerce. A permissions.send array can list email. There are also supplementary blocks: commerce.endpoints declares which Woo surfaces the app reaches, runtime.external_origins declares which third-party HTTPS origins it talks to, scheduled.jobs declares whether the app runs on a cron, and so on.

This is the right shape for the runtime. Each entry maps cleanly to bridge methods and capability checks. It is the wrong shape for a person who has thirty seconds and a question that’s really “should I trust this app on my site?”

A well-meaning but technical permission row reads like the v1 dialog above. A site owner sees it and either glazes over or panics. Both reactions are bad. Glazing over means they click Approve without thinking; panicking means they decline an app that’s actually fine. Neither outcome reflects what the app does.

What we ship instead

The install dialog has at most seven rows. Each row is a bucket, and a bucket activates only if the manifest has at least one declaration that matches it. The seven are:

  1. Read content. Active when any of posts, pages, user, abilities, site is in permissions.read. Plain English: the app can read things on your site (posts, pages, user info, what other plugins can do).
  2. Write content. Active when permissions.write is non-empty. (In v1 this stays empty; the bucket is built for v2.)
  3. External services. Active when runtime.external_origins is non-empty. The dialog lists the actual origins, because “this app talks to external services” without naming them is a worse disclosure than no disclosure.
  4. Send messages. Active when permissions.send includes email, or when later versions add SMS or chat. The bucket name is verb-shaped on purpose: site owners understand “send” intuitively.
  5. AI. Active when permissions.read includes ai. Calls go through the site’s configured Connector; the dialog reminds the owner of that, because the AI bucket is the one most likely to make people nervous and the architectural answer (“you control the Connector, you control the spend”) is a real reassurance.
  6. Run automatically. Active when scheduled.jobs is non-empty. This is the bucket that says “this app runs on its own schedule, not just when a visitor opens it.” It’s the difference between a calculator and a job.
  7. Commerce. Active when permissions.read includes commerce or commerce.endpoints is non-empty. The dialog lists which endpoints (products, cart, checkout, orders) so the owner knows what the app is touching.

That’s the whole list. Storage is intentionally not a bucket. Apps almost always need a little local state, none of it leaves the site, and putting “Storage” in the dialog as its own row trains users to ignore the rest. We mention it in a small footer note instead. The general rule we’re applying: every additional row in a permission dialog raises the cost of reading it, and rows that don’t correspond to a real risk just dilute the rows that do.

The bucket-detection rule

A bucket activates when any relevant manifest declaration is present, not just when a matching permissions.read entry shows up. This matters because several capabilities activate via supplementary blocks rather than the read array:

  • Email activates via permissions.send, not permissions.read.
  • Commerce can activate via permissions.read: ["commerce"] or via commerce.endpoints listing specific surfaces.
  • External Services activates via runtime.external_origins.
  • Run Automatically activates via scheduled.jobs.

The plugin’s Bucket::active_for_raw reads the entire raw manifest and unions the activations. The site owner sees a bucket if the app does the thing, regardless of which manifest field declared the thing. There is no manifest shape that hides a capability from the dialog.

If you build apps and you ever notice “huh, the dialog isn’t showing this bucket I expected,” the answer is almost always that the manifest field for that capability is empty or misnamed. Add it; the bucket appears.

This sounds obvious, but the alternative would have been to hand-curate which manifest fields contribute to which buckets, which would have invited a “this version of the plugin doesn’t show that bucket, install it on this site” downgrade attack we didn’t want to defend against. The single-source-of-truth rule (one function, all manifest fields) is robust to malicious manifests trying to be technically precise but functionally misleading.

Why we resisted “expand for details”

Early drafts had a “show advanced” toggle that revealed the raw permission strings under each bucket row. We pulled it. The reason: every UX study of permission dialogs (browser, mobile OS, OAuth) finds the same thing. Users either ignore the dialog entirely or read exactly the top-level summary. An “advanced” toggle reaches a population of zero. It looks like rigor; it functions as decoration. We were lying to ourselves about who would use it.

What replaced it: every bucket row has a deterministic plain-English description that is the same regardless of which manifest fields activated it, plus a “what this means for your site” sentence that’s specific to the bucket. The advanced details that used to live behind a toggle now live in our public permission reference, which any curious reader can pull up. The dialog stays clean.

A separate point worth making: telling users they can “click to see details” tends to inoculate them against being suspicious of the summary. They click the toggle, see a list of words they don’t understand, conclude “this is fine, the developers know what they’re doing,” and click Approve. The toggle becomes a confidence-laundering surface. Removing it forces the summary to be readable on its own, which is a higher bar but the right one.

What we’d revisit

A few honest open questions we know about and haven’t resolved:

  • Should “External Services” show the origin list inline, or behind a click? We currently inline it, because hiding the origins felt like burying a real signal. But a manifest with eight external origins makes the dialog visually loud and pushes other buckets below the fold. The likely v2 move is “first three plus a count,” but it depends on whether the longer-origin-list case is common enough to matter; for now most v1 manifests stay under a handful of origins.
  • Should each bucket have a per-install deny option, or only an all-or-nothing approve? v1 is all-or-nothing because partial grants make for a confusing runtime story (the app’s code asks for a bridge method that’s silently disabled, gets an error code that doesn’t really mean what the user did, and the developer files a bug we can’t reproduce). Per-bucket deny is the right v2 conversation, when the app authoring tools have caught up enough that authors can declare optional permissions distinctly from required ones.
  • What about apps installed via the CLI, not via the admin dialog? A deploy from npx designsetgo apps deploy skips the dialog because the operator is presumably the developer. We log the buckets that would have been shown to a separate audit row, so a non-developer auditor can see what was approved by way of a CLI deploy. We don’t yet show that row in the admin UI, which we should. It’s been on the punch list for two releases.
  • Should the dialog cite an external trust signal (signed manifest, vetted publisher)? v1 has none of that machinery. v2 might add publisher verification, but every prior attempt at “verified plugin authors” in the WP ecosystem has had governance problems we’d rather avoid until we have a better answer than “we say so.”

Why this is load-bearing

The plugin can be technically locked down. The bridge can be permission-gated. The iframe can be sandboxed. None of those matter if the install dialog is incomprehensible, because the user just clicks Approve and goes on with their day, regardless of what’s behind the click.

The bucket model is our attempt to make the click mean something. Seven legible rows, each one tied to a real capability the runtime will actually grant. If the user reads the dialog and approves, the approval reflects what the app does. If the user doesn’t read the dialog (most users won’t), the seven buckets are still few enough that their pre-attentive sense of “this seems reasonable / this seems much” tends to be approximately right. A dialog with three buckets activated feels different than one with seven, and the difference roughly tracks the actual capability difference.

That’s the bar. A trust dialog only works if it doesn’t lie, and only useful if the user can pattern-match on it without reading every word. Seven plain-English rows, one per real capability, no hidden permissions, no toggle for advanced details. Anything more elaborate gets ignored; anything less honest erodes the trust we’re trying to build.

For the field-level mapping between manifest declarations and buckets, see the source: class-permissions.php. For the design rationale that fed into the implementation, see docs/superpowers/specs/2026-05-09-permission-buckets-and-harness-pruning-design.md.

Nealey