Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
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
Open
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
46 changes: 46 additions & 0 deletions .changeset/system-write-sharing-materialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): let system writes materialize sharing rules — drop the `isSystem` skips in `bindRuleHooks` (#13533)

A criteria sharing rule declares a promise: `status == "approved"` means the
named recipients can see the record. `bindRuleHooks` did not keep that promise
when the platform was the writer. Its `afterInsert` and `afterUpdate` hooks
returned early on `ctx.session.isSystem`, so a system-context write that moved a
record INTO a rule's criteria materialized no `sys_record_share` row at all.

The path that made this a real outage rather than a boot-time curiosity is
approval write-back. An approval node with `lockRecord: true` mirrors the
decision onto the subject record under a system context — that is the only write
that can land while the record is locked — so a manager approving a leave
request produced exactly the skip above. A teammate who relied on the rule could
not see the record, and nothing repaired it until somebody ran
`POST /api/v1/sharing/rules/:id/evaluate` or restarted the server. The failure
was invisible from a manager or admin view, which reads through the profile's
`viewAllRecords` grant and never consults a sharing rule at all.

**What changes.** A system write now materializes exactly as a user write does —
grants on the way into a rule's criteria, revokes on the way out, per record,
synchronously with the write. Three early returns are gone: the two on
`afterInsert` / `afterUpdate`, and the one on the `beforeUpdate` / `beforeDelete`
row-set stash they depended on (without that stash an `after` hook reads the row
set as *unbounded*, which would have turned every single-row system update into
an object-wide revoke plus an asynchronous re-grant). The INFO line that
announced the skip — `[sharing-rule] sharing materialisation skipped for isSystem
writes; re-evaluate rules or restart to backfill` — is retired with it, along
with the `SYSTEM_WRITE_SKIP_NOTICE` constant, which was not exported from the
package entry point. Operationally this means seed- and import-time system writes
on rule-covered objects now pay per-record sharing evaluation at write time — the
cost a user write of the same shape has always paid, with the
`kernel:bootstrapped` backfill still reconciling behind it.

**What does not change.** `afterDelete` still skips system writes, on separate
grounds: what it skips is revocation, and `record-share-cascade.ts` delivers that
on every sharing-capable object, stashing for system writes on its own account.
The `kernel:bootstrapped` boot backfill still runs and is still needed — it
reaches rows no hook saw, and it is the only pass that purges a deactivated
rule's grants. No new option, flag or declarative switch: the fix is the removal.

No published export moves; `SYSTEM_WRITE_SKIP_NOTICE` was never re-exported from
the package entry point.
52 changes: 35 additions & 17 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **109
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-six exist. Every documented app-side bug
no hint that the other hundred-and-three exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand DownExpand Up@@ -124,18 +124,18 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **20 of the 109 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 30 | **Sharing-rule REVOCATION is skipped on the record-`afterDelete` hook** — and on that hook only | Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**. The payload belongs to another subscriber: `record-share-cascade.ts` binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ **Grant MATERIALISATION no longer asks** — the `afterInsert` / `afterUpdate` skips, and the `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | `rule-hooks.ts:292` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 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:449`, `:503`, `:507`, `:580`, `:610` |
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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 DownExpand Up@@ -217,14 +217,32 @@ should recognise it instead of re-deriving it.
(`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.

2. **Sharing materialisation is skipped, and now says so.** Row 30 produces
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
and it is recorded here rather than deleted.** This entry used to describe
"configured but inert": nine installed sharing rules, matching records,
correct positions — and `sys_record_share` empty. The boot backfill does
eventually fix it, so the behaviour is not wrong; it was undiscoverable.
The INFO notice tracked as #6783 **has since shipped**
(`rule-hooks.ts:274`, `:293` call `noteSystemWriteSkipped`), so the skip is
now observable — the earlier edition of this page said it was not, and that
sentence was already stale when the anchors were re-censused.
correct positions — and `sys_record_share` empty, repaired only by a
re-evaluation or a restart. The reading that made it a rough edge rather
than a defect was that the boot backfill eventually fixes it, so the
behaviour was correct and merely undiscoverable; #6783 shipped an INFO
notice on that reasoning.

That reading did not survive contact with a runtime write. An approval
node's write-back is a system write — `lockRecord: true` means only a
platform write can land while the record is locked — and it happens long
after boot, so no backfill was coming: a manager approved a request and the
teammate who depended on the criteria rule `status == "approved"` could not
see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was
a **bug**, because a sharing rule's declared semantics is a published
promise and `isSystem` names the operator, never a consequence that need not
happen. Both materialisation skips and the notice that announced them are
gone; row 30 is now the `afterDelete` skip alone, which survives on the
separate ground that another subscriber delivers that payload.

⚠️ The observability half of that reading is worth keeping in mind
independently: the only signal a builder ever had was one INFO line, and
nothing in the docs or the tests said "after approval, when does the team
see it?". A compensating path that exists but that nobody can be expected to
know about is not a compensating path.

3. **Strict write observability is inert under elevation.** Row 22: a caller
that asked to be told loudly about dropped fields is told nothing, because
Expand All@@ -251,7 +269,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 109 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand DownExpand Up@@ -308,12 +326,12 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 115 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **109** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 105 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| — carry the flag onward only (rows 62–65 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-sharing/src/boot-backfill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,17 @@
/**
* [#2926 ③] Boot backfill of sharing-rule grants.
*
* Rule grants are materialized by write hooks, which deliberately skip
* `isSystem` writes (rule-hooks.ts) — so records created by the boot-time
* seed loader (always `isSystem`) never produced `sys_record_share` rows:
* demo data shipping with matching sharing rules was broken out of the box
* until an admin "touched" each record at runtime. `backfillRuleGrants`
* reconciles every active rule once per boot, idempotently.
* Rule grants are materialized by write hooks, and this pass reconciles every
* rule once per boot, idempotently, for the rows those hooks did not reach.
*
* [#13533] The original reason for that gap was that the hooks skipped
* `isSystem` writes, so boot-time seed rows (always `isSystem`) never produced
* `sys_record_share` rows and demo data shipped inert. That skip is gone — a
* system write now materialises like any other — but this pass is NOT
* obsolete, and the two remaining reasons are what these tests cover: rows
* written while a rule was inactive or before its hooks were bound are still
* unreached by any hook, and this is the only pass that PURGES a deactivated
* rule's grants (#4433), which no write-path hook can do.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand Down
18 changes: 11 additions & 7 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -205,13 +205,17 @@ export function writeCanChangeExpansion(objectName: string, event: string, hookC
* nothing here needs the pre-write state. What the revoke needs is the tree as
* it is NOW, and the write is what makes that true.
*
* **System writes are NOT skipped**, which is the opposite of what
* `bindRuleHooks` does and is deliberate. Its skip is about grant
* MATERIALISATION, which the boot backfill re-does anyway. Here the payload is
* REVOCATION, and the realistic production trigger for a re-parent is an HRIS
* or directory sync — a system write. Skipping those would leave the hole open
* on the very path most likely to open it. `primary-bu-projection.ts` reached
* the same conclusion for the same table.
* **System writes are NOT skipped**, and this file's hooks never carried an
* `isSystem` branch to skip them with. `bindRuleHooks` no longer skips them
* either: its `afterInsert` / `afterUpdate` materialisation skips, and the
* `before*` stash skip that fed them, were removed by the 2026-08-31 ruling on
* #13533, so a system write there materialises grants exactly as a user write
* does — the one skip it keeps is `afterDelete` revocation, which
* `record-share-cascade.ts` delivers instead. Here the payload is REVOCATION,
* and the realistic production trigger for a re-parent is an HRIS or directory
* sync — a system write. Skipping those would leave the hole open on the very
* path most likely to open it. `primary-bu-projection.ts` reached the same
* conclusion for the same table.
*/
export function bindBusinessUnitTreeRecompute(
engine: MinimalEngine,
Expand Down
33 changes: 29 additions & 4 deletions packages/plugins/plugin-sharing/src/bulk-recompute.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,11 @@ import {
interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
/** A non-system session — the hooks deliberately skip `isSystem` writes. */
/**
* The default session for this suite: an interactive admin. [#13533] It is no
* longer the only session the hooks act on — a system write takes the same
* path — so this is a default, not a precondition.
*/
const ADMIN_SESSION = { isSystem: false, userId: 'admin' };

type HookEntry = { event: string; handler: (ctx: any) => any; options: Row };
Expand DownExpand Up@@ -343,16 +347,37 @@ describe('#4779 predicate (multi) writes recompute sharing rules', () => {
expect(ruleShares(engine)).toEqual([]);
});

it('leaves system-context bulk writes to the boot backfill, as before', async () => {
/**
* [#13533] REVERSED, not deleted. This test used to read:
*
* it('leaves system-context bulk writes to the boot backfill, as before')
* … expect(ruleShares(engine)).toHaveLength(2); // untouched by the hooks
*
* and it pinned a real behaviour: `bindRuleHooks` skipped `session.isSystem`
* on `afterUpdate` (and on the `before*` stash that fed it), so a system bulk
* write changed no grants and `kernel:bootstrapped`'s backfill owned the
* repair. The maintainer reversed that on 2026-08-31 (verbatim, untranslated:
* 「裁定:系统写参与逐记录共享物化 —— 删除 `plugin-sharing` 两个钩子里的
* `isSystem` 跳过,⛔ 不加声明式开关、不以文档代修。」), on the ground that a
* rule's declared semantics is a published promise and `isSystem` names the
* OPERATOR, never a consequence that need not happen.
*
* So the same write is now expected to do what the identical user-context
* write two tests up does — and the assertion below is deliberately the same
* shape as that one, because "the same" is the whole content of the ruling.
*/
it('recomputes system-context bulk writes too — identically to a user write (#13533)', async () => {
seed(2, 'east');
await rules.evaluateRule('srule_east', SYS);
expect(ruleShares(engine)).toHaveLength(2);

await engine.simulateBulkUpdate(
'opportunity', { region: 'east' }, { region: 'west' }, { isSystem: true },
);

// Untouched by the hooks — `kernel:bootstrapped`'s backfill owns seeds.
expect(ruleShares(engine)).toHaveLength(2);
// Moved OUT of the criteria, so the grants are revoked — exactly as the
// user-context twin above revokes them.
expect(ruleShares(engine)).toEqual([]);
});

it('does not touch manual shares — only rule-materialised ones', async () => {
Expand Down
Loading
Loading