Skip to content

Permissions & FLS

Access in CAOS is composed from additive permission sets grouped into permission-set groups — there is no profile plane and no baseline permission bundle. Object and field grants are boolean rows; a user’s effective permission is the union of every grant they hold, computed as a bool_or aggregate. Field-level security is authoritative at read time and enforced inside the query (Postgres Row-Level Security plus a column-projection layer), so there is one enforcement path and no privileged bypass short of the service role. Permissions are not business logic — they belong to neither the pure nor the effectful tier — but they are the access-control envelope the kernel evaluates around every read and write.

Authorization splits into orthogonal planes, exactly as Salesforce splits it. To touch a specific value, a user needs a grant on both the object/field plane and the record-access plane; system permissions are a third, separate axis that gates platform surfaces (Setup, deploy, AI…) rather than record data.

Plane Governs Carried by
Object + field permissions What may be done to a type of thing — object CRUD, field Read/Edit Permission sets → permission-set groups
Record access Which individual rows are visible/editable Ownership, an org-wide default floor, the role hierarchy, then additive grants — see Roles & record sharing
System permissions Platform capabilities not tied to any object — Setup, customization, deploy, AI, diagnostics Permission sets → permission-set groups (see System permissions)

Where grants live. Every object permission is a row keyed by (source, object) with boolean flags; every field permission is a row keyed by (source, field) with read/edit booleans. A “source” is a permission set — never a profile. This maps 1:1 onto SQL: the union model is a bool_or() aggregate over the join user → assigned sets → grant rows.

How grants combine. Access is a most-permissive union. If any set a user holds grants Edit on a given object, the user has Edit. The single subtraction in the model is a group-scoped muting set (below): it removes only permissions delivered by its own group, so a grant carried by a directly-assigned set or by any other group survives it. There is no baseline “deny” and no cross-group subtraction. Assigning a standalone set can therefore only ever add access — the property that keeps the model auditable — while muting is confined to the one group that declares it.

FLS enforced in the query. Rather than trusting each caller to strip inaccessible fields, CAOS enforces field access on the read path itself:

  • Row visibility — RLS USING policies decide which rows a user may see, evaluated against the record-access plane.
  • Column visibility — because RLS is row-level only, per-user column masking is done by a SECURITY BARRIER view / projection layer that nulls (or omits) columns the user lacks Read on. There is no “system mode” that skips this; the only bypass is the service role.

Effective-access resolver. The union bool_or is exposed as a first-class, queryable resolver — effective_object_perm(user, object, r,c,e,d,…) and effective_field_perm(user, field, read, edit) — so admin surfaces read the resolved access (None / Read / Edit) directly rather than reconstructing it from N sources or showing an ambiguous “Default.” The resolver is a plain view computed live from the grant rows, not a materialized table. A materialized form would reintroduce sharing-recalculation — invalidate-and-recompute on every grant change — so it is adopted only if profiling proves the live union too costly at the target catalog size, and only together with the invalidation machinery it demands.

The authoring surface is the permission set and the permission-set group. A permission set is a grant-only bundle; a group assembles sets into a role’s worth of access. There is no profile to author.

Permission set body vocabulary:

Key Meaning
objectPermissions[] Rows of { object, read, create, edit, delete, viewAll, modifyAll }. viewAll/modifyAll are object-scoped super-grants that bypass the record-access plane (not FLS). Dependency: create/edit/delete imply read.
fieldPermissions[] Rows of { field, read, edit }. Dependency: edit implies read (cannot edit an unreadable field).
userPermissions[] System-permission grants — platform capabilities not tied to an object (see System permissions). Carries both tiers: a bare domain.verb must be a member of the closed platform catalog, and a namespaced <ns>__domain.verb must resolve to a declared systemPermission component.
appVisibility[] / tabVisibility[] Which apps/tabs the set exposes. The apps and tabs themselves are surfaces delivered by installable packages; only the visibility gate here is kernel.
appPermissions[] Rows of { app, administer } — “does this person administer this app”, distinct from appVisibility (“is this app shown to them”) and from the global setup.access system permission (“do they reach Setup at all”). Sits on the same per-instance row plane as objectPermissions, keyed by app the way an object row is keyed by object. Not muteable, on the same precedent as appVisibility/tabVisibility.
sessionRequired If true, the set’s grants apply only within an active session (analog of Salesforce hasActivationRequired) rather than as standing access.

Permission-set group body:

Key Meaning
sets[] The component permission sets; the group’s effective access is their union.
mute (optional) a single group-scoped muting set — subtractive objectPermissions/fieldPermissions/userPermissions rows evaluated in the same union query. A package-declared permission is muted here like any other, so a permission an administrator can grant is one they can also take back. It removes only permissions delivered by this group’s sets[]; one muting set per group, matching Salesforce. Muting cascades along dependencies (muting Read implies Create/Edit/Delete/View All/Modify All also mute).

Worked example — a billing group assembled from two additive sets, with field access on a sensitive column granted by exactly one of them:

// Permission set: read/write the Invoice object, but NOT margin
{
"key": "ps_invoice_edit",
"label": "Invoice — Edit",
"type": "permission_set",
"body": {
"objectPermissions": [
{ "object": "invoice", "read": true, "create": true, "edit": true,
"delete": false, "viewAll": false, "modifyAll": false }
],
"fieldPermissions": [
{ "field": "invoice.total_price", "read": true, "edit": true }
// note: invoice.margin_pct is absent → effective None here
]
}
}
// Permission set: adds visibility to the margin column, nothing else
{
"key": "ps_invoice_margin",
"label": "Invoice — Margin Visibility",
"type": "permission_set",
"body": {
"fieldPermissions": [
{ "field": "invoice.margin_pct", "read": true, "edit": false }
]
}
}
// Group: a lead billing specialist gets both → union grants edit on the invoice
// AND read on margin. A plain billing specialist holds only ps_invoice_edit → margin resolves None.
{
"key": "psg_lead_billing",
"label": "Lead Billing Specialist",
"type": "permission_set_group",
"body": { "sets": ["ps_invoice_edit", "ps_invoice_margin"] }
}

