sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

Description

@os-trump

Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

What was measured

Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

  • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
  • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

So the eligibility policy is a mint-time gate, not a serving-time one.

Why that matters

The state the predicate reads is mutable, and it is exactly the state an editor changes:

  1. article is published + public → an author mints a public link → the link is handed out;
  2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
  3. the object's declared policy now says the record is not eligible for link sharing;
  4. the existing token still resolves, and the record is still served in full to a caller with no principal.

The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

Options

A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

Where this came from

hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

Metadata

Metadata

Assignees

Type

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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

    Description

    @os-trump

    Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

    What was measured

    Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

    • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
    • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

    So the eligibility policy is a mint-time gate, not a serving-time one.

    Why that matters

    The state the predicate reads is mutable, and it is exactly the state an editor changes:

    1. article is published + public → an author mints a public link → the link is handed out;
    2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
    3. the object's declared policy now says the record is not eligible for link sharing;
    4. the existing token still resolves, and the record is still served in full to a caller with no principal.

    The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

    This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

    Options

    A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

    B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

    C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

    Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

    ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

    Where this came from

    hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

    Metadata

    Metadata

    Assignees

    Type

    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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

      Description

      @os-trump

      Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

      What was measured

      Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

      • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
      • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

      So the eligibility policy is a mint-time gate, not a serving-time one.

      Why that matters

      The state the predicate reads is mutable, and it is exactly the state an editor changes:

      1. article is published + public → an author mints a public link → the link is handed out;
      2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
      3. the object's declared policy now says the record is not eligible for link sharing;
      4. the existing token still resolves, and the record is still served in full to a caller with no principal.

      The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

      This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

      Options

      A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

      B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

      C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

      Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

      ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

      Where this came from

      hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

      Metadata

      Metadata

      Assignees

      Type

      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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

        Description

        @os-trump

        Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

        What was measured

        Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

        • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
        • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

        So the eligibility policy is a mint-time gate, not a serving-time one.

        Why that matters

        The state the predicate reads is mutable, and it is exactly the state an editor changes:

        1. article is published + public → an author mints a public link → the link is handed out;
        2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
        3. the object's declared policy now says the record is not eligible for link sharing;
        4. the existing token still resolves, and the record is still served in full to a caller with no principal.

        The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

        This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

        Options

        A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

        B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

        C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

        Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

        ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

        Where this came from

        hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

        Metadata

        Metadata

        Assignees

        Type

        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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

          Description

          @os-trump

          Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

          What was measured

          Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

          • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
          • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

          So the eligibility policy is a mint-time gate, not a serving-time one.

          Why that matters

          The state the predicate reads is mutable, and it is exactly the state an editor changes:

          1. article is published + public → an author mints a public link → the link is handed out;
          2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
          3. the object's declared policy now says the record is not eligible for link sharing;
          4. the existing token still resolves, and the record is still served in full to a caller with no principal.

          The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

          This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

          Options

          A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

          B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

          C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

          Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

          ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

          Where this came from

          hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

          Metadata

          Metadata

          Assignees

          Type

          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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

            Description

            @os-trump

            Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

            What was measured

            Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

            • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
            • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

            So the eligibility policy is a mint-time gate, not a serving-time one.

            Why that matters

            The state the predicate reads is mutable, and it is exactly the state an editor changes:

            1. article is published + public → an author mints a public link → the link is handed out;
            2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
            3. the object's declared policy now says the record is not eligible for link sharing;
            4. the existing token still resolves, and the record is still served in full to a caller with no principal.

            The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

            This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

            Options

            A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

            B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

            C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

            Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

            ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

            Where this came from

            hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

            Metadata

            Metadata

            Assignees

            Type

            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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

              Description

              @os-trump

              Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

              What was measured

              Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

              • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
              • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

              So the eligibility policy is a mint-time gate, not a serving-time one.

              Why that matters

              The state the predicate reads is mutable, and it is exactly the state an editor changes:

              1. article is published + public → an author mints a public link → the link is handed out;
              2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
              3. the object's declared policy now says the record is not eligible for link sharing;
              4. the existing token still resolves, and the record is still served in full to a caller with no principal.

              The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

              This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

              Options

              A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

              B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

              C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

              Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

              ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

              Where this came from

              hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

              Metadata

              Metadata

              Assignees

              Type

              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: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608

                Description

                @os-trump

                Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.

                What was measured

                Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:

                • ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.
                • ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.

                So the eligibility policy is a mint-time gate, not a serving-time one.

                Why that matters

                The state the predicate reads is mutable, and it is exactly the state an editor changes:

                1. article is published + public → an author mints a public link → the link is handed out;
                2. someone notices it contains internal detail and flips audience to internal (or status back to draft);
                3. the object's declared policy now says the record is not eligible for link sharing;
                4. the existing token still resolves, and the record is still served in full to a caller with no principal.

                The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.

                This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.

                Options

                A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.

                B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.

                C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.

                Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.

                ⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.

                Where this came from

                hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.

                Metadata

                Metadata

                Assignees

                Type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions