Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

Description

@os-warren

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:

  1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
  2. 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-securitygetReadFilter:

returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and plugin-sharingbuildReadFilter, 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

    Description

    @os-warren

    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:

    1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
    2. 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-securitygetReadFilter:

    returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

    and plugin-sharingbuildReadFilter, 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.

    Activity

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Metadata

    Metadata

    Assignees

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

      Description

      @os-warren

      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:

      1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
      2. 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-securitygetReadFilter:

      returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

      and plugin-sharingbuildReadFilter, 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.

      Activity

      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

      Metadata

      Metadata

      Assignees

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

        Description

        @os-warren

        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:

        1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
        2. 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-securitygetReadFilter:

        returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

        and plugin-sharingbuildReadFilter, 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.

        Activity

        Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

        Metadata

        Metadata

        Assignees

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

          Description

          @os-warren

          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:

          1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
          2. 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-securitygetReadFilter:

          returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

          and plugin-sharingbuildReadFilter, 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.

          Activity

          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

          Metadata

          Metadata

          Assignees

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

            Description

            @os-warren

            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:

            1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
            2. 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-securitygetReadFilter:

            returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

            and plugin-sharingbuildReadFilter, 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.

            Activity

            Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

            Metadata

            Metadata

            Assignees

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

              Description

              @os-warren

              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:

              1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
              2. 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-securitygetReadFilter:

              returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

              and plugin-sharingbuildReadFilter, 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.

              Activity

              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

              Metadata

              Metadata

              Assignees

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                Sharing rules cannot name a RECORD-RELATIVE recipient (the owner's manager; the value of a user field on the matched record), and no other declarative surface can widen a private object either #14103

                Description

                @os-warren

                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:

                1. duly_log_entry where visibility == 'manager' → that person's manager and nobody else. The predicate lowers fine. The recipient does not exist.
                2. 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-securitygetReadFilter:

                returnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

                and plugin-sharingbuildReadFilter, 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.

                Activity

                Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                Metadata

                Metadata

                Assignees

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions