Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/share-link-eligibility-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/spec": minor
---

fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them.
Shipped as `minor` under the repo's launch-window convention.

`ShareLinkService.createLink()` evaluated the object's declared
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
the audience gates, the password and record EXISTENCE — then served whatever
survived, under the system context, to a caller with no principal at all. So the
declaration read as a standing policy about which records may be reached
anonymously, while the platform held it at exactly one instant in a link's life.

The state the predicate reads is the state an editor changes. Publish an article
`published` + `public`, mint a link, then flip `audience` to `internal` or
`status` back to `draft`: the object's own policy now says the record is not
eligible for link sharing, and the old token kept resolving and kept serving the
record in full. The remedy was to revoke every link on the record by hand, which
first requires knowing they exist.

It also sat oddly beside its neighbour. In that same `resolveToken()`, the
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
so a **deleted** record stopped being served immediately while a
**reclassified** one did not — two failure directions in one door.

**What changed.** `resolveToken()` re-evaluates the predicate against the record
it is about to serve, through the same `assertEligible` the mint path calls, so
the two points cannot drift on strictness, on the declared-field binding, or on
which faults refuse. It is one read either way: when a predicate is declared the
existence probe's projection widens from `['id']` to the whole row instead of a
second query being issued, so an object with no `eligibility` key keeps the
exact probe it always had. Fail-closed, matching mint: a predicate that will not
compile, faults on the record, or answers anything other than `true` refuses.

**The refusal is deliberately indistinguishable.** For a caller who may hold
nothing but a token, telling "does not exist" apart from "revoked" apart from
"no longer eligible" is an existence oracle, so the redemption refusal is the
same undifferentiated `null` a revoked, expired or unknown token already gets —
no new error code, no new response branch, and no usage stamp. Over HTTP an
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
byte what a token that never existed receives. The readable reason a link died
is written to the server-side log instead.

**Operator impact.** Deployments upgrading across this change can feel it
immediately: any live link whose record has since moved out of its object's
`eligibility` predicate stops resolving, with no revocation event and no grace
period. That is the intent — the alternative is a declared policy the platform
does not hold — but it is worth measuring before rollout: an object's
`eligibility` predicate is the thing to read, and the links at risk are those on
records that no longer satisfy it. An operator who needs such links to keep
working must widen the predicate; there is no per-link opt-out, deliberately.
`redactFields` behaviour, the audience/password gates and objects that declare
no `eligibility` key are all untouched and pinned.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
24 changes: 24 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ publicSharing:
allowedPermissions: [view]
redactFields: [internal_notes]
maxExpiryDays: 30
eligibility: "record.status == 'published'"
```

**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
Expand All@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
`canManageShares`), or system context: a link someone else minted on your record
is your record's exposure to kill, not only its creator's.

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
anonymously**, not a mint-time check. The platform evaluates it when a link is
minted **and again on every redemption**, against the record it is about to
serve. So flipping a record out of the policy — `audience` to internal, `status`
back to draft — stops every token minted while it was in, immediately, with no
revocation step to remember. Fail-**closed** at both points: a predicate that
will not compile, or that faults on the record, refuses rather than assuming
consent. What an anonymous holder sees is the ordinary "invalid or expired"
answer, identical to the one an unknown token gets; the readable reason a link
died is written to the server log, never to the response.

<Callout type="warn">
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
record reclassified after a link was issued kept being served in full to
anyone holding the URL. Deployments upgrading across that change can feel it:
links minted while a record qualified stop resolving as soon as it stops
qualifying. That is the intent — the alternative was a declared policy the
platform did not hold — but if you depend on links surviving a
reclassification, widen the predicate rather than relying on the old
behaviour.
</Callout>

---

## 6. Field-Level Encryption
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,7 +431,7 @@ const result = ApiMethod.parse(data);
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |

### Nested Shape: `Object.actions[number]`

Expand Down
Loading
Loading