Skip to content

Commit 8654435

Browse files
docs(sharing): record the external-recipient arm on ADR-0111 D7 and the grant contract (#25603)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9sUfiwLBMYyhPAkzpzFo9P)_ Part of #25549 Clause-②: no Metadata-protocol: none The Tier H follow-up to #25591, which landed letter A of the ruling on #25549 (record 6377844010): on a public object, a share or sharing rule is admitted for a recipient who is an `external` member of the record's organization. This PR records that decision where the governing texts live, and carries the two wording nits from the seat's contract review of #25591. ## 维护者速读(草稿) **改了什么:** 只改文字,不改行为。ADR-0111 D7 第 2 条加一行带日期的修订,写明「全员可读写」对象上的分享可以给该组织的外部成员(供应商门户用户),其他人照旧拒绝;`ISharingService.grant` 的接口说明同步改正;共享规则跳过收件人时的那句提示,补上「记录所属组织解析不出来」这种情况;一处测试注释里写错的文件名改正。 **为什么改:** #25591 已按裁决 A 落地,但 ADR 和接口说明仍写着「公开对象上的分享永远无效」,以后读文档的人(包括 AI)会被误导。ADR 属于受管文件,需要单独的 PR 由你批准。 **风险与代价(含回滚):** 无运行时风险,提示文字改了一句(没有程序解析这句话)。回滚即还原这一个提交。 **席位意见:** **你要做的:** 看一眼 ADR 修订那一段的措辞,同意即批准合并。 ## What changes - **ADR-0111 D7 item 2** (`docs/adr/0111-record-share-management-authority-and-verb-boundary.md`): one dated amendment paragraph under the item, the text drafted in the os-dev-report of #25591 (comment 6378915159 on #25549). - **`ISharingService.grant` docblock** (`packages/spec/src/contracts/sharing-service.ts`): "a share on a public / bypass / owner-less / `controlled_by_parent` object enforces nothing" becomes "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". Comment only. - **Review nit 1, the pass note** (`plugin-sharing` `sharing-rule-service.ts` `internalHalfNote`): the per-recipient refusal a pass skips is also raised when a record's organization cannot be resolved, which admits no recipient. The note now says so: "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)". The `note` field's docblock says the same. Two test assertions follow the text. - **Review nit 2** (`system-caller-inert-grant.test.ts`, the `RULE_ABORTING_OBJECTS` comment): it named `external-recipient-public-object.test.ts`, which does not exist; it now points at block 3 of the same file. - **Changeset** `.changeset/25549-external-share-followup-wording.md`: `@objectstack/plugin-sharing` and `@objectstack/spec` `patch`. ## Verification (head `3aae2160`; re-reconciled at `297cfe51` after the sharing-service.mdx drift fix: 123 derived, 123 run, 0 NOT-MEASURED) - `pnpm --filter @objectstack/plugin-sharing typecheck` exit 0; the suite under `os-verify-lock`: 55/55 files, 1233 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `3aae2160` derived 100 families, each run with its exit code captured before any pipe. First sweep: 98 exit 0, 2 exit 3 (PREREQUISITE NOT MET: workspace dists for `check:dual-build-cjs-loads` and `check:i18n`); after `check:type-check-debt`'s locked workspace build, both re-ran exit 0. `--ran` reconciliation: 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6021b5e commit 8654435

6 files changed

Lines changed: 29 additions & 12 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.

‎content/docs/kernel/runtime-services/sharing-service.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
6060
- `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).
6161
- `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.
6262
- `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.
63-
- `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).
63+
- `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).
6464

6565
## Enforcement is automatic — do not re-check it in a hook
6666

‎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)