A user assigned psg_lead_billing resolves invoice.margin_pct → Read and invoice.total_price → Edit; a user assigned only ps_invoice_edit resolves invoice.margin_pct → None, and the column is nulled on their read path. No profile participates in either outcome.

Object and field grants answer “what may this user do to a record.” They do not answer “may this user open Setup, run a deploy, purge metadata, or ask the AI to build an object.” Those are system permissions — platform capabilities attached to no object — and they are the third grant kind a permission set carries, in userPermissions[], unioned exactly like the other two.

Salesforce exposes these as a sprawling flat list — well over a hundred, the exact set edition-dependent and never published as a single figure — grouped only loosely under “System Permissions” and “App Permissions,” with catch-alls like Customize Application that hide far too much power behind one checkbox (User Permissions). CAOS takes the opposite stance: a curated, fully enumerated catalog organized into capability domains, coarse by default and granular only where a real trust boundary exists. No permission is invented ahead of a feature; one is added the day a surface exists that must be gated. The catalog below is that enumeration — every permission the kernel checks, in one place, with no edition-dependent variance.

Every system permission is spelled domain.verb.

A domain is the capability area that owns the surface being gated — files, jobs, deploy, diagnostics. It is a singular lowercase noun, and it is never a business object. invoice.approve is not a system permission and cannot become one: anything scoped to a type of record belongs on the object plane, where it composes with record access and field-level security instead of side-stepping them. The domain prefix is what keeps the catalog navigable — “what does the release team hold” is a scan of deploy.% and env.%, not a read of the whole list.

A verb is what the holder may do in that domain. The catalog draws first from a small core vocabulary and reaches past it only where no core verb names the actual trust boundary:

Core verb Means
access Reach a surface at all. The front door, and read-only.
view Read the surface’s content.
author Create or change metadata in the domain. Always lands through deploy validate.
manage Change runtime configuration or state that is not metadata — assignments, credentials, suppressions, queue control.
run Execute an operation.
promote Move something to production.
use Consume a capability.
approve Sign off on another actor’s proposal.
purge Destroy irreversibly. Never a synonym for delete.

A verb may carry a qualifier — verb_qualifier — where one verb has two genuinely different trust levels inside a domain: view_all, modify_all, manage_credentials, impersonate_write, send_test. A qualifier narrows or widens a verb; it never smuggles in a second domain.

Three spellings are ruled out, because each is how a catalog stops being readable. No wildcards — files.* is not a grant and cannot be assigned. No levels — there is no admin, full, or super verb, because a level is a promise to hide whatever gets added to it later. No catch-alls — metadata.author, logic.author, and automation.author are three permissions rather than one Customize Application precisely because “can build schema,” “can write pure logic,” and “can write effectful code” are three separate trust decisions, and the seam between them is the one Salesforce’s single checkbox erases.

Each row is a boolean grant, unioned with bool_or like every other grant. The does not grant column is load-bearing: a permission’s boundary is as much a part of its definition as its capability, and most access accidents are a reader assuming the boundary is somewhere else. The prerequisite column is a precondition on assignment, not an implication — see Implication and dependency.

setup — the front door.

Permission Grants Does not grant Prerequisite
setup.access Reaching the Setup area, and reading every builder surface inside it — Object Manager, the permission-set catalog, deploy history, the job board. Any change to anything. Setup with no other grant is read-only everywhere: every action renders disabled with the grant it needs named. —

metadata — the component catalog.

Permission Grants Does not grant Prerequisite
metadata.author Creating and changing components: objects, fields, layouts, record pages, list views, tabs, apps, lifecycles, notification types, job definitions, session policies. Changes land through deploy validate. Applying a deploy — that is deploy.run. Nor purging a retired component, nor authoring pure or effectful logic. —
metadata.translate Deploying locale slices only: translated labels, help text, picklist labels, message templates. Any change to a non-locale component. A translator can rewrite every French string in the org and cannot touch a formula, a permission set, or an English label. —
metadata.purge Hard-purging an already-retired component ahead of its safe-delete window. Retiring a component in the first place, and anything to do with record data. metadata.author

logic — the pure tier.

Permission Grants Does not grant Prerequisite
logic.author Authoring formula fields, roll-up summaries, validation rules, and calc functions. Anything effectful. A pure-tier author cannot write a record-triggered controller, and the compiler enforces it independently of the grant. —

automation — the effectful tier.

Permission Grants Does not grant Prerequisite
automation.author Authoring automation controllers and the object automation board. Switching automation off. —
automation.bypass Setting a scoped, reasoned, expiring bypass on an object or one slot of its board. Changing the rules themselves. Deliberately not a prerequisite chain with automation.author: writing the rules an object obeys and suspending them for everyone are different trust decisions. —

config — configuration data.

Permission Grants Does not grant Prerequisite
config.author Reaching the configuration-data surface and editing entries and flags in the sets a per-set grant admits. Defining or changing a configuration set — its fields, types, scope chain, effective-dating — which is metadata.author. “May change the freight rate” is not “may change the shape of pricing.” —

validation — one generated family. The permission domain validation. and the error class validation share a word and nothing else: validation.bypass_line_price is a grant, validation.required_field is an error code. Permissions are assigned; codes are returned.

Permission Grants Does not grant Prerequisite
validation.bypass_<rule> Skipping exactly one validation rule that declares itself bypassable. One permission is minted per such rule; the rule ends && !can("validation.bypass_line_price"). Every other rule. There is no permission that skips validation in general, and none that skips a rule which did not opt in. —

deploy — the release pipeline.

Permission Grants Does not grant Prerequisite
deploy.run Validating and applying a deploy in the current environment. Promoting to production. —
deploy.promote Promoting an already-validated deploy to production. Authoring what is in it, or applying it in any other environment. Deliberately independent of deploy.run — a release manager promotes work somebody else validated, and requiring both would make every promoter a builder. —

env — environments.

Permission Grants Does not grant Prerequisite
env.manage Creating, refreshing, and deleting environments; setting masking, subsetting, and synthetic policy; running reverse-integration. Reading the record data inside an environment, which resolves through the ordinary planes. A mask downgrade to none additionally requires step-up and a typed reason. —

package — packaging.

Permission Grants Does not grant Prerequisite
package.author Creating and changing package definitions and their contents. Building or promoting a version. —
package.build Running a build and producing a version. Promoting a version to released, or installing one. —
package.promote Promoting a built version from beta to released. Building it, or changing what is in it. —
package.install Installing or upgrading a package in this org. Anything about the package’s own source, which lives in the publishing org. Nor removing one. —
package.uninstall Removing an installed package, leaving its data in the retention window. Purging what the uninstall left behind. package.install
package.purge Destroying an uninstalled package’s retained data ahead of policy. Uninstalling. Deliberately separate from all three of the above — removing a package and destroying what it held are different decisions, and the second one is not undoable. —

data — bulk and cross-sharing record operations. The only domain whose permissions touch the record plane, and none of them touches field-level security.

Permission Grants Does not grant Prerequisite
data.view_all Reading every record of every object regardless of record access. Field access. A data.view_all holder reads null for any column they lack field Read on — exactly as on Salesforce. —
data.modify_all Editing and deleting every record regardless of record access. Field access; hard purge; erasure; impersonation. data.view_all
data.export Bulk export of records the reader may already see. Widening what they may see. Export runs through the planes, not around them. —
data.import Bulk load and upsert. Suppressing automation during the load — that is automation.bypass, held separately and on purpose. —
data.mass_update Mass update, mass transfer, and mass delete from a list view. Hard delete. A mass delete is a soft delete, recoverable for the retention window. —
data.purge Hard-deleting rows: a retention policy’s purge stage, emptying the recycle bin, hardDelete: true on a job. Deleting rows in the first place — that is object Delete. The ability to remove a row is not the ability to remove its recoverability. —
data.restore Restoring soft-deleted records, which come back with the ids they had. Restoring purged rows. Nothing restores those; that is what purge means. —
data.erase Irreversibly erasing a data subject across every object, satisfying a right-to-erasure request. Acting alone. Erasure requires an approver who is not the requester, and no single grant satisfies both roles. —

files — file content.

Permission Grants Does not grant Prerequisite
files.share_external Minting a file_external_link for a recipient with no login. Reading a file the holder could not already read; the link inherits, it does not widen. —
files.legal_hold Placing or releasing a legal hold. Reading held content, or exempting held content from any other rule. —
files.purge Hard-deleting file content and versions ahead of policy. Overriding a legal hold. A held file is not purgeable by anyone, including root. —

jobs — background work.

Permission Grants Does not grant Prerequisite
jobs.view Reading job definitions, runs, queue health, and the dead-letter queue. Any control action. —
jobs.enqueue Submitting a run of a definition the holder may see. Editing the definition or its schedule — that is a deploy, and metadata.author. jobs.view
jobs.manage Run now, replay, discard, cancel, pause, and resume, one run or one definition at a time. Draining a queue wholesale. jobs.view
jobs.purge Draining an environment’s queue: discarding every queued run at once. Confirmed twice. Deleting the definitions, or touching the records those runs would have written. jobs.manage

notifications — delivery.

Permission Grants Does not grant Prerequisite
notifications.manage Reading notification destinations, the delivery log, and suppressions; clearing a suppression. Editing a notification type, template, or sending identity — those are components, and need metadata.author. Nor reading a rendered body past field-level security. —
notifications.send_test Sending a test message to oneself. Rate-limited per user. Sending to anybody else. notifications.manage

integration — outbound and inbound.

Permission Grants Does not grant Prerequisite
integration.manage Reading integration configuration — named credentials, external services, event channels — and replaying or discarding from the outbox. Reading, writing, or rotating a secret. —
integration.manage_credentials Writing and rotating credentials. The operation is write-only. Reading the stored value back. The metadata audit records the actor, the credential, and the fact of the write — never the value. integration.manage

views — saved list views.

Permission Grants Does not grant Prerequisite
views.share Sharing a saved view with named groups, roles, or users, and promoting a personal view to an org view. Publishing to everyone. —
views.publish Publishing a view to every user with Read on the object. Any access to the records in it. A published view still resolves per reader, so two people open the same view and see different rows. views.share

ai — the AI platform.

Permission Grants Does not grant Prerequisite
ai.use Using the AI assistant. Any write. —
ai.author Letting the assistant create or modify metadata on the holder’s behalf; those writes route through the same deploy validate as a typed one. Skipping approval where the org requires it. ai.use
ai.approve Approving an AI-proposed change before it deploys. Proposing one. Deliberately independent of ai.use, so an approver is not required to be an assistant user — an approval gate staffed only by the people using the thing is not a gate. —
ai.manage Configuring the AI platform: providers, models, capabilities, budgets, and evals. Reading spend, which additionally needs diagnostics.view; a reader without it sees the configuration and not the numbers. —

diagnostics — logs, traces, telemetry.

Permission Grants Does not grant Prerequisite
diagnostics.view Reading the execution trace, platform health, and telemetry. Decoding a sensitive raw trace, or raising trace verbosity. —
diagnostics.decode Decoding a correlationId to its sanitized raw detail — the operator power from Errors & diagnostics. Field-level security on the traced data. Reading what happened and reading what it contained stay separate grants. diagnostics.view
diagnostics.trace Raising verbose tracing for a bounded scope and window. An unbounded or open-ended trace. Scope and window are mandatory arguments, not defaults. diagnostics.view

