Found while implementing the security model of the duly metadata application (objectstack-ai/duly#8). Filed unassigned.
The shape
A criteria sharing rule can express any predicate over the RECORD, but its recipient is a single static principal resolved once per rule:
sharedWith: { type: 'user' | 'team' | 'position' | 'unit_and_subordinates' | 'business_unit', value: '<static id or code>' }
plugin-sharing's expandRecipient reads rule.recipient_id and expands it without ever looking at the matched record, so every sys_record_share row a rule materialises names the same recipients. There is no way to say "share this row with the principal named by a field ON this row" — or with a principal DERIVED from one, such as the owner's manager.
Two independent requirements in one small application hit this in the same afternoon:
duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
duly_assignment → the users in its assignees field. Same shape: the recipient is a value on the matched record.
Why the near-misses are not answers
position is not "the manager". The only recipient that resolves to managers at all is position: '<manager position>', which shares every matched row with every holder of that position across the tenant — the skip-level, the manager two teams over, everyone. For a personal work log that is precisely the disclosure the feature exists to prevent, so the application ships the grant UNAUTHORED and fails closed rather than take it.
RLS is not an answer for a private object, despite the lint hint saying so. @objectstack/lint's sharing-rule-runtime-variable-condition tells authors:
Express per-user access with the mechanism that runs per request instead — an RLS policy on a permission set (rowLevelSecurity[].using, where current_user.* IS resolved)
That advice does not hold on a private object, because the two layers are AND-composed, not OR-composed. plugin-security getReadFilter:
return andComposeLayers(andComposeLayers(filter, cbpFilter), sharingFilter) ?? void 0;
and plugin-sharing buildReadFilter, for a private object with an owner column, returns
ownerMatch // owner_id ∈ (depth-resolved set)
// or, when the caller holds record shares:
{ $or: [ownerMatch, { id: { $in: grantedIds } }] }
Because that filter is AND-ed on top, an RLS policy can only ever NARROW a private object's readable set. It cannot add a row the sharing layer already excluded. So widening a private object has exactly two doors:
- the ADR-0057 depth scopes (
own_and_reports / unit / unit_and_below / org) — which widen by OWNER, not by predicate, so they cannot be conditioned on a field like visibility and would expose the rows the field marks private; and
- a
sys_record_share row, which only a criteria sharing rule writes.
Both doors are shut for a record-relative recipient, by different bolts. The lint hint is therefore actively misleading for the private case and probably wants a carve-out sentence either way.
Approvals already have the vocabulary. spec/src/automation/approval.zod.ts declares manager as a first-class approver source — "Submitter's manager (sys_user.manager_id)", resolved by the engine at runtime — alongside field (derive the principal from a field on the record). Sharing has neither. The concept exists on the platform; it just is not reachable from the surface that grants record access.
What would close it
Sketches, not a design ask — the shape matters more than the spelling:
ShareRecipientType gains manager (the matched record's owner's manager, via sys_user.manager_id) and field (sharedWith: { type: 'field', value: 'assignees' } — the user or users named by that column, honouring multiple: true).
expandRecipient becomes per-record for those two members. That is the real cost: the current materialiser expands once per rule and writes N share rows; a record-relative recipient expands once per matched record. The owner-type rule was removed from the authoring surface for a related reason (live-membership-dependent re-materialisation), so this needs the same "what re-materialises when the graph moves" answer — when a person's manager changes, their marked rows must re-point.
- Whatever lands, it must not validate-and-do-nothing (ADR-0078/ADR-0049): if per-record expansion is not on the table, an author-time diagnostic saying so is worth more than silence, because today the only feedback is that the desired rule is simply unwritable and the tempting wrong rule lints clean.
Impact today
duly ships duly_log_entry.visibility with a My manager option that stores correctly and grants nothing, and duly_assignment readable only by the person who raised it. Both are fail-closed, both are documented in the application's docs/deployment/security.md as blocked on this issue. Measured on @objectstack/spec 17.2.0 and the 17.2.0 runtime.
Found while implementing the security model of the
dulymetadata application (objectstack-ai/duly#8). Filed unassigned.The shape
A criteria sharing rule can express any predicate over the RECORD, but its recipient is a single static principal resolved once per rule:
plugin-sharing'sexpandRecipientreadsrule.recipient_idand expands it without ever looking at the matched record, so everysys_record_sharerow a rule materialises names the same recipients. There is no way to say "share this row with the principal named by a field ON this row" — or with a principal DERIVED from one, such as the owner's manager.Two independent requirements in one small application hit this in the same afternoon:
duly_log_entrywherevisibility == 'manager'→ that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.duly_assignment→ the users in itsassigneesfield. Same shape: the recipient is a value on the matched record.Why the near-misses are not answers
positionis not "the manager". The only recipient that resolves to managers at all isposition: '<manager position>', which shares every matched row with every holder of that position across the tenant — the skip-level, the manager two teams over, everyone. For a personal work log that is precisely the disclosure the feature exists to prevent, so the application ships the grant UNAUTHORED and fails closed rather than take it.RLS is not an answer for a
privateobject, despite the lint hint saying so.@objectstack/lint'ssharing-rule-runtime-variable-conditiontells authors:That advice does not hold on a private object, because the two layers are AND-composed, not OR-composed.
plugin-securitygetReadFilter:and
plugin-sharingbuildReadFilter, for a private object with an owner column, returnsBecause that filter is AND-ed on top, an RLS policy can only ever NARROW a private object's readable set. It cannot add a row the sharing layer already excluded. So widening a private object has exactly two doors:
own_and_reports/unit/unit_and_below/org) — which widen by OWNER, not by predicate, so they cannot be conditioned on a field likevisibilityand would expose the rows the field marks private; andsys_record_sharerow, which only a criteria sharing rule writes.Both doors are shut for a record-relative recipient, by different bolts. The lint hint is therefore actively misleading for the
privatecase and probably wants a carve-out sentence either way.Approvals already have the vocabulary.
spec/src/automation/approval.zod.tsdeclaresmanageras a first-class approver source — "Submitter's manager (sys_user.manager_id)", resolved by the engine at runtime — alongsidefield(derive the principal from a field on the record). Sharing has neither. The concept exists on the platform; it just is not reachable from the surface that grants record access.What would close it
Sketches, not a design ask — the shape matters more than the spelling:
ShareRecipientTypegainsmanager(the matched record's owner's manager, viasys_user.manager_id) andfield(sharedWith: { type: 'field', value: 'assignees' }— the user or users named by that column, honouringmultiple: true).expandRecipientbecomes per-record for those two members. That is the real cost: the current materialiser expands once per rule and writes N share rows; a record-relative recipient expands once per matched record. Theowner-type rule was removed from the authoring surface for a related reason (live-membership-dependent re-materialisation), so this needs the same "what re-materialises when the graph moves" answer — when a person's manager changes, their marked rows must re-point.Impact today
dulyshipsduly_log_entry.visibilitywith aMy manageroption that stores correctly and grants nothing, andduly_assignmentreadable only by the person who raised it. Both are fail-closed, both are documented in the application'sdocs/deployment/security.mdas blocked on this issue. Measured on@objectstack/spec17.2.0 and the 17.2.0 runtime.