From 3aae2160ac61a7e1f758b34436f6ea216a69338b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:05:57 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx --- .changeset/25549-external-share-followup-wording.md | 11 +++++++++++ ...rd-share-management-authority-and-verb-boundary.md | 2 ++ .../plugin-sharing/src/sharing-rule-service.ts | 8 +++++--- .../src/system-caller-inert-grant.test.ts | 10 +++++----- packages/spec/src/contracts/sharing-service.ts | 8 +++++--- 5 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 .changeset/25549-external-share-followup-wording.md diff --git a/.changeset/25549-external-share-followup-wording.md b/.changeset/25549-external-share-followup-wording.md new file mode 100644 index 00000000000..f5e1a9f34ed --- /dev/null +++ b/.changeset/25549-external-share-followup-wording.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/spec': patch +--- + +docs: the sharing-rule pass note and the `ISharingService.grant` contract say what a share on a public object does + +Clause-②: no +Metadata-protocol: none + +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. diff --git a/docs/adr/0111-record-share-management-authority-and-verb-boundary.md b/docs/adr/0111-record-share-management-authority-and-verb-boundary.md index 7e8be31dbfb..5ebe8492802 100644 --- a/docs/adr/0111-record-share-management-authority-and-verb-boundary.md +++ b/docs/adr/0111-record-share-management-authority-and-verb-boundary.md @@ -137,6 +137,8 @@ Three write-time refusals so a persisted share always means something: 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. 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). + + *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. 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). ### D8 — Share-link re-share semantics are made explicit diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 3c89c6d4f88..c1c9152c2b5 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -395,7 +395,8 @@ export interface SharingRuleReconcilePassResult extends SharingRuleEvaluationRes * [#25549] One line, present only when the pass skipped recipients: the rule * is anchored on a public object, where a share reaches an `external` member * of the record's organization only, so no row was written for the internal - * recipients it expands to. The count is in the pass's log line. + * recipients it expands to, nor for any recipient on a record whose + * organization could not be resolved. The count is in the pass's log line. */ note?: string; } @@ -405,8 +406,9 @@ type RuleGrantOutcome = 'granted' | 'refused' | 'internal'; /** [#25549] The pass's one-line note when it skipped internal recipients. */ function internalHalfNote(object: string): string { - return `"${object}" has a public sharing model: rows were written for external recipients only; ` - + 'the internal recipients this rule expands to already reach its records, so none was written for them'; + return `"${object}" has a public sharing model: rows were written only for external members of each record's ` + + 'organization; none for the other recipients this rule expands to (internal members already reach its ' + + 'records, and a record whose organization could not be resolved admits no recipient)'; } export interface SharingRuleServiceOptions { diff --git a/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts index a9b660a92f5..6304bcc8b91 100644 --- a/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts +++ b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts @@ -192,8 +192,7 @@ const INERT_OBJECTS = Object.keys(INERT_SCHEMAS); * [#25549] The classes on which a RULE's pass still aborts. On the public one * (`whiteboard`) the grant service admits an `external` member of the record's * organization and refuses everyone else, so a rule pass there skips its - * internal recipients and goes on — pinned below and in - * `external-recipient-public-object.test.ts`. + * internal recipients and goes on — pinned below and in block 3 of this file. */ const RULE_ABORTING_OBJECTS = INERT_OBJECTS.filter((o) => o !== 'whiteboard'); @@ -364,7 +363,7 @@ describe('[#8207] the sharing-rule evaluator can no longer materialise inert row // a skip the pass absorbs and reports, not an abort. const result = await rules.evaluateRule('share_whiteboard_gold', SYS); expect(result).toMatchObject({ matchedRecords: 1, expandedUsers: 1, grantsCreated: 0, grantsRefused: 0 }); - expect(result.note).toMatch(/external recipients only/); + expect(result.note).toMatch(/written only for external members of each record's organization/); expect(engine._tables.sys_record_share ?? []).toHaveLength(0); }); @@ -555,8 +554,9 @@ describe('[#25549] a public object admits a share to an external member of the r // member, and the skip is neither a refusal nor an abort. expect(result).toMatchObject({ matchedRecords: 2, expandedUsers: 2, grantsCreated: 2, grantsRefused: 0 }); expect(result.note).toBe( - '"board" has a public sharing model: rows were written for external recipients only; ' - + 'the internal recipients this rule expands to already reach its records, so none was written for them', + '"board" has a public sharing model: rows were written only for external members of each record\'s ' + + 'organization; none for the other recipients this rule expands to (internal members already reach its ' + + 'records, and a record whose organization could not be resolved admits no recipient)', ); expect((engine._tables.sys_record_share ?? []).map((r) => r.recipient_id)).toEqual(['usr_ext', 'usr_ext']); // The count reaches the log once per pass, not once per skipped grant. diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 0106b551ca8..49438e806b5 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -399,9 +399,11 @@ export interface ISharingService { * recipient (`VALIDATION_FAILED` — other recipient types are not enforced by * any gate and are refused rather than persisted inert, ADR-0078), and an * object in an enforcing sharing posture (`SHARING_NOT_ENABLED` — a share on - * a public / bypass / owner-less / `controlled_by_parent` object enforces - * nothing). Upserts match on `(object, record, recipient, source)` so a - * manual grant never clobbers a rule-materialised row (D7). + * a bypass / owner-less / `controlled_by_parent` object enforces nothing; on + * a public object it is admitted only for a recipient who is an `external` + * member of the record's organization, since that population's owner-only + * floor consults it). Upserts match on `(object, record, recipient, source)` + * so a manual grant never clobbers a rule-materialised row (D7). */ grant(input: GrantShareInput, context: ExecutionContext): Promise; From 297cfe51f80f51d7b0eaeb972c324326a08cbe38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:41:59 +0000 Subject: [PATCH 2/2] docs(sharing): the sharing-service page names the external-recipient arm of SHARING_NOT_ENABLED The docs-drift check named this page: its SHARING_NOT_ENABLED entry still listed a public sharing model as refused for every recipient. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx --- content/docs/kernel/runtime-services/sharing-service.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index 67069ebf986..81379f3d0d6 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -60,7 +60,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`. - `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). On the sharing-**rule** surface (`ISharingRuleService`, declared in the same canonical source — `listRules` / `getRule` / `defineRule` / `deleteRule` / `evaluateRule`) the same code carries a second condition: the caller holds `manage_sharing` but their session resolves **no active organization**, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (`manage_platform_settings`, or the `platform_admin` position) are unaffected — see [Rule administration](/docs/permissions/sharing-rules). - `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record. - `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead. -- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit). +- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult for the recipient (a public sharing model, unless the recipient is an `external` member of the record's organization, whose owner-only floor does consult it; no `owner_id` field; a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit). ## Enforcement is automatic — do not re-check it in a hook