security — the access model itself.

Permission Grants Does not grant Prerequisite
security.manage Authoring and assigning permission sets, groups, and muting sets; setting org-wide defaults, sharing rules, and field-level security. Any record or field access of its own. A holder can of course grant themselves access — and that grant is a component diff and an audit entry with their name on it, which is the control, not a loophole. —

users — people.

Permission Grants Does not grant Prerequisite
users.manage Provisioning, deactivating, and reactivating users; assigning and removing permission sets. Impersonation. Managing an account and becoming its owner are different trust decisions. —
users.impersonate Starting a read-only View-as session against a permitted target, with a typed reason and step-up re-authentication. Writing anything as the target. Nor is it implied by data.modify_all, by users.manage, or by platform.root. —
users.impersonate_write Writing as the impersonated user, on a shorter session cap. Reaching a target the holder could not already impersonate. users.impersonate

session — authentication policy.

Permission Grants Does not grant Prerequisite
session.manage Authoring the session-policy component: login hours, IP ranges, session timeout, MFA and device-trust requirements. Any data access. Session policy is evaluated at authentication, never at query time, so it can neither widen nor narrow a query result. —

platform — the capstone.

Permission Grants Does not grant Prerequisite
platform.root Full object, field, and record access to everything in the org, present and future, with nothing to enumerate; plus every platform-namespace system permission above. The four constraint permissions, namespaced permissions, the save order, the audit, and the tenant boundary — restated precisely below. —

Fifty-two permissions across twenty-one domains. That is roughly half the size of Salesforce’s list, and unlike it, the whole of it: there is no edition-dependent tail, no permission that appears when a feature is licensed, and no checkbox whose real reach is larger than its label.

Implication, dependency, and why there is no implicit web

Section titled “Implication, dependency, and why there is no implicit web”

No system permission implies another. Holding deploy.promote does not confer deploy.run. Holding security.manage does not confer users.manage. Holding data.modify_all does not confer data.view_all. Effective access is the bool_or union of the grants a user actually holds and nothing else, so there is no graph to walk at evaluation time — which is exactly what keeps the resolver a single SELECT and the union order-independent.

The prerequisite column is not implication. A prerequisite says a permission cannot be assigned without the one it names. It is checked when the permission set is saved and again when the set is assigned, and it fails loudly with the missing grant named. It never turns the prerequisite on. data.modify_all requires data.view_all to be present, so the assignment is refused until both are — it does not silently enable the second.

Prerequisites are therefore not transitive at evaluation time, because at evaluation time they are not consulted at all. A chain — jobs.purge requires jobs.manage requires jobs.view — is resolved once, at author and assign time, by computing the closure and requiring every member of it to be explicitly present in the union. At read time the kernel asks one question: is jobs.purge in this user’s grant rows? An unsatisfiable closure is an authoring error surfaced immediately; a cycle in the prerequisite graph fails validation and cannot deploy.

This is the deliberate opposite of the incumbent. Salesforce states that “Some user permissions have dependencies with other user permissions or object permissions” (User Permissions) without publishing the graph, so an administrator meets a dependency when a checkbox refuses to save, or — worse — when enabling one permission quietly enables another. The Metadata API carries the same shape: viewing a permission set’s own object settings and assignments requires View Setup and Configuration, so a permission about reading Setup is a hidden precondition for inspecting permissions (PermissionSet metadata type). Three documented facts compose into the escalation chain administrators actually get bitten by, and each link is separately sourced:

  1. Customize Application and Author Apex let a user create Apex classes.
  2. Apex declared without sharing “ensure[s] that the sharing rules for the current user aren’t enforced,” and “[s]haring declarations don’t enforce object-level access or field-level security” (sharing keywords).
  3. On API version 66.0 and earlier, “system mode is the default” for Apex, and a developer had to opt in to enforcement; only “[i]n API version 67.0 and later, Apex runs in user context by default” (enforcing object and field permissions).

The composition is the problem: a permission that names building an application reaches, through code the same permission authorizes, the effect of Modify All Data — and on any class still pinned to an older API version it reaches it by default rather than by choice. CAOS does not close this by writing a warning next to a checkbox. It closes it in three places at once: automation.author is a separate grant from metadata.author, so authoring code is not a side effect of building schema; effectful code has no system mode to run in, because enforcement is in the query for every caller; and a permission’s reach is its row and nothing downstream of it.

The object and field plane keeps its dependencies, and those genuinely are implications: edit ⇒ read, and create|edit|delete ⇒ read. They are allowed there because each lives inside a single grant row about a single object or field, is validated at author time, and cannot compose into a chain. The rule is worth stating plainly: implication is permitted where it is local to one row, and impossible where it would form a graph.

Most permissions need nothing beyond being granted. A minority are dangerous enough that the assignment is the beginning of the control rather than the whole of it. Four escalations exist, and a permission carries only the ones its risk earns:

  • Step-up authentication — the holder re-authenticates at the moment of use, and session age is never trusted. Applied where the action is irreversible, identity-assuming, or changes who can do what.
  • A typed reason — free text captured at the moment of use and written into the audit entry. Applied where the why is the thing a later reader will need and cannot reconstruct. A sentence naming a ticket is worth more than a checkbox.
  • A second actor — the operation cannot be completed by one person. Applied only where a single holder would defeat the control’s whole purpose.
  • An expiry — the grant or the session is time-bounded, and re-arming is a new grant with a new record rather than an extension.
