Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/sharing-rule-field-recipient.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/spec": minor
---

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)

Maintainer ruling 2026-09-02 (B): a criteria sharing rule may now be authored as
`sharedWith: { type: 'field', value: 'assignees' }`, where `value` is the snake_case
name of a user-typed field on the shared object. Each record the rule's `condition`
matches is shared with the user or users that column holds on that record; a field
with `multiple: true` shares with every user it names; an empty column shares with
nobody (fail-closed). Unlike every other recipient, which resolves once per rule,
a `field` recipient expands once per matched record, and its grants re-materialise
when the record's own write changes that column.

There is deliberately **no `manager` member**. "Share with the owner's manager" is
authored as a user field the application stores on the record (a snapshot or kept
in sync — the application's explicit choice) plus a `field` recipient naming it. A
`manager` member would walk `sys_user.manager_id` from the record and re-introduce
the graph-change re-materialisation obligation that once removed the `owner`
recipient type; with `field` the recipient stays visible on the record.

What this release ships is the **contract**: the enum member, the `sharedWith`
describe text, a `field`-scoped refinement on `value` (an empty name or a dotted
path such as `owner.manager_id` is refused at parse — a field name, never a graph
walk), the stored-row mirror `SharingRuleRecipientType` in `contracts/sharing-service.ts`
widened in step, and the generated JSON schema / authorable surface / reference
docs. The per-record executor (`plugin-sharing` `expandRecipient`, the
`sys_sharing_rule.recipient_type` select, re-materialisation on the record's own
update) is the services half, #15072; until it lands the declared-rule bootstrap
skips a `field` rule with a logged warning rather than seeding it.

Accept-set widening only: every sharing rule that parsed before parses
identically — the refinement is scoped to `type: 'field'`, and the other members'
`value` stays the opaque string it was.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
7 changes: 4 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,9 +132,9 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

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

| `type` | Shares with |
|:--|:--|
Expand All@@ -143,6 +143,7 @@ materializes `sys_record_share` grants:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -377,12 +377,14 @@ object: account
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | team | position | unit_and_subordinates | business_unit
type: position # user | team | position | unit_and_subordinates | business_unit | field
value: sales_rep
```

`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).

`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.

### Owner-Based Sharing — removed in v17

Owner-based rules (`type: 'owner'`, `ownedBy`) were removed from the authoring
Expand DownExpand Up@@ -417,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/security/sharing.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -48,8 +48,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand All@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
* `field`


---
Expand All@@ -101,7 +102,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional (default: `true`) | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional (default: `"read"`) | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **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 |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand All@@ -116,8 +117,8 @@ const result = CriteriaSharingRuleSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>` | ✅ | |
| **value** | `string` | ✅ | ID or code of the recipient (user / team / position / business unit) |
| **type** | `Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit' \| 'field'>` | ✅ | |
| **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 |


---
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/validate-org-axis-red-lines.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,12 +434,14 @@ describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, no
/**
* ── Rule ②'s recipient word list ────────────────────────────────────────────
*
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
* past. Split out here because the drift guard below asserts the two halves
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
* The two BU-tree recipients ② intercepts, and the four it deliberately lets
* past (`field` — #14103 — is read off the matched record itself: no tree, so
* nothing for ② to scope). Split out here because the drift guard below
* asserts the two halves partition `ShareRecipientType` exactly — the check
* whose absence is #4991.
*/
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
const FLAT_RECIPIENTS = ['user', 'team', 'position', 'field'] as const;

describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
const platformGlobalStack = (
Expand Down
12 changes: 7 additions & 5 deletions packages/lint/src/validate-org-axis-red-lines.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
* author can write, since `sharedWith` is `.strict()` and rejects everything
* else by name. That enum has FIVE members; this list intercepts two, and the
* else by name. That enum has SIX members; this list intercepts two, and the
* difference is deliberate, not an oversight (it is exactly the oversight
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
* D6 ②'s own text names `unit_and_subordinates`):
Expand All@@ -130,11 +130,13 @@ const ORG_PARENT_FIELD = 'parent_organization_id';
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
* | `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. |
* | `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). |
*
* The three allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
* position audience grants those people the catalog, which is the entire point
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* The four allowed recipients are the sanctioned way to share a
* platform-global object (ADR-0066): naming a user, a flat team, a flat
* position audience, or the users a column on the record itself names grants
* those people the catalog, which is the entire point of
* `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
* but "resolving a BU SUBTREE with no organization to resolve it within".
*
* The runtime contract `SharingRuleRecipientType`
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,12 +63,31 @@ describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
'position',
'unit_and_subordinates',
'business_unit',
// [#14103] the record-relative recipient — a user-typed field on the
// matched record; per-record expansion is the services half (#15072).
'field',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});

it('the stored-row union is exactly the authoring enum plus the reserved `queue` (#14103)', () => {
// `plugin-sharing`'s declared-rule bootstrap copies `sharedWith.type` onto
// `sys_sharing_rule.recipient_type` member-for-member (an unmapped value is
// skipped with a warning), so a member added to one list and not the other
// is a rule that parses and is never seeded — the ADR-0078 shape. Pinned at
// the TYPE level so the drift is a compile error, not a runtime surprise.
type Authorable = (typeof ShareRecipientType.options)[number];
const inStep: Assert<Eq<Exclude<SharingRuleRecipientType, 'queue'>, Authorable>> = true;
expect(inStep).toBe(true);
// And at the VALUE level: every authorable member is assignable to the
// stored-row union (a runtime list, so a reader sees the members).
const stored: SharingRuleRecipientType[] = [...ShareRecipientType.options, 'queue'];
expect(stored).toHaveLength(ShareRecipientType.options.length + 1);
expect(stored).toContain('field');
});
});

/**
Expand Down
Loading
Loading