Repository navigation
Commit 4a25192
feat(sharing): a share or sharing rule on a public object reaches an external member of the record's organization (#25591)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9sUfiwLBMYyhPAkzpzFo9P)_
Closes #25549
Clause-②: yes (widening: the grant door and the materialiser admit one
recipient population on effective-public objects)
Metadata-protocol: semantic
Letter A of the ruling on #25549 (record 6377844010, re-recorded
6378238137 after Tesla's item-by-item approval of 2026-10-24T01:28Z). An
external member (`sys_member.role` exactly `external`) meets an
owner-only floor on every object since #25541, so on a public object a
share is how it reaches one record. The D7 pre-flight still refused that
share as one "no gate would consult". This PR makes that verdict read
the recipient's population.
## What changes
| Item | Where | What |
|---|---|---|
| The verdict | `plugin-sharing` `sharing-service.ts`
`inertGrantReason(object, recipient?)` | On an effective-`public` object
with a real `owner_id` (not a federated phantom anchor), a `user`
recipient whose `sys_member.role` in the record's organization is
exactly `external` is admitted. Every other recipient keeps the 422
`SHARING_NOT_ENABLED`, now worded "consulted only for an external member
of the record's organization, and the recipient is not one".
`controlled_by_parent` (ADR-0111 D7.2), owner-less, phantom-anchor and
bypass objects are refused for every recipient, as before. |
| Where it is asked | `grant()` | The object half stays first and
caller-free. The recipient half runs on both paths after the
organization is resolved (the rule's threaded organization, else the
record's; the record's, else the session's for a direct grant). On the
direct path it runs after the D1 management check, so a caller who
cannot manage the record learns nothing about the recipient. No
organization resolved is refused with its own named reason. |
| The population test | `isExternalMemberIn` | The stage-1 doors' test
(`isExternalMembershipRole` in plugin-auth): a `sys_member` row in that
organization whose role list is exactly `external`. Mirrored locally,
since plugin-sharing does not depend on plugin-auth. |
| Rule materialisation | `sharing-rule-service.ts` | The per-recipient
refusal is marked (`isInternalRecipientRefusal`), and all three passes
(`reconcile`, `reconcileForRecord`, `reconcileRuleForUser`) skip that
recipient instead of aborting. A pass that skipped any carries one
`note` line on its result and logs the count once (`[sharing-rule]
internal recipients skipped on a public object`). Every other D7 refusal
still aborts the rule's pass, as before. |
| Lint | `validate-sharing-rule-enforceability.ts`,
`rule-explanations.ts` | `sharing-rule-object-not-shareable` on a public
anchor drops from `error` to `warning`: "so the rule is effective for
external recipients only". The `controlled_by_parent` arm stays an
`error`. The `os explain` entry says when to keep the rule and when to
change the object's `sharingModel`. |
| Docs | `content/docs/permissions/sharing-rules.mdx` | One paragraph
under "External principals". `permissions/index.mdx` and the app-crm
comment said the rule "fails the build"; both now say what it does. |
| Changeset | `.changeset/25549-external-share-public-object.md` |
`@objectstack/plugin-sharing` and `@objectstack/lint` `minor`, with both
declaration lines. |
### How the rule's report surfaces (measured)
`POST /api/v1/sharing/rules/:idOrName/evaluate` answers `evaluateRule`'s
return value unfiltered (`sharing-routes.ts`, `res.json(result)`), so
the `note` reaches the admin who evaluates a rule. The boot backfill,
the write hooks and the re-grant queues read only the counts, so for
them the log line is the report. `note` is declared on plugin-sharing's
own `SharingRuleReconcilePassResult`, not on the spec's
`SharingRuleEvaluationResult`.
## Verification (head `a1260e57`)
- `plugin-sharing`, `system-caller-inert-grant.test.ts`, new block (9
cases): an external recipient is admitted, its row carries the record's
organization and `buildReadFilter` for that external returns exactly
that record; an internal recipient gets `SHARING_NOT_ENABLED:` and no
row; a user external in org B and a member of org A is refused on an org
A record, and the reverse user is admitted; a role list
`external,member` is refused; a system caller gets the same two answers;
a caller without management rights gets `PERMISSION_DENIED` first; a
team rule expanding to one external and one staff member on two gold
records writes two rows for the external, `grantsRefused: 0`, the note,
and one log line with `skipped: 2`; a rule to externals only has no
note; an owner-less public object still refuses an external.
- The #8207 pins in the same file: the rule-pass refusal block now
covers the four object classes that still abort, and a new case pins
that a rule on the public object with an internal recipient mints
nothing and carries the note.
- `pnpm --filter @objectstack/plugin-sharing typecheck` exit 0; the
suite: 55/55 files, 1233 tests passed.
- `pnpm --filter @objectstack/lint typecheck` exit 0; the suite: 158/158
files, 7237 tests passed.
- Dogfood, `external-member-directory.dogfood.test.ts` on a showcase
boot, two new cases: the owner POSTs a share of one `showcase_invoice`
(`public_read_write`) to an external over
`/data/showcase_invoice/ID/shares` and gets 201 with the organization
stamped; the external read zero invoices before and exactly that one
after. The same share to an ordinary member answers 422 with
`error.code` `SHARING_NOT_ENABLED`, and no row is written. The file:
13/13. `tsc --noEmit` on the dogfood package exit 0.
- Ablation (committed fix first, `scripts/ablation-replace.mjs`): `if
(recipient === 'external') return null;` replaced so the external arm
returns a reason. Anchor 1 to 0, blob c1f616f to 5d5c8c31; the file
went red, 8 failed of 33; restored, blob equals HEAD, `git diff HEAD`
empty.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `a1260e57` derived 104 families; each ran
with its exit code captured before any pipe. First sweep: 101 exit 0, 3
exit 3 (PREREQUISITE NOT MET: the pinned control commit absent from the
shallow clone for `check-plugin-teardown-shape --self-test`; no
`client-react` dist for `check:skill-examples`; workspace dists for
`check:dual-build-cjs-loads`). After fetching that commit and a locked
workspace build, all three re-ran exit 0. `--ran` reconciliation: 104
derived, 104 run, 0 NOT-MEASURED, 0 UNRUN.
## Acceptance notes
- **The two confidence gaps.** The REST door and the rule evaluator both
have the organization at hand: `grant()` resolves it on both paths
before writing (commit 3f64fe6), and the recipient half reads it from
there. When neither path resolves one, the grant is refused with a named
reason, not judged against the session's organization.
- **Error order on a public object.** For a direct grant on a public
object the 422 now comes after the D1 management pre-flight, so a caller
who may not manage the record gets 403/404 where it used to get 422.
That is deliberate (the population is only read for an authorized
caller). On every other object the order is unchanged.
- **Existing inert rows.** A `source: 'rule'` row for an internal
recipient on a public object, minted by an older build, is still left
standing by a pass when its level is unchanged; it is never consulted
for that recipient.
- **Spec contract docblock.** `ISharingService.grant`'s docblock in
`packages/spec/src/contracts/sharing-service.ts` still says a share on a
public object "enforces nothing". It is outside this claim's file
surface and is left for the seat to route with the ADR-0111 line drafted
in the os-dev-report.
- **ADR-0111 D7.** The dated line is Tier H and is not in this PR; its
draft is in the os-dev-report on the card.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ba6912c commit 4a25192
12 files changed
Lines changed: 531 additions & 82 deletions
File tree
- .changeset
- content/docs/permissions
- examples/app-crm
- packages
- lint/src
- plugins/plugin-sharing/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
47 | | - | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
137 | 137 | | |
138 | 138 | | |
139 | 139 | | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
140 | 146 | | |
141 | 147 | | |
142 | 148 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
112 | | - | |
113 | | - | |
114 | | - | |
115 | | - | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
116 | 117 | | |
117 | 118 | | |
118 | 119 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2222 | 2222 | | |
2223 | 2223 | | |
2224 | 2224 | | |
2225 | | - | |
2226 | | - | |
2227 | | - | |
| 2225 | + | |
| 2226 | + | |
| 2227 | + | |
| 2228 | + | |
| 2229 | + | |
2228 | 2230 | | |
2229 | 2231 | | |
2230 | | - | |
| 2232 | + | |
| 2233 | + | |
2231 | 2234 | | |
2232 | 2235 | | |
2233 | 2236 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1670 | 1670 | | |
1671 | 1671 | | |
1672 | 1672 | | |
1673 | | - | |
1674 | | - | |
1675 | | - | |
1676 | | - | |
1677 | | - | |
1678 | | - | |
1679 | | - | |
1680 | | - | |
1681 | | - | |
1682 | | - | |
1683 | | - | |
1684 | | - | |
1685 | | - | |
1686 | | - | |
1687 | | - | |
1688 | | - | |
| 1673 | + | |
| 1674 | + | |
| 1675 | + | |
| 1676 | + | |
| 1677 | + | |
| 1678 | + | |
| 1679 | + | |
| 1680 | + | |
| 1681 | + | |
| 1682 | + | |
| 1683 | + | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
| 1692 | + | |
| 1693 | + | |
| 1694 | + | |
| 1695 | + | |
| 1696 | + | |
| 1697 | + | |
1689 | 1698 | | |
1690 | 1699 | | |
1691 | 1700 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
319 | 319 | | |
320 | 320 | | |
321 | 321 | | |
322 | | - | |
| 322 | + | |
323 | 323 | | |
324 | 324 | | |
325 | 325 | | |
326 | | - | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
327 | 329 | | |
328 | 330 | | |
329 | 331 | | |
| |||
333 | 335 | | |
334 | 336 | | |
335 | 337 | | |
336 | | - | |
337 | | - | |
| 338 | + | |
| 339 | + | |
338 | 340 | | |
339 | 341 | | |
340 | | - | |
341 | | - | |
| 342 | + | |
| 343 | + | |
342 | 344 | | |
343 | 345 | | |
344 | 346 | | |
| |||
654 | 656 | | |
655 | 657 | | |
656 | 658 | | |
657 | | - | |
| 659 | + | |
658 | 660 | | |
659 | 661 | | |
660 | 662 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
64 | 71 | | |
65 | 72 | | |
66 | 73 | | |
| |||
439 | 446 | | |
440 | 447 | | |
441 | 448 | | |
442 | | - | |
443 | | - | |
444 | | - | |
445 | | - | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
446 | 456 | | |
447 | 457 | | |
448 | 458 | | |
| |||
524 | 534 | | |
525 | 535 | | |
526 | 536 | | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
527 | 543 | | |
528 | | - | |
| 544 | + | |
529 | 545 | | |
530 | 546 | | |
531 | 547 | | |
532 | 548 | | |
533 | | - | |
| 549 | + | |
534 | 550 | | |
535 | | - | |
536 | | - | |
537 | | - | |
| 551 | + | |
| 552 | + | |
538 | 553 | | |
539 | 554 | | |
540 | 555 | | |
541 | | - | |
| 556 | + | |
542 | 557 | | |
543 | 558 | | |
544 | 559 | | |
| |||
0 commit comments