Skip to content

Commit 0f94cc7

Browse files
claude[bot]claude
andauthored
feat(spec): ShareRecipientType gains field — the record-relative sharing recipient (#14103) (#15084)
* feat(spec): ShareRecipientType gains field — the record-relative sharing recipient Maintainer ruling 2026-09-02 (B): a criteria sharing rule may name a user-typed field on the matched record as its recipient; no manager member. Spec half only: enum member + describes, field-scoped value refinement, stored-row union mirror, pins, lint partition row, docs, changeset. Executor is the services half. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * chore(spec): regenerate sharing reference docs; keep the manager refusal pin independent of the accept set Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * docs(permissions): enforcement-status sentence tells the truth between the field recipient and its executor The permissions matrix said every authorable recipient type is enforced; between the field recipient landing and its per-record executor that is false. Say what is true: it parses, and the declared-rule bootstrap skips it with a logged warning until the executor lands — never silently, never wider. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53fc81f commit 0f94cc7

11 files changed

Lines changed: 268 additions & 36 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ShareRecipientType` gains `field` — a sharing rule can share each matched record with the user or users a field on that record names (#14103)
6+
7+
Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
8+
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
9+
name of a user-typed field on the shared object. Each record the rule's `condition`
10+
matches is shared with the user or users that column holds on that record; a field
11+
with `multiple: true` shares with every user it names; an empty column shares with
12+
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
13+
a `field` recipient expands once per matched record, and its grants re-materialise
14+
when the record's own write changes that column.
15+
16+
There is deliberately **no `manager` member**. "Share with the owner's manager" is
17+
authored as a user field the application stores on the record (a snapshot or kept
18+
in sync — the application's explicit choice) plus a `field` recipient naming it. A
19+
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
20+
the graph-change re-materialisation obligation that once removed the `owner`
21+
recipient type; with `field` the recipient stays visible on the record.
22+
23+
What this release ships is the **contract**: the enum member, the `sharedWith`
24+
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
25+
path such as `owner.manager_id` is refused at parse — a field name, never a graph
26+
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
27+
widened in step, and the generated JSON schema / authorable surface / reference
28+
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
29+
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
30+
update) is the services half, #15072; until it lands the declared-rule bootstrap
31+
skips a `field` rule with a logged warning rather than seeding it.
32+
33+
Accept-set widening only: every sharing rule that parsed before parses
34+
identically — the refinement is scoped to `type: 'field'`, and the other members'
35+
`value` stays the opaque string it was.

content/docs/permissions/permissions-matrix.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
152152
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |
153153

154154
<Callout type="warn">
155-
**Enforcement status:** every authorable rule and recipient type is enforced. v17 reconciled the surface with the runtime (#1878): `owner`-type rules (`type: 'owner'`, `ownedBy`) and `group` / `guest` recipients — previously declared but skipped at seed time — **no longer parse**; `group` became the enforced `team` and `business_unit` joined the enum. That is a stronger statement than "declared but not enforced": a skipped rule is still authorable and is ignored, whereas a removed one is rejected by `SharingRuleSchema`, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078). See [Sharing Rules](/docs/permissions/sharing-rules#recipient-types).
155+
**Enforcement status:** every authorable rule type is enforced, and so is every recipient type but the newest: the `field` recipient (#14103) parses, and until its per-record executor (#15072) lands the declared-rule bootstrap skips such a rule with a logged warning — never silently, never as a wider grant. v17 reconciled the surface with the runtime (#1878): `owner`-type rules (`type: 'owner'`, `ownedBy`) and `group` / `guest` recipients — previously declared but skipped at seed time — **no longer parse**; `group` became the enforced `team` and `business_unit` joined the enum. That is a stronger statement than "declared but not enforced": a skipped rule is still authorable and is ignored, whereas a removed one is rejected by `SharingRuleSchema`, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078). See [Sharing Rules](/docs/permissions/sharing-rules#recipient-types).
156156
</Callout>
157157

158158
<Callout type="info">

content/docs/permissions/sharing-rules.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({
132132

133133
### Recipient types
134134

135-
`sharedWith` accepts a `{ type, value }` recipient. **Every authorable
136-
recipient is enforced**each expands to concrete users at seed time and
137-
materializes `sys_record_share` grants:
135+
`sharedWith` accepts a `{ type, value }` recipient. Every recipient expands to
136+
concrete users and materializes `sys_record_share` grantsonce per **rule**
137+
for the first five, once per **matched record** for `field`:
138138

139139
| `type` | Shares with |
140140
|:--|:--|
@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
143143
| `position` | Everyone assigned that position (flat expansionpositions have no tree) |
144144
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchyADR-0090 D3) |
145145
| `business_unit` | Everyone in exactly that business unit (no subtree) |
146+
| `field` | The user or users named by a **user-typed field on each matched record**`value` is that field's name (`assignees`). A `multiple: true` field shares with every user it holds; an empty column shares with nobody. There is deliberately no `manager` recipient: "the owner's manager" is a user field the application stores on the record, named here (maintainer ruling 2026-09-02, objectstack#14103). The per-record executor is objectstack#15072 — until it lands, a `field` rule is skipped with a logged warning at seed, never seeded wider |
146147

147148
A criteria `condition` must be compilable by the CELfilter pushdown
148149
compiler. A condition the compiler cannot lower is **skipped and logged

content/docs/protocol/objectql/security.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,14 @@ object: account
377377
accessLevel: read # read | edit
378378
condition: 'record.account_type == "Enterprise"'
379379
sharedWith:
380-
type: position # user | team | position | unit_and_subordinates | business_unit
380+
type: position # user | team | position | unit_and_subordinates | business_unit | field
381381
value: sales_rep
382382
```
383383

384384
`unit_and_subordinates` expands a **business-unit subtree**: the unit named by `value` plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the `sys_business_unit` tree).
385385

