Roles & record sharing
Object and field permissions decide what a user may do to a kind of thing. They say nothing about which rows. That second question is the record-access plane, and it is the third of the three orthogonal planes a user must clear to touch a value.
Record access is composed from four things: who owns the record, the object’s org-wide default floor, the role hierarchy that lets a manager see what their reports see, and grants that widen access beyond the floor. The whole composition resolves to a single Postgres row-security predicate.
The load-bearing decision on this page: rule-based access is computed, not materialized. Salesforce writes a physical share row for every record a rule touches, which is why changing a role’s parent or a group’s membership kicks off an org-wide recalculation job. CAOS compiles the same rules into a predicate and materializes rows only for shares that are genuinely per-record. Sharing recalculation, as a background job an admin waits on, does not exist.
The model
Section titled “The model”| Layer | Question it answers | Where it lives |
|---|---|---|
| Ownership | Who owns this row? | An owner_id column on every object — a user or a queue |
| Org-wide default | What may everyone else do by default? | Per-object setting: private, read, read_write, or controlled_by_parent |
| Role hierarchy | Does someone above the owner inherit that access? | A role tree, opt-in per object |
| Grants | Who else, beyond the floor? | Sharing rules (computed) and explicit shares (stored) |
Access is a union and it is additive — exactly like the object/field plane. Most-permissive wins. There is no deny share. Access is tightened by lowering the org-wide default or removing a grant, never by layering a subtraction on top, because a model where any one grant can be silently cancelled elsewhere is no longer auditable by reading the grants. That property is the reason the permission model has no deny either; the two planes are consistent by design rather than by coincidence.
Ownership
Section titled “Ownership”Every record has exactly one owner, and the owner always has full access to their own record subject to the object/field plane. The owner defaults to the creating user and can be transferred; transfer is an ordinary field write that the save order treats like any other, so automation and validation see it.
An owner may also be a queue — a named target that owns records without any individual owning them, the standard pattern for work waiting to be claimed. Taking ownership out of a queue is a transfer.
The org-wide default
Section titled “The org-wide default”The floor is per object, and it is the answer to “what can a user do to a record they do not own and hold no grant for”:
private— nothing. Only the owner, the hierarchy above them, and explicit grants.read— everyone with object Read may read the row; only the owner and grant holders may edit.read_write— everyone with object access may read and edit any row.controlled_by_parent— the record has no independent access; it inherits its parent’s. This is the master-detail case, and the child’s policy defers to the master’s.
The floor only ever raises access from nothing; every other layer is additive on top of it. Setting the floor low and granting up is the model working as intended — and unlike the incumbent, lowering the floor is not a destructive operation, because there are no share rows to delete and rebuild.
The role hierarchy
Section titled “The role hierarchy”A role is a node in a tree. Each user holds zero or one role. A role grants access to every record owned by a user in any role below it in the tree.
Two properties of a role matter more than anything else on this page:
- A role grants no permissions. Not object access, not field access, not a system permission. It affects which rows, and nothing else. Capability comes from permission sets; the two are independent dimensions and are deliberately not merged. A “Sales Manager” role does not make anyone a sales manager — it makes them able to see their reports’ records.
- Hierarchy inheritance is opt-in per object, on every object. An object declares
grantAccessUsingHierarchy: true | false. A commission record owned by a rep is often precisely the thing their manager should not see, and the model has to be able to say that for any object, not just some.
Because a role is only a tree node, the hierarchy is free to mirror the org chart, and usually should — but it is a record-visibility structure, not a reporting structure, and the two are allowed to diverge where visibility requires it.
Grants
Section titled “Grants”Two kinds, and the distinction between them is the whole performance story:
- Sharing rules — declarative and rule-shaped: “records owned by anyone in group A are readable by group B,” or “records where
region == 'West'are editable by group C.” A rule describes a set of records by a predicate over their columns and their owner. It is never expanded into rows. - Explicit shares — genuinely per-record: a user hand-shares one record with one colleague, or automation grants access to a specific row. There is no rule that produces it, so there is nothing to compute; it is stored as a row in a share table, sparse by nature because only explicitly-shared records appear in it.
A group is a named set of people: it contains users, roles, roles-and-their-subordinates, and other groups, so “the West sales org” is named once and reused. Group membership is data, not schema. A group confers no record access by existing — sharing rules and explicit shares (below) are consumers that may target a group, not its definition; the mechanism record access actually flows down is the role hierarchy, above.
Queues
Section titled “Queues”A queue is an owner that is not a person: a named place a record can sit while it waits for someone to take it. Three unrelated parts of the platform need one — an approval step routes work to a queue, a mass transfer moves records to one, and deactivating a user reassigns their records to one — and they all need the same thing, which is somewhere a record can be owned without being any individual’s.
A queue is a component, and its membership is record data. The queue itself deploys, because other components name it: a lifecycle stage’s owner, an approval step’s approver, an object’s default assignment. Those references have to resolve at deploy time, and a reference into pure record data cannot. Who is in the queue does not deploy, for the same reason role assignments and group membership do not — it changes on a Tuesday when somebody joins the team, and that is an operational act, not a release.
Membership is a group, not a second member model. A queue names one group, and that group’s closure is its membership. There is no queue-specific member list, no queue-specific nesting, and no second place to author “who is on the support team” — membership is edited where all group membership is edited, and a group already expresses users, roles, roles-and-subordinates, and nested groups. The queue component adds only what a group has no reason to carry: which objects it may own, and its display name for the work surfaces.
Ownership. owner_id on any object may hold a queue rather than a user, subject to the queue declaring that object. A queue-owned record has no individual owner, so every member of the queue’s group holds owner-level access to it — one more clause in the same predicate, resolved through the group closure that already exists. Claiming a record is an ordinary owner transfer from the queue to the claiming user, which means it passes through the save order like any other field write: automation sees it, validation gates it, and it lands in data history with an actor. There is no separate “accept” mechanism that writes ownership by a path the rest of the model cannot see.
The role hierarchy does not reach a queue-owned record, and that is the point. A role grants access to records owned by users below it in the tree. A queue holds no role and sits nowhere in the tree, so nobody inherits a queue-owned record by being above anybody — access to it comes from queue membership, the org-wide default, and grants, and from nothing else. The consequence is worth stating plainly, because it is the behavior people are surprised by: a manager who can see every record their reports own cannot necessarily see the ones sitting in the queue those reports draw from. That is correct. A queue exists precisely so that unclaimed work is visible to the people who will claim it rather than to a management path that has not been chosen yet, and the moment a record is claimed, the hierarchy applies to it normally, because it now has a user owner. Where a manager genuinely should see the backlog, they are a member of the queue’s group, which is the explicit statement of that fact rather than an accident of the org chart.
Why a queue is not just a group with a flag. A group is a named set of people — it answers “who is in this set,” independent of whether anything currently grants them access through it. A queue is a subject of ownership — it answers “whose is this while nobody has taken it.” The two are separable: most groups should never own a record, and a queue’s object list is a real constraint that a group has no place to hold. Keeping them distinct also keeps the failure modes distinct — deleting a group that grants access widens nothing and narrows access, while deleting a queue would orphan the records it owns, so a queue holding records routes through safe-delete with those records named.
Authoring
Section titled “Authoring”Roles, groups, queues, the org-wide default, and sharing rules are all canonical components. Assignments — which user holds which role, who belongs to which group, and therefore who staffs which queue — are record data.
{ "key": "role_regional_manager_west", "label": "Regional Manager — West", "type": "role", "body": { "parent": "role_vp_sales" }}{ "key": "queue_sales_ops", "label": "Sales Ops", "type": "queue", "body": { "members": "group_sales_ops", // membership lives on the group "objects": ["invoice", "credit_note"] // what it is allowed to own }}{ "key": "sharing_invoice", "label": "Invoice sharing", "type": "sharing_settings", "body": { "object": "invoice", "orgWideDefault": "private", "grantAccessUsingHierarchy": true }}A sharing rule names who it grants to, what it grants, and which records it covers — the coverage written as a pure boolean expression in the one typed language, the same grammar as a formula or a validation rule:
{ "key": "share_west_invoices_to_billing", "label": "West invoices → Billing team", "type": "sharing_rule", "body": { "object": "invoice", "grantTo": "group_billing_team", "access": "edit", "covers": "record.region == \"West\" && record.status != \"Draft\"" }}An owner-based rule is the same component with a covers expression over the owner instead of the record’s own columns — inGroup(record.owner_id, "group_west_sales"). There is no separate rule type for owner-based versus criteria-based, because in a model where both compile to a predicate the distinction has no mechanical consequence. It exists in the incumbent only because the two expand into share rows by different code paths.
Because covers is an ordinary pure expression, it gets the same treatment as any other: type-checked at author time, recorded in the dependency graph, and analyzable — the platform can answer “which sharing rules read the region field” before a deploy changes it.
Semantics & evaluation
Section titled “Semantics & evaluation”One predicate per object. Every layer compiles into a single RLS policy:
USING ( is_root(current_user_id()) OR owner_id = current_user_id() OR owner_id = ANY (subordinate_user_ids(current_user_id())) -- hierarchy OR owner_id = ANY (current_user_queue_ids()) -- queue membership OR <org-wide-default clause> OR <compiled sharing-rule clauses> OR EXISTS (SELECT 1 FROM invoice_share s WHERE s.record_id = invoice.id AND s.grantee = ANY (current_user_groups())) -- explicit shares)The clauses are ordered cheapest-first and short-circuit, so the common cases — you own it, or the floor is open — settle without touching the share table. platform.root short-circuits the entire predicate, which is what makes it a true root grant rather than a very wide set of grants.
What is materialized, and why that is the whole argument. Exactly two things are precomputed, and neither is per-record:
| Precomputed | Size | Recomputed when |
|---|---|---|
| Role closure — every role mapped to all roles beneath it | O(roles²) worst case; roles number in the hundreds | The role tree changes |
| Group closure — every group flattened to its transitive user set | O(groups × members) | Membership or nesting changes |
Both are role-scale, not record-scale. Reparenting a role rewrites a closure table with hundreds of rows inside the transaction that made the change, and the next query is already correct. The same edit in the incumbent triggers a recalculation across every affected record, which its own documentation says “can take a while” and which large orgs manage with deferred-maintenance and parallel-recalculation features (Recalculate Sharing Rules).
The trade is real and it is a trade: cost moves from write time to read time. Every record query now evaluates a predicate rather than probing a prebuilt share table. Three things keep that honest — the closures are indexed and small enough to sit in cache, owner_id and every column named in a covers expression are indexed by construction, and each object carries a predicate-cost budget measured with EXPLAIN, so a rule set that would degrade list views is rejected at deploy with the plan that proves it rather than discovered in production.
The budget is a platform constant, and it is a ratio. It is not a per-tenant plan value and not a per-object setting. What it bounds is the overhead the access predicate adds: the compiled predicate may not raise the estimated cost of the object’s benchmark query by more than 3× over the same query with the predicate replaced by true. The benchmark is the object’s most expensive active list view, or, where the object has none, the canonical first page ordered by last-modified date, planned with EXPLAIN against the target environment’s current row counts.
A ratio rather than an absolute cost, because an absolute cost is meaningless across objects: a hundred-million-row object legitimately plans more expensively than a thousand-row one, and a single number would be either unreachable for the large object or vacuous for the small one. Normalizing against the object’s own unpredicated plan lets one constant bind differently everywhere — the same 3× is a generous allowance on a narrow, well-indexed object and a tight one on an object whose list views are already near the edge, which is the correct behavior in both cases.
Nobody inside a tenant can change it. Not an administrator, not the security administrator, and not platform.root — root bypasses access checks, and this is not an access check. The value moves with a platform release and is published with it. Two properties follow, and both are the reason it is a constant:
- A per-tenant value would make the same rule set deploy in one org and fail in another with no difference in the metadata. A packaged app could not be tested against a number it does not know, and a failure could not be attributed to anything the author could see.
- A per-object setting would be raised the first time it fired. The thing the budget protects is every list view on the object for every user; the person raising it is looking at one deploy they want to land. That trade is made badly under deadline, every time.
There is no override, so the remedy is to make the predicate cheaper — an index that covers the dominating clause, or a rule narrowed to the records it actually needs to reach. The deploy report is written to make that the obvious next step rather than a research project. It carries: the object and the benchmark query measured; the row count the plans were taken at; both plans, unpredicated and compiled, with their estimated costs and the resulting ratio; the single clause that dominates, quoted in compiled form with its share of the cost and the sharing_rule component it came from; and the index that would make that clause index-backed. The failure is a deploy-class error surfaced on the deploy result, and it names the rule that pushed the object over rather than only the total, because the set that failed today is usually yesterday’s set plus one addition.
A deploy that lands between 2× and the ceiling passes with a warning carrying the identical report. An object drifting toward the limit is visible several deploys before the one that fails, which is the difference between a budget and a wall.
The planned cost is bounded at deploy; reality is bounded at run time by a per-object statement timeout, which an administrator may lower but not raise. An EXPLAIN cost is a prediction, and a plan that degrades at a row count nobody benchmarked has to fail as a bounded, correlated error rather than as a query that never returns.
Where enforcement runs. In the query, on every read and write, the same single path as FLS. There is no system mode. A user who cannot see a row cannot see it through a report, an export, an API call, or an automation running on their behalf.
Interaction with the other planes. The planes are ANDed, and each is enforced independently:
- Object
viewAll/modifyAllbypass this plane and not FLS — amodifyAllholder sees every row and still readsnullfor a column they lack field Read on. - FLS bypasses nothing; it is checked on every projection regardless of how a row became visible.
platform.rootis the only grant that clears all three.
Not-found beats forbidden. A row the user cannot see is absent, not refused — consistent with the error model. Returning “forbidden” for a filtered row leaks the row’s existence, which for a private object is precisely the fact the floor was set to hide.
Explaining access. Because access is a predicate rather than a pile of rows, the kernel can evaluate it clause by clause and report which clause admitted a user. caos access explain --object invoice --record 8f3a… --user jane returns the matching clause — ownership, queue membership (naming the queue), hierarchy (naming the role path), a rule (naming the rule), or an explicit share — and the same answer renders on the record’s Sharing panel. The incumbent answers this only where a share row exists to carry a RowCause; a predicate can explain a denial too, which is the question an admin actually asks.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”| Concern | CAOS approach | Salesforce’s limit | Why their limit exists |
|---|---|---|---|
| Sharing rules per object | No fixed cap; bounded by the object’s predicate-cost budget | 300 per object (raisable to 500), 50 criteria-based (reported) | Each rule expands to share rows on every matching record; recalc is O(rules × records) |
| Role-hierarchy depth | No fixed cap; closure depth is bounded by cost, and a deep tree is a modeling smell | 500 roles, 10 levels (reported) | Bounds the ancestry walk baked into share-row generation |
| Roles per user | One (or none) | One | A hierarchy has one path upward; two roles make the closure ambiguous rather than more expressive |
| Explicit shares | Rows, sparse by construction | Rows | Genuinely per-record data — the one case where materializing is correct |
| Recalculation | None for rule-based access; closures recompute transactionally | Async org-wide job; deferred maintenance and parallel recalculation for large orgs | Share rows must be rebuilt when the inputs to their generation change |
How Salesforce does it
Section titled “How Salesforce does it”Record access resolves as a union: the org-wide default floor, then role-hierarchy inheritance, then sharing rules (owner-based or criteria-based), then manual / team / Apex managed shares, plus non-configurable implicit parent↔child shares. Most-permissive wins, and there is no deny share.
Mechanically, that union is materialized. Access lands in per-object share tables (AccountShare, OpportunityShare, …), each row carrying a RowCause that records why the access exists. This makes reads fast — a share row is an index probe — and it makes writes expensive, because anything that changes the inputs to those rows forces the rows to be rebuilt.
Salesforce Help states that sharing rules are automatically reevaluated when you are “adding or removing individual users from a group, role, or territory,” “changing which role a particular role reports to,” or “adding or removing a group from within another group,” and warns that recalculation “can take a while” (Recalculate Sharing Rules Manually). Large orgs are given two dedicated mitigations — Deferred Sharing Maintenance and Parallel Sharing Rule Recalculation — which exist because the recalculation cost is a first-order operational problem at scale (Defer Sharing Calculations).
One further constraint: “Grant Access Using Hierarchies” cannot be disabled for standard objects. The checkbox exists for custom objects only; on standard objects it is always on and not editable, so a manager’s inherited visibility into their reports’ standard records cannot be switched off. It is a long-standing IdeaExchange request.
Where CAOS is genuinely better:
- No record-level recalculation. Rules are predicate clauses; only role and group closures precompute, and those are role-scale and transactional. The class of operational pain around deferring, scheduling, and waiting on sharing maintenance does not arise.
- Hierarchy is opt-out on every object. No standard/custom asymmetry, because there is no privileged set of objects in the kernel to begin with.
- Access explains itself, including denials. A predicate can report which clause admitted a user and that none did. Share rows can only ever explain access that exists.
- Sharing rules are analyzable.
coversis an expression in the same language as everything else, so where-used, dependency, and blast-radius analysis apply to it — a field rename knows which sharing rules read it. - One rule shape. Owner-based and criteria-based collapse into one component, because the distinction was an artifact of two share-row generation paths.
Parity: ownership, an org-wide floor, a role hierarchy, group-targeted rules, explicit per-record shares, additive union with no deny, and separation of the record plane from the object/field plane. This is the right model; CAOS ports it rather than reinventing it.
Costs and risks:
- Read-time predicate cost is the whole bet. Salesforce materializes precisely because live evaluation is expensive. Indexes, small closures, and a per-object cost budget are what make it work, and they must be benchmarked at target row counts rather than assumed.
- Planner sensitivity. A predicate that is cheap on 10,000 rows can pick a bad plan at 10,000,000. Per-object budgets and statement timeouts are mandatory, not optional hardening.
- Policy cycles. An RLS policy that reads group membership must not itself trip a policy — the closures are exposed through
SECURITY DEFINERhelpers returning arrays for exactly this reason. - Closure churn. Reparenting a role high in a large tree rewrites a large fraction of the closure. Still bounded by role count, but not free.
- Legibility. A predicate is more powerful and less inspectable than a table of share rows. The explain surface is what keeps the model auditable, and it is a required part of the feature, not a later addition.
Metadata & deploy representation
Section titled “Metadata & deploy representation”| Component | type |
Body |
|---|---|---|
| Role | role |
parent |
| Group | group |
members[] — users, roles, roles-and-subordinates, nested groups |
| Queue | queue |
members — the group whose closure staffs it — and objects[] it may own |
| Sharing settings | sharing_settings |
object, orgWideDefault, grantAccessUsingHierarchy |
| Sharing rule | sharing_rule |
object, grantTo, access, covers |
Role assignments, group membership, and explicit shares are record data and are never part of a component — the same split the permission model draws between a permission set and its assignments.
A deploy that reparents a role or changes an org-wide default is metadata-only: it rewrites the closure or the compiled predicate and takes effect at the next query, under the same generation flip as any other metadata change. Nothing is rebuilt, so nothing has to be waited on — the difference between changing a predicate and regenerating rows, stated as an operational property.
Salesforce Metadata API analogs, for migration mapping: Role, Group, SharingSettings (per-object default access plus the hierarchy flag), SharingRules (sharingOwnerRules, sharingCriteriaRules), and <Object>Share rows carrying ParentId, UserOrGroupId, AccessLevel, and RowCause.
Sources
Section titled “Sources”- Recalculate Sharing Rules Manually — Salesforce Help — the exact list of changes that trigger automatic recalculation, and the warning that it can take a while.
- Automatic Recalculation of Org-Wide Defaults and Sharing Rules — Salesforce Help — recalculation is processed asynchronously and in parallel.
- Understanding Defer Sharing Calculations — Salesforce (PDF) — deferred sharing maintenance for bulk ownership, hierarchy, and rule changes.
- Create a User Role — Salesforce Help — a user holds zero or one role.
- Platform Sharing Architecture — Salesforce Architects — the share-table mechanism and
RowCause. - Allow “Grant Access Using Hierarchies” to be disabled for standard objects — IdeaExchange — the setting is not editable on standard objects.