Skip to content

Commit 3aae216

Browse files
committed
docs(sharing): record the external-recipient arm on ADR-0111 D7 and the grant contract
ADR-0111 D7 item 2 gains the dated amendment for the external-recipient arm. ISharingService.grant's docblock stops saying a share on a public object enforces nothing. The rule pass note also covers a record whose organization could not be resolved, and a test comment no longer names a file that does not exist. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx
1 parent a57643d commit 3aae216

5 files changed

Lines changed: 28 additions & 11 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
docs: the sharing-rule pass note and the `ISharingService.grant` contract say what a share on a public object does
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A follow-up to the external-share change. The one-line `note` a sharing-rule pass carries when it skips recipients on a public object now also covers a record whose organization could not be resolved, which admits no recipient. The `ISharingService.grant` docblock no longer says a share on a public object enforces nothing: it is admitted for an `external` member of the record's organization.

‎docs/adr/0111-record-share-management-authority-and-verb-boundary.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ Three write-time refusals so a persisted share always means something:
137137

138138
1. **`recipientType` is narrowed to `user`** at the grant boundary; anything else → **400**. The `group` / `position` / `unit_and_subordinates` / `guest` values are not enforced by any gate today — persisting them is the ADR-0078 inert-metadata trap. The vocabulary is consolidated to one list in the contract; non-`user` principals become a documented *future direction* (delivered via rule materialisation, which already expands `team` / `business_unit` into per-user rows), not an accepted-but-ignored input on the manual surface.
139139
2. **`grant()` validates the object is in an enforcing posture** — `effectiveSharingModel` is `private` or `read` **and** the object has an `owner_id` field. Otherwise → **422** (`SHARING_NOT_ENABLED`): a share on a public / bypass / owner-less object enforces nothing. `controlled_by_parent` is refused explicitly (Salesforce parity — a detail record is not independently shareable; its access follows the master).
140+
141+
*Amended 2026-10-24 (ruling 6377844010 on objectstack-ai/objectstack#25549, letter A; landed in #25591).* An external principal meets an owner-only floor on every object (ruling 6355206139, letter B), so a share row on a public object IS consulted for that population. On an object whose effective model is `public` and whose `owner_id` is real (not a federated phantom anchor), `grant()` therefore admits a `user` recipient whose `sys_member.role` in the record's organization is exactly `external`; every other recipient keeps the 422. The verdict reads the object and the recipient's population, never the granter. A sharing rule materialises rows for its external recipients only and reports the skipped half in one line. `controlled_by_parent` stays refused for every recipient.
140142
3. **Manual and rule shares coexist by `source`.** The upsert key becomes `(object, record, recipient, source)` so a manual grant no longer clobbers a `source: 'rule'` row and vice versa. When multiple rows grant the same recipient, the widest `access_level` wins (Salesforce's RowCause model — grants are additive, never subtractive).
141143

142144
### D8 — Share-link re-share semantics are made explicit

‎packages/plugins/plugin-sharing/src/sharing-rule-service.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,8 @@ export interface SharingRuleReconcilePassResult extends SharingRuleEvaluationRes
395395
* [#25549] One line, present only when the pass skipped recipients: the rule
396396
* is anchored on a public object, where a share reaches an `external` member
397397
* of the record's organization only, so no row was written for the internal
398-
* recipients it expands to. The count is in the pass's log line.
398+
* recipients it expands to, nor for any recipient on a record whose
399+
* organization could not be resolved. The count is in the pass's log line.
399400
*/
400401
note?: string;
401402
}
@@ -405,8 +406,9 @@ type RuleGrantOutcome = 'granted' | 'refused' | 'internal';
405406

406407
/** [#25549] The pass's one-line note when it skipped internal recipients. */
407408
function internalHalfNote(object: string): string {
408-
return `"${object}" has a public sharing model: rows were written for external recipients only; `
409-
+ 'the internal recipients this rule expands to already reach its records, so none was written for them';
409+
return `"${object}" has a public sharing model: rows were written only for external members of each record's `
410+
+ 'organization; none for the other recipients this rule expands to (internal members already reach its '
411+
+ 'records, and a record whose organization could not be resolved admits no recipient)';
410412
}
411413

412414
export interface SharingRuleServiceOptions {

‎packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,7 @@ const INERT_OBJECTS = Object.keys(INERT_SCHEMAS);
192192
* [#25549] The classes on which a RULE's pass still aborts. On the public one
193193
* (`whiteboard`) the grant service admits an `external` member of the record's
194194
* organization and refuses everyone else, so a rule pass there skips its
195-
* internal recipients and goes on — pinned below and in
196-
* `external-recipient-public-object.test.ts`.
195+
* internal recipients and goes on — pinned below and in block 3 of this file.
197196
*/
198197
const RULE_ABORTING_OBJECTS = INERT_OBJECTS.filter((o) => o !== 'whiteboard');
199198

@@ -364,7 +363,7 @@ describe('[#8207] the sharing-rule evaluator can no longer materialise inert row
364363
// a skip the pass absorbs and reports, not an abort.
365364
const result = await rules.evaluateRule('share_whiteboard_gold', SYS);
366365
expect(result).toMatchObject({ matchedRecords: 1, expandedUsers: 1, grantsCreated: 0, grantsRefused: 0 });
367-
expect(result.note).toMatch(/external recipients only/);
366+
expect(result.note).toMatch(/written only for external members of each record's organization/);
368367
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
369368
});
370369

@@ -555,8 +554,9 @@ describe('[#25549] a public object admits a share to an external member of the r
555554
// member, and the skip is neither a refusal nor an abort.
556555
expect(result).toMatchObject({ matchedRecords: 2, expandedUsers: 2, grantsCreated: 2, grantsRefused: 0 });
557556
expect(result.note).toBe(
558-
'"board" has a public sharing model: rows were written for external recipients only; '
559-
+ 'the internal recipients this rule expands to already reach its records, so none was written for them',
557+
'"board" has a public sharing model: rows were written only for external members of each record\'s '
558+
+ 'organization; none for the other recipients this rule expands to (internal members already reach its '
559+
+ 'records, and a record whose organization could not be resolved admits no recipient)',
560560
);
561561
expect((engine._tables.sys_record_share ?? []).map((r) => r.recipient_id)).toEqual(['usr_ext', 'usr_ext']);
562562
// The count reaches the log once per pass, not once per skipped grant.

‎packages/spec/src/contracts/sharing-service.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,11 @@ export interface ISharingService {
399399
* recipient (`VALIDATION_FAILED` — other recipient types are not enforced by
400400
* any gate and are refused rather than persisted inert, ADR-0078), and an
401401
* object in an enforcing sharing posture (`SHARING_NOT_ENABLED` — a share on
402-
* a public / bypass / owner-less / `controlled_by_parent` object enforces
403-
* nothing). Upserts match on `(object, record, recipient, source)` so a
404-
* manual grant never clobbers a rule-materialised row (D7).
402+
* a bypass / owner-less / `controlled_by_parent` object enforces nothing; on
403+
* a public object it is admitted only for a recipient who is an `external`
404+
* member of the record's organization, since that population's owner-only
405+
* floor consults it). Upserts match on `(object, record, recipient, source)`
406+
* so a manual grant never clobbers a rule-materialised row (D7).
405407
*/
406408
grant(input: GrantShareInput, context: ExecutionContext): Promise<RecordShare>;
407409

0 commit comments

Comments
 (0)