Permission Step-up Typed reason Second actor Expiry
platform.root yes — — recommended as org policy; assignment supports an end date
users.impersonate yes yes — session capped — 30 minutes by default, one hour hard
users.impersonate_write yes yes — shorter cap than read-only impersonation
security.manage yes — — —
users.manage yes — — —
integration.manage_credentials yes — — —
data.modify_all yes — — —
data.purge yes yes — —
data.erase yes yes yes — approver ≠ requester —
metadata.purge yes yes — —
files.purge yes yes — —
package.purge yes yes — —
files.legal_hold yes yes on release — —
jobs.purge yes yes — —
deploy.promote yes — — —
env.manage, for a mask: none downgrade only yes yes — —
automation.bypass — yes — mandatory, 24-hour maximum, no open-ended form

An audit entry is deliberately absent from that table, because it is not an escalation: audit is always on for every permission in the catalog, including the ordinary ones, and a design that treats logging as an opt-in safeguard for dangerous grants has already conceded that the safe ones are unlogged. What the dangerous grants add to the audit entry is the reason and the step-up assertion, so the record answers why and how convincingly, not merely who and when.

The three planes stay orthogonal, and the system plane is the one most often assumed to leak into the others. It does not.

  • A system permission grants no record access and no field access. data.view_all and data.modify_all are the only permissions in the catalog that touch the record plane at all, and they widen that plane only — a holder of either still reads null for a column they lack field Read on, matching Salesforce’s own rule that “View All Data, Modify All Data, and View All Records or Modify All Records for a given object don’t override field-level security” (View All and Modify All). Every other permission in the catalog leaves both data planes exactly where it found them: security.manage reads no records, diagnostics.decode sees no field its holder could not otherwise read, notifications.manage renders a message body only as far as the reader’s field access reaches.
  • setup.access is sight, not authority. It is the front door and nothing past it. A user holding setup.access alone opens Object Manager and reads every object and field definition, opens the deploy history and reads every deploy, opens the permission-set catalog and reads every set — and every action on every one of those surfaces renders disabled with the grant it would need named beside it. Holding the door open is not holding the permission to change anything in the room.
  • The converse also holds, and it matters more than it looks. metadata.author without setup.access is a valid, deliberate identity: a build agent or CLI user that authors and deploys components through the pipeline and has no Setup shell at all. Capability and surface are separate axes, so a headless builder is a configuration rather than a workaround. This is why setup.access appears in no prerequisite column in the catalog above.
  • Reading a permission is itself gated by nothing special. Any user may see which permission sets they hold and what those sets grant them; the effective-access resolver answers for the caller about the caller without any grant. Seeing other people’s access needs security.manage. Salesforce inverts this — inspecting a permission set’s contents at all requires View Setup and Configuration — which is how “why can’t I see this field” becomes a ticket instead of a self-service answer.

Nobody assigns fifty checkboxes by hand. The platform ships a handful of capability bundles — ordinary permission-set groups pre-loaded with a domain-coherent slice — so an org grants a role’s worth of platform power in one assignment and refines from there.

Bundle System permissions For
Platform Admin platform.root the org’s owner / superuser — one boolean, everything, nothing to maintain
Builder setup.access, metadata.author, logic.author, automation.author, config.author, deploy.run, ai.use, ai.author, jobs.view, diagnostics.view builds and ships to non-production; cannot manage users or security, and cannot promote to prod
Release Manager setup.access, deploy.promote, metadata.purge, env.manage, package.build, package.promote, jobs.view, jobs.manage owns the path to production
Security Admin setup.access, security.manage, users.manage, session.manage owns access, not the app
Operator setup.access, data.view_all, jobs.view, jobs.manage, diagnostics.view, diagnostics.decode, integration.manage, notifications.manage supports and observes — reads everything, authors nothing
Translator metadata.translate changes every string in a locale and no component in any other
Standard User (none, or ai.use) does the business job; access comes entirely from object/field sets

Bundles are additive sets like any other, so an org composes them (a Builder who is also a Release Manager holds both) or clones one to make its own. They map onto the platform’s capability-role dimension — coarse platform power — which is independent of a user’s business role.

Eight permissions are in no bundle at all, and nobody holds them until somebody decides to. automation.bypass, users.impersonate, users.impersonate_write, data.purge, data.erase, files.purge, files.legal_hold, and package.purge are deliberately unbundled: each is an act against the org’s own controls rather than a job function, and shipping one inside a role-shaped bundle is how it ends up held by people who never needed it. Granting one is a decision with a name attached, which is the point.

The root grant — one boolean, everything, forever

Section titled “The root grant — one boolean, everything, forever”

Salesforce’s most painful long-running gap: even Modify All Data does not override field-level security, so the moment an admin creates a new custom field it is invisible to their own query until FLS is granted for it — and FLS is a per-field grant new fields do not receive automatically (View All / Modify All). The System Administrator profile is therefore never actually “done”: every new object and field is another row of upkeep, and forgetting it produces the classic “No such column” error on a query the admin should obviously be allowed to run.

CAOS closes this with a single grant that is not a pile of permissions but a short-circuit. platform.root is one boolean. Holding it makes the effective-access resolver return full access — object, field, and record — for every object and field, before it consults a single grant row, RLS predicate, or column mask:

  • effective_object_perm(root_user, …) → all true, without reading object-permission rows.
  • effective_field_perm(root_user, …) → read + edit, without reading FLS rows or masking any column.
  • RLS row policies are written USING (is_root(current_user) OR <predicate>), so root sees every row.

Because the check is “is this user root?” rather than “does this user hold a grant for this specific field?”, there is nothing to enumerate and therefore nothing to maintain. A field created one second ago is covered exactly like one created a year ago — the resolver never asks about it. The Salesforce upkeep loop cannot occur by construction.

This is strictly stronger than the object viewAll/modifyAll grants and the org-wide data.view_all/data.modify_all permissions, which — matching Salesforce — bypass the record plane but not FLS. platform.root bypasses all three planes, and it is the entire content of the Platform Admin bundle.

Stated precisely against the catalog, root bypasses: the object plane, the field plane, and the record plane, for every object and field that exists now or later; and every system permission in the platform namespace, so a root holder needs no separate assignment to reach Setup, author components, run and promote deploys, manage security, or read diagnostics.

