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.
The model
Section titled “The model”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
USINGpolicies 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 BARRIERview / 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.
Authoring
Section titled “Authoring”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.
System permissions
Section titled “System permissions”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.
Names: domain and verb
Section titled “Names: domain and verb”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.
The catalog
Section titled “The catalog”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:
- Customize Application and Author Apex let a user create Apex classes.
- 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). - 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.
Grants that need more than an assignment
Section titled “Grants that need more than an assignment”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.
A system permission is never a data grant
Section titled “A system permission is never a data grant”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_allanddata.modify_allare the only permissions in the catalog that touch the record plane at all, and they widen that plane only — a holder of either still readsnullfor 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.managereads no records,diagnostics.decodesees no field its holder could not otherwise read,notifications.managerenders a message body only as far as the reader’s field access reaches. setup.accessis sight, not authority. It is the front door and nothing past it. A user holdingsetup.accessalone 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.authorwithoutsetup.accessis 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 whysetup.accessappears 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.
Starter bundles
Section titled “Starter bundles”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, andfiles.legal_holdmust 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.approveor an org’sdgn__pricing.editgates 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 tocan(). - 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.
Extending the catalog
Section titled “Extending the catalog”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:
- Namespace is mandatory outside the platform. There is no unnamespaced customer permission, and no way to add a verb to a platform domain —
files.approveis not available to a package, becausefilesis not its namespace. A reader always knows from the name alone which tier a permission came from and who to ask about it. - A permission must name the surface it gates. A declaration with no
gatestext 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. - 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.
- Generated families collapse. A per-rule permission like
validation.bypass_line_priceis minted by its rule and listed under that rule, not as a loose sibling ofdeploy.run. The Setup catalog shows one row per generator with its count; expanding it lists the members. - 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.
- 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.
Enforcement and provenance
Section titled “Enforcement and provenance”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.
Semantics & evaluation
Section titled “Semantics & evaluation”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.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”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 |
How Salesforce does it
Section titled “How Salesforce does it”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_orover 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.rootcollapses 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 itsModify All Dataexplicitly 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, onecan(), 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_orSQL 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 DEFINERhelper functions returninguuid[](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.purgeis 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.
Metadata & deploy representation
Section titled “Metadata & deploy representation”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 |
Sources
Section titled “Sources”- PermissionSet Metadata API type — objectPermissions / fieldPermissions / hasActivationRequired, “grant not deny”
- User Permissions — definition, the View Setup and Configuration example, and “Some user permissions have dependencies with other user permissions or object permissions”
- PermissionSet Metadata API type —
userPermissionssemantics, “grant access but not to deny access,” View Setup and Configuration required to read a set’s object settings,viewAllFieldssuppressingfieldPermissions - Custom Permissions — assignable access checks,
FeatureManagement.checkPermission, custom permissions requiring other custom permissions, distribution in managed packages - CustomPermission metadata type —
requiredPermission/CustomPermissionDependencyRequired(API 32.0+),.customPermissionsuffix - Apex sharing keywords — “without sharing … sharing rules for the current user aren’t enforced” and “Sharing declarations don’t enforce object-level access or field-level security”
- Enforcing object and field permissions in Apex — user context default in API 67.0+, “system mode is the default” in 66.0 and earlier
- Apex
stripInaccessible/ user-mode default in API 67.0 / system mode ≤ 66.0 - LWC Apex security — UI/LDS auto-enforce FLS vs Apex manual enforcement
- View All and Modify All Permissions — “don’t override field-level security”
- Muting Permission Sets — dependency cascade
- Permission Set Groups — considerations (800/org)
- Permission sets per org (1,000 / 1,500)
- Permissions in Profiles Retirement Cancelled (Knowledge article 003834041)
- Managing the Sharing Model (OWD)
- Designing Record Access for Enterprise Scale (DRAES) — introduction
- Salesforce backtracks on permission retirement in profiles (cancellation, ~July 2026)
- Salesforce cancels the permissions-in-profiles retirement — what it means for admins and architects
- Tips for planning and creating Salesforce sharing rules (300/500/50 limits)