Skip to content

Commit dd65fca

Browse files
committed
fix(plugin-sharing): per-grant catch in both reconcile loops so a refused grant no longer aborts the pass
After #14484 `sys_record_share` is tenant-scoped in the #13491 ledger, so an organization-less system insert on it is refused loudly with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on a walled install. A platform-global sharing rule materialising a grant onto an organization-less record resolves no organization and meets that refusal; with no per-grant catch it propagated and that rule's reconcile pass aborted mid-loop, taking the pass's stale-row revocations with it. Those revocations are the security-relevant half: a stale over-grant persisted across every later pass, which met the same record and died in the same place. Both loops now attempt each grant individually. A refusal is logged with the rule, object, record, recipient and the engine's code, counted in the pass result, and the pass continues. The catch is narrow — only `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` is absorbed. `record-share-organization-stamp.test.ts` deliberately pins the abort on the other error a pass can meet here (the scoped update half answering `RECORD_NOT_FOUND`), a shape the 2026-09-02 contract review left standing; a catch-all would retire that decision as a side effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
1 parent 13b5200 commit dd65fca

4 files changed

Lines changed: 663 additions & 65 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
fix(plugin-sharing): one refused grant no longer aborts a sharing rule's reconcile pass — its stale-row revocations still run (#14754)
6+
7+
After #14484 `sys_record_share` is `tenant-scoped` in the #13491 ledger, so on a
8+
walled install an organization-less system insert on it is refused loudly with
9+
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (#8844). `SharingService.grant`
10+
resolves the organization on every path that can; a platform-global sharing
11+
rule (`organization_id = null`, its sweep unscoped) materialising a grant onto
12+
an organization-LESS record resolves none, and meets that refusal.
13+
14+
`SharingRuleService.reconcile` / `reconcileForRecord` had no per-grant catch, so
15+
the refusal propagated and **that rule's pass aborted mid-loop**. Two things
16+
were lost, and they are not equally serious:
17+
18+
- the remaining grants — recoverable, the next pass writes them;
19+
- **the stale-row revocations of that pass** — not recoverable by waiting,
20+
because every subsequent pass meets the same organization-less record and
21+
dies in the same place. A stale over-grant of that rule therefore persisted
22+
indefinitely, and the record kept aborting the pass until it was repaired by
23+
hand. That is the security-relevant half.
24+
25+
Measured while pinning this, and it sharpens the point: the engine returns
26+
organization-less rows **last** in a rule's criteria sweep (the driver's
27+
NULL-org compatibility arm is appended to the scoped arm). So a refused grant
28+
is nearly always one of the final attempts of a pass, and what an abort
29+
destroyed was hardly ever "the remaining grants" — it was almost entirely the
30+
revoke loop that runs after the whole upsert loop.
31+
32+
Both loops now attempt each grant individually. A refusal is logged with the
33+
rule, object, record, recipient and the engine's own code, counted, and the
34+
pass **continues** — the remaining grants and, above all, the stale-row
35+
revocations still run.
36+
37+
**The catch is deliberately narrow.** Only
38+
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` is absorbed; every other error
39+
rethrows unchanged. A catch-all would swallow real defects and report a pass
40+
that "completed" having written nothing. It would also silently retire a
41+
reviewed decision: `record-share-organization-stamp.test.ts` deliberately pins
42+
the abort on the OTHER error a reconcile pass can meet here — the scoped update
43+
half answering `RECORD_NOT_FOUND` for a row stamped with a different
44+
organization — which the 2026-09-02 contract review left standing on "loud
45+
beats a wrong count". Those three pins are unchanged and still green.
46+
47+
**Why `minor` rather than `patch`.** The repair is a bug fix, but it reports
48+
through a new key. `reconcile` / `reconcileForRecord` / `evaluateRule` /
49+
`evaluateAllForRecord` now return `SharingRuleReconcilePassResult` — the spec's
50+
`SharingRuleEvaluationResult` plus `grantsRefused: number` — and that type is
51+
newly exported from the package index. Purely additive: the contract in
52+
`@objectstack/spec` is untouched, its six declared fields are unchanged, and a
53+
consumer typed against `ISharingRuleService` keeps compiling as it did. Same
54+
shape as `fix(runtime): tell an action handler when its caller-scope record load
55+
was refused` (#14143), which shipped `minor` for the same reason.
56+
57+
`grantsRefused > 0` does **not** mean the pass failed. It means the pass met a
58+
record it cannot grant on and carried on — which is the whole point.

packages/plugins/plugin-sharing/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export {
2323
export {
2424
SharingRuleService,
2525
type SharingRuleServiceOptions,
26+
// [#14754] The spec's `SharingRuleEvaluationResult` plus the pass's
27+
// `grantsRefused` count. Additive: the six declared fields are unchanged, and
28+
// a consumer typed against the spec contract keeps compiling untouched.
29+
type SharingRuleReconcilePassResult,
2630
} from './sharing-rule-service.js';
2731
export {
2832
ShareLinkService,

0 commit comments

Comments
 (0)