Root does not bypass six things, and each omission is deliberate:

  • The four constraint permissions. users.impersonate, users.impersonate_write, data.erase, and files.legal_hold must be assigned to a root holder explicitly, and a root holder without them is refused exactly like anyone else. One principle covers all four: root does not satisfy a permission whose entire purpose is to constrain an administrator. Impersonation is identity assumption, not data access — reading every record and becoming another person are different decisions, and binding them to one grant means every org needing a data-recovery admin also has an impersonator. Erasure is dual-control by definition, and a grant that satisfies both the requester and the approver has deleted the control rather than passed it. A legal hold exists precisely to bind the org’s own administrators, and a hold the top administrator can lift silently is not a hold.
  • Namespaced permissions. A package’s acme__pricing.approve or an org’s dgn__pricing.edit gates logic the kernel cannot reason about, so root does not satisfy it. Root is an authority over the platform’s own surfaces, not a universal answer to can().
  • The save order. Root is an access grant, not a logic bypass. A root user’s write still runs shape, adjust, validate, write, effects, and post-commit: validation rules fire, required fields are required, lifecycle gates hold, roll-ups recompute. Suppressing automation is automation.bypass, which root does not carry.
  • The escalations. Step-up authentication and typed reasons in the table above apply to root’s use of those operations exactly as they apply to anyone’s.
  • The audit. Every root read and every root write is an audit entry, and root cannot turn the streams off — there is no setting that does that for anybody.
  • The tenant boundary and the service role. Root is org-scoped, and it is not the database service role, which skips enforcement at the infrastructure layer and is never assignable to a person.

Root is still an ordinary, auditable grant — a bool_or row like any other — so who is root is one query, it is revocable in one click, and every root action is logged. It is not a hidden profile, and not the database service role (which skips enforcement at the infrastructure layer and is never assignable to a person). Root is org-scoped: a tenant’s root sees everything in that tenant’s schema and nothing outside it — the schema-per-tenant boundary still holds, so root is never a cross-tenant key.

A closed catalog would be honest and useless: a package that ships an approval step needs to gate it, and an org that ships a pricing override needs to gate that. Both may declare system permissions, and the thing that keeps fifty legible when it becomes five hundred is that the namespace tells you who declared it, always.

Tier Spelling Who declares it Lifecycle
Platform domain.verb — bare Only the platform. Adding one is a platform release with a catalog entry, a named surface, and a documented boundary. Permanent; removal is a deprecation with a release note.
Package <ns>__domain.verb A package, in its own namespace. Declared as a system_permission component and shipped with the package. Installs and uninstalls with the package; a permission still referenced by an assignment blocks the uninstall.
Org <ns>__domain.verb The customer, in their own namespace. Same component type, same grammar. An ordinary component: retrieved, diffed, deployed, safe-deleted.

The grammar does not change across tiers — dgn__pricing.edit is a domain and a verb like everything else, checked the same way in an expression, unioned the same way in the resolver, listed the same way in Setup. Six rules keep the catalog readable as it grows:

  1. Namespace is mandatory outside the platform. There is no unnamespaced customer permission, and no way to add a verb to a platform domain — files.approve is not available to a package, because files is not its namespace. A reader always knows from the name alone which tier a permission came from and who to ask about it.
  2. A permission must name the surface it gates. A declaration with no gates text does not deploy. The single most common way a catalog rots is entries nobody can explain, and the cheapest moment to require the explanation is the moment of authorship.
  3. Prerequisites may point inside the declaring namespace or at a platform permission, never at another package’s. Cross-package prerequisite coupling is inexpressible, so no install order can create a permission nobody can satisfy.
  4. Generated families collapse. A per-rule permission like validation.bypass_line_price is minted by its rule and listed under that rule, not as a loose sibling of deploy.run. The Setup catalog shows one row per generator with its count; expanding it lists the members.
  5. Unchecked permissions are dead metadata. A permission that no surface, expression, or automation checks is reported by the same dead-metadata pass that reports an unreferenced field, and safe-delete removes it. A catalog that only grows is a catalog nobody reads.
  6. No wildcards, at any tier. A grant names exactly one permission, so a package cannot ship acme__* and a customer cannot assign it.
{
"key": "dgn__pricing.override_material_total",
"label": "Pricing — Override Material Total",
"type": "system_permission",
"body": {
"gates": "Overriding the computed material total on an invoice line.",
"requires": ["dgn__pricing.edit"], // same namespace; a platform key is also legal
"stepUp": true,
"reason": "required"
}
}

Salesforce’s analogue is the custom permission — “access checks that can be assigned to users via permission sets or profiles, similar to how you assign user permissions,” checked from code as FeatureManagement.checkPermission(...), and able to require other custom permissions through requiredPermission, available since API 32.0 (Custom Permissions, CustomPermission metadata type). The mechanism is the right one and CAOS keeps it. What CAOS changes is that custom permissions and platform permissions are the same kind of thing rather than two parallel lists in two parts of Setup — one grammar, one catalog view, one resolver, one can() — so an org has one place to answer “what gates this” instead of two.

A system permission is the same shape as an object grant: a boolean row keyed by (source, permission), unioned with bool_or, subtractable only by a group’s own muting set. So the effective-access resolver answers “may this user run a deploy?” the same single-SELECT way it answers “may this user edit margin?” — a system grant is as auditable and revocable as any other, with no invisible baseline. Each permission is checked at exactly one surface (the Enforced at column above); a missing grant hides the surface rather than letting the user reach it and fail.

When enforcement runs. FLS and object access are evaluated on every read and write, in the query, not as a UI convenience layer. A read resolves the RLS row predicate and the column projection together; a write is rejected before commit if the user lacks the object/field grant. This is deliberately the only path — there is no elevated code mode that sees around it (contrast Salesforce Apex ≤ API 66.0, which ran in system mode by default; see How Salesforce does it).