386+
`field` is the **record-relative** recipient (#14103, maintainer ruling 2026-09-02): `value` names a user-typed field on the object, and each matched record is shared with the user or users that column holds on it (`multiple: true` shares with every user it names; an empty column shares with nobody). It expands once per matched record, not once per rule. There is no `manager` recipient — "the owner's manager" is a user field the application stores on the record, named by a `field` recipient. The per-record executor is #15072.
387+
386388
### Owner-Based Sharing — removed in v17
387389

388390
Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
@@ -417,7 +419,7 @@ sharedWith:
417419
value: west_region_managers
418420
```
419421

420-
> **Enforcement status.** Every authorable rule and recipient type is enforced. Criteria rules with `user` / `team` / `position` / `unit_and_subordinates` / `business_unit` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). Owner-type rules and the `group` / `guest` recipients are **not** `[experimental — not enforced]` and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a `condition` the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).
422+
> **Enforcement status.** Criteria rules with `user` / `team` / `position` / `unit_and_subordinates` / `business_unit` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). The `field` recipient is the contract half of a two-part landing (#14103): it parses, and until its per-record executor (#15072) lands the declared-rule bootstrap skips such a rule with a logged warning — never silently, never as a wider grant. Owner-type rules and the `group` / `guest` recipients are **not** `[experimental — not enforced]` and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a `condition` the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).
421423

422424
> `accessLevel` is one of `read` or `edit`. Sharing widens **which rows** a principal reaches, never **which verbs** they may use — an `edit` share opens *update*, not *delete*: delete comes from ownership, the ADR-0057 DEPTH scopes, or the `modifyAllRecords` bypass, enforced by the sharing layer's own `canDelete` gate (distinct from the `canEdit` update gate) on top of the object-level CRUD gate (ADR-0111 D3). A third level `full` ("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matched `edit`/`full` alike, so it was equivalent to `edit` while telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten to `edit` at load by the `sharing-rule-access-level-full-to-edit` conversion.
423425

content/docs/references/security/sharing.mdx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
3333
| **object** | `string` || Target Object Name |
3434
| **active** | `boolean` | optional (default: `true`) | |
3535
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
36-
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` || The recipient of the shared access |
36+
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>; value: string }` || The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record |
3737
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
3838
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
3939
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);
4848

4949
| Property | Type | Required | Description |
5050
| :--- | :--- | :--- | :--- |
51-
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` || |
52-
| **value** | `string` || ID or code of the recipient (user / team / position / business unit) |
51+
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` || |
52+
| **value** | `string` || The recipient principal: the id or code of the user / team / position / business unit — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with |
5353

5454

5555
---
@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
7575
* `position`
7676
* `unit_and_subordinates`
7777
* `business_unit`
78+
* `field`
7879

7980

8081
---
@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
101102
| **object** | `string` || Target Object Name |
102103
| **active** | `boolean` | optional (default: `true`) | |
103104
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
104-
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` || The recipient of the shared access |
105+
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>; value: string }` || The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the user or users named by a field on each matched record |
105106
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
106107
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
107108
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);
116117

117118
| Property | Type | Required | Description |
118119
| :--- | :--- | :--- | :--- |
119-
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` || |
120-
| **value** | `string` || ID or code of the recipient (user / team / position / business unit) |
120+
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` || |
121+
| **value** | `string` || The recipient principal: the id or code of the user / team / position / business unit — or, for `type: 'field'`, the snake_case name of a user-typed field on the record whose value names the user or users to share each matched record with |
121122

122123

123124
---

packages/lint/src/validate-org-axis-red-lines.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
434434
/**
435435
* ── Rule ②'s recipient word list ────────────────────────────────────────────
436436
*
437-
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
438-
* past. Split out here because the drift guard below asserts the two halves
439-
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
437+
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
438+
* past (`field` — #14103 — is read off the matched record itself: no tree, so
439+
* nothing for ② to scope). Split out here because the drift guard below
440+
* asserts the two halves partition `ShareRecipientType` exactly — the check
441+
* whose absence is #4991.
440442
*/
441443
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
442-
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
444+
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;
443445

444446
describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
445447
const platformGlobalStack = (

packages/lint/src/validate-org-axis-red-lines.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
118118
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
119119
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
120120
* author can write, since `sharedWith` is `.strict()` and rejects everything
121-
* else by name. That enum has FIVE members; this list intercepts two, and the
121+
* else by name. That enum has SIX members; this list intercepts two, and the
122122
* difference is deliberate, not an oversight (it is exactly the oversight
123123
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
124124
* D6 ②'s own text names `unit_and_subordinates`):
@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
130130
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
131131
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
132132
* | `position` | — | Flat holder expansion (ADR-0090 D3 finalized the retirement of the position hierarchy); `PositionGraphService`, not the BU graph. The BU *depth scopes* D6 ② also names are a SCOPE mechanism, not a sharing-rule recipient. |
133+
* | `field` | — | RECORD-RELATIVE (#14103, maintainer ruling 2026-09-02): the user or users a user-typed column on the matched record names — read off the row itself, no tree walked, so no organization needed to resolve one in. Per-record expansion is `plugin-sharing`'s (#15072). |
133134
*
134-
* The three allowed recipients are the sanctioned way to share a
135-
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
136-
* position audience grants those people the catalog, which is the entire point
137-
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
135+
* The four allowed recipients are the sanctioned way to share a
136+
* platform-global object (ADR-0066): naming a user, a flat team, a flat
137+
* position audience, or the users a column on the record itself names grants
138+
* those people the catalog, which is the entire point of
139+
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
138140
* but "resolving a BU SUBTREE with no organization to resolve it within".
139141
*
140142
* The runtime contract `SharingRuleRecipientType`

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
6363
'position',
6464
'unit_and_subordinates',
6565
'business_unit',
66+
// [#14103] the record-relative recipient — a user-typed field on the
67+
// matched record; per-record expansion is the services half (#15072).
68+
'field',
6669
]);
6770
const ruleRecipient: SharingRuleRecipientType = 'queue';
6871
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
6972
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
7073
expect(ruleRecipient).toBe(notAuthorable);
7174
});
75+
76+
it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
77+
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
78+
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
79+
// skipped with a warning), so a member added to one list and not the other
80+
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
81+
// the TYPE level so the drift is a compile error, not a runtime surprise.
82+
type Authorable = (typeof ShareRecipientType.options)[number];
83+
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
84+
expect(inStep).toBe(true);
85+
// And at the VALUE level: every authorable member is assignable to the
86+
// stored-row union (a runtime list, so a reader sees the members).
87+
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
88+
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
89+
expect(stored).toContain('field');
90+
});
7291
});
7392

7493
/**

0 commit comments

Comments
 (0)