Union / dependency rules. Effective access is bool_or across the user’s grant rows. edit ⇒ read and create|edit|delete ⇒ read are enforced at author time so an inconsistent set cannot be saved. viewAll/modifyAll widen the record plane only; they never grant field access — a modifyAll holder still reads null for a column they lack field Read on.

Field-hidden behavior. Salesforce ships two answers for “field the user cannot read”: silently null it (stripInaccessible) or throw and fail the whole query (SECURITY_ENFORCED). CAOS’s default is strip — the column is nulled on the read path — matching graceful degradation: a caller lacking field Read still gets the rows it may see, with the inaccessible column blank. A strict mode that errors instead is available as a per-query opt-in, for callers (integrations, exports) that must fail loudly rather than silently omit a value. Strip is the default because it composes with the additive model — a partial grant yields partial data, never a hard failure — while strict is opt-in where all-or-nothing correctness matters more than availability.

Determinism. The resolver is a pure aggregate over persisted grant rows and is therefore deterministic and reproducible for a given assignment state. It has no dependence on evaluation order — union is commutative — which is the property that makes “additive ⇒ safe” hold.

Salesforce’s caps exist to bound metadata-catalog size, per-user union cost, and — for record access — sharing-recalculation surface. A greenfield Postgres kernel does not inherit the same ceilings, but it inherits the same cost curves, so several equivalents still matter.

Concern CAOS approach Salesforce’s exact limit (cited) The WHY behind the SF limit CAOS still needs an equivalent?
Permission-set catalog Sets are rows; no fixed catalog cap, bounded by DB scale 1,000 / org (1,500 incl. managed-package sets) Each set is metadata + assignment-join rows; bounds catalog + join cost Soft — union cost grows with sets/user; monitor, don’t hard-cap arbitrarily
Group catalog Groups are rows referencing sets 800 / org A group precomputes a flattened union; count bounds recalculation surface Only if the resolver is later materialized — then group count bounds recompute; the default live view has no such cap
Scoped subtraction One optional mute set per group, subtracted in the same union query 1 muting permission set per group One deterministic subtraction layer keeps union→mute single-pass and predictable Same cap — one muting set per group holds the union→mute pass single and order-independent
Sets assignable per user Assignment is a join row; no designed ceiling No documented hard cap — unverified as official Each assignment adds to the per-user union cost, not a fixed count Soft — per-user union cost is the real bound, not a number
Record-access grants per object Additive share grants; no deny share Sharing rules 300 / object (raisable to 500 via support), 50 criteria-based (reported) Each rule expands to share rows on every matching record; recalc is O(rules × records); criteria rules re-evaluate on field change Yes — the recalc cost curve is identical; a “materialize shares” design inherits this exact bound
Baseline plane None — every grant is a set 1 profile / user (mandatory baseline) Baseline must be singular and deterministic No — removing the baseline removes the plane, not just the cap

Object/field permissions. A user’s effective permission is the most-permissive union of their one profile, every directly-assigned permission set, and every set delivered via every assigned permission-set group. The only subtraction is a muting permission set, which exists only inside a group, is capped at one per group, and subtracts only permissions delivered by that group — the same grant from a directly-assigned set survives the mute. Muting cascades along dependencies (mute Read ⇒ Create/Edit/Delete/View All/Modify All all mute).

Object super-grants vs FLS. Object View All/Modify All, and the org-wide View All Data/Modify All Data, bypass the entire record-sharing plane — but not field-level security. Salesforce is explicit: those permissions “don’t override field-level security. Users must still have field permissions … to access each field.” A Modify-All-Data integration user still returns null for an FLS-hidden field. FLS is checked independently and always.

Where FLS is auto-enforced. The UI, Lightning Data Service / UI API, and the standard SOAP/REST Data API enforce FLS automatically. Apex historically did not: on API ≤ 66.0 it ran in system mode (all field/object access granted) unless the developer opted in via WITH USER_MODE, WITH SECURITY_ENFORCED, or Security.stripInaccessible. Only in API 67.0+ does Apex run in user mode — enforcing FLS — by default, and only for new code; old classes keep their old API version and old behavior.

System permissions. Salesforce calls them user permissions: “User permissions specify what tasks users can perform and what features users can access. For example, users with the View Setup and Configuration user permission can view Setup pages, and users with the API Enabled user permission can access any Salesforce API.” They are boolean flags on a profile or permission set, surfaced in Setup under App Permissions and System Permissions, and there is no published catalog of them outside those screens — the guidance is to read the checkboxes. Three properties follow. They are not namespaced, so nothing in a name says which feature owns it. They carry undocumented dependencies — “Some user permissions have dependencies with other user permissions or object permissions” — which an administrator discovers when a checkbox refuses to save. And several are catch-alls whose reach exceeds their label: Customize Application covers schema, code, and application configuration together. Extension is a separate mechanism, the custom permission, with its own object, its own Setup page, and its own requiredPermission dependency graph, so an org that extends the model ends up maintaining two catalogs that behave alike and are administered apart.

Record access. Access to a specific row is a union: the OWD floor for records a user does not own, then role-hierarchy inheritance, then sharing rules (owner- or criteria-based), then manual / team / Apex managed shares, plus non-configurable implicit parent↔child shares. Most-permissive wins; there is no deny share — access is tightened only by lowering OWD or removing grants.

Salesforce is not retiring profiles. In Jan 2023 Salesforce announced end-of-life for permissions on profiles targeting Spring ’26; it softened in 2024, and in mid-2026 it was cancelled outright — Knowledge article 003834041, “Permissions in Profiles Retirement Cancelled,” states the retirement is cancelled, citing customer feedback and remaining tooling gaps for a clean at-scale migration. Profiles keep carrying permissions with no mandatory EOL, though Salesforce directs new investment to permission sets and groups.

What CAOS does better, by named mechanism.

  • Genuinely better (mechanism): FLS is enforced on the single read path for every caller — RLS row policies plus a column-projection view that nulls unreadable columns — so there is no system-mode backdoor and no legacy-API-version escape hatch that a class can be pinned to. The effective-access resolver is a queryable view (bool_or over grant rows), so “what can user U do to field F” is one SELECT rather than a by-hand union of profile ∪ N sets ∪ M groups that Salesforce offers no single source of truth for. And no profile plane removes an entire class of “which invisible baseline granted this” confusion — every grant is a set, assignable, auditable, deterministically revocable. Finally, platform.root collapses the perpetually-maintained System Administrator into one resolver-level boolean that overrides FLS and covers all present and future metadata — killing Salesforce’s “new field is invisible to the admin’s own query until FLS is set” upkeep loop, which its Modify All Data explicitly does not solve.
  • Genuinely better (mechanism), on the system plane specifically: the catalog is enumerated and namespaced — domain.verb, one document, no edition-dependent tail — so “what gates this surface” and “what does this permission not do” are readable rather than inferred from a checkbox label. Implication is removed and replaced with an assignment-time prerequisite, so no grant silently turns on another and the runtime never walks a dependency graph; the escalation composition that lets Customize Application plus Author Apex reach the effect of Modify All Data through system-mode code has no analogue here, because effectful authoring is its own grant and there is no system mode to author into. Dangerous grants carry structural escalations — step-up, typed reason, second actor, expiry — declared per permission rather than left to org discipline. And extension is the same mechanism as the platform catalog: one grammar, one resolver, one can(), instead of user permissions and custom permissions as two parallel systems.
  • Mere parity: additive sets, groups, boolean CRUD/FLS rows, and the union with subtraction confined to a single group-scoped muting set are the Salesforce model Salesforce now recommends. Matching it is table stakes. CAOS invents no new algorithm here; it ports the union to a bool_or SQL aggregate with the mute as a subtractive term over the group’s own grants.
  • Costs / risks (named):
    • RLS performance at scale. Row-security predicates run per candidate row; correlated subqueries or per-row function calls in policies are the classic footgun. Salesforce materializes share rows precisely because recomputing access predicates live is expensive — computing record access live in RLS trades a write-time recalc cost for a per-query cost, and must be benchmarked at target row counts before any “provably better” claim.
    • Policy-cycle breaks. An RLS policy that reads another RLS-protected table can recurse or deadlock; breaking the cycle needs SECURITY DEFINER helper functions returning uuid[] (already a known Dragon OS pattern). The access query must be designed not to trip access policies.
    • Column-level FLS is not native. Postgres RLS is row-level; per-user column masking requires either coarse column-privilege GRANTs or a projection/view applied on every read path — miss one path and the “no backdoor” advantage is gone.
    • Admin comprehension of a pure additive model. A Salesforce-trained admin understands profiles/sets/groups/OWD/roles; a pure-additive RLS model is more powerful but less legible. The effective-access resolver UI is not optional polish — it is what keeps the model auditable.
    • A no-implication catalog is more tedious to assign. Removing implication means a chain like jobs.view → jobs.manage → jobs.purge is three grants an administrator must set, where Salesforce would have granted the lower two by implication. The bundles exist to absorb that cost for common roles, but an org building a bespoke role does more clicking than it would on the incumbent, and the answer to “why do I have to tick three boxes” is a property of the design rather than a defect to fix.
    • A curated catalog needs upkeep to stay curated. Every new surface argues for its own permission, and the pressure is always toward one more. The discipline that keeps fifty from becoming three hundred — a permission only when a real trust boundary exists, a stated boundary in the does not grant column, dead-permission reporting — is process, and process erodes. The failure mode is not a security hole; it is a catalog that becomes exactly the flat unreadable list this design was built to avoid.
    • Namespaced extension is only as good as namespace hygiene. Three tiers of authorship keep provenance readable only while every non-platform declaration actually carries a namespace. An org that ships everything in one namespace, or a package that treats its namespace as a dumping ground, gets a flat list again with extra characters in front of it.

Each permission construct is a canonical component (key / label / type / body) subject to retrieve → diff → deploy. Permission assignments to users are record data, not schema, and are never part of the component.

{
"key": "psg_lead_billing",
"label": "Lead Billing Specialist",
"type": "permission_set_group",
"body": {
"sets": ["ps_invoice_edit", "ps_invoice_margin"]
// "mute": { ... } // optional single group-scoped muting set
}
}

A diff that adds a set to a group is metadata-only — the live resolver view picks it up on the next query with nothing to recompute; a diff that changes a set’s fieldPermissions re-provisions the affected column-projection policy. Record-access configuration (the OWD floor per object, and any share-grant rules) is a separate component family from the object/field permission sets, mirroring Salesforce’s split.

Salesforce Metadata API analogs, for migration/parity mapping:

CAOS component Salesforce Metadata type Key elements
Permission set PermissionSet objectPermissions[], fieldPermissions[], userPermissions[], hasActivationRequired
System permission (declared) CustomPermission label, description, requiredPermission[] (API 32.0+), connectedApp, isLicensed
Permission-set group PermissionSetGroup contained sets; computed/flattened state; one optional muting set
Scoped subtraction MutingPermissionSet subtractive objectPermissions/fieldPermissions/userPermissions
Object permission (row) objectPermissions object, allowRead, allowCreate, allowEdit, allowDelete, viewAllRecords, modifyAllRecords, viewAllFields (API 63.0+)
Field permission (row) fieldPermissions field, readable, editable
Record share <Object>Share (e.g. AccountShare) ParentId, UserOrGroupId, AccessLevel, RowCause
OWD / sharing model SharingSettings per-object default access + hierarchy flag
Sharing rule SharingRules (sharingOwnerRules, sharingCriteriaRules) source group, target group, access level, criteria