diff --git a/.changeset/sharing-rule-field-recipient.md b/.changeset/sharing-rule-field-recipient.md
new file mode 100644
index 0000000000..826d385adc
--- /dev/null
+++ b/.changeset/sharing-rule-field-recipient.md
@@ -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.
diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx
index 5b0291a3c3..c4188af9b6 100644
--- a/content/docs/permissions/permissions-matrix.mdx
+++ b/content/docs/permissions/permissions-matrix.mdx
@@ -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" |
-**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).
diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx
index 0fd68bd8c8..b80a822191 100644
--- a/content/docs/permissions/sharing-rules.mdx
+++ b/content/docs/permissions/sharing-rules.mdx
@@ -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 |
|:--|:--|
@@ -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 —
diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx
index 6907dec760..e83cb601d3 100644
--- a/content/docs/protocol/objectql/security.mdx
+++ b/content/docs/protocol/objectql/security.mdx
@@ -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
@@ -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.
diff --git a/content/docs/references/security/sharing.mdx b/content/docs/references/security/sharing.mdx
index 6142a9eb98..59a89d333b 100644
--- a/content/docs/references/security/sharing.mdx
+++ b/content/docs/references/security/sharing.mdx
@@ -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). |
@@ -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 |
---
@@ -75,6 +75,7 @@ const result = CriteriaSharingRuleSchema.parse(data);
* `position`
* `unit_and_subordinates`
* `business_unit`
+* `field`
---
@@ -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). |
@@ -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 |
---
diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts
index 4e11799cbe..782d6a9f4e 100644
--- a/packages/lint/src/validate-org-axis-red-lines.test.ts
+++ b/packages/lint/src/validate-org-axis-red-lines.test.ts
@@ -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 = (
diff --git a/packages/lint/src/validate-org-axis-red-lines.ts b/packages/lint/src/validate-org-axis-red-lines.ts
index 65f4394cdf..8e44550a97 100644
--- a/packages/lint/src/validate-org-axis-red-lines.ts
+++ b/packages/lint/src/validate-org-axis-red-lines.ts
@@ -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`):
@@ -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`
diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts
index da24604597..ef344af2dd 100644
--- a/packages/spec/src/contracts/sharing-service.test.ts
+++ b/packages/spec/src/contracts/sharing-service.test.ts
@@ -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, 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');
+ });
});
/**
diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts
index de14469fa3..3e92876eac 100644
--- a/packages/spec/src/contracts/sharing-service.ts
+++ b/packages/spec/src/contracts/sharing-service.ts
@@ -425,16 +425,33 @@ export interface ISharingService {
// ─────────────────────────────────────────────────────────────────────
/**
- * Kinds of principals a rule can target.
+ * Kinds of principals a stored rule row can target —
+ * `sys_sharing_rule.recipient_type`.
*
- * - `user` — a specific user id (no expansion)
- * - `team` — a flat collaboration team (`sys_team` + `sys_team_member`)
- * - `department` — an org-skeleton node (`sys_business_unit` + descendants via
- * `parent_business_unit_id` + members from `sys_business_unit_member`)
- * - `role` — tenant role on `sys_member.role`
- * - `queue` — opaque queue identifier (resolution left to caller / app)
+ * The authoring enum `ShareRecipientType` (`spec/security`, `sharing.zod.ts`)
+ * is this union minus `queue`: `plugin-sharing`'s declared-rule bootstrap maps
+ * `sharedWith.type` onto this column member-for-member (an unmapped value is
+ * skipped with a warning, never widened), and `sharing-service.test.ts` beside
+ * this file pins the two lists in step.
+ *
+ * - `user` — a specific user id (no expansion)
+ * - `team` — a flat collaboration team (`sys_team` + `sys_team_member`)
+ * - `business_unit` — exactly one business unit's members (`sys_business_unit_member`)
+ * - `position` — every holder of a position (flat; ADR-0090 D3)
+ * - `unit_and_subordinates` — a business unit plus every descendant unit
+ * (`parent_business_unit_id` walk; ADR-0057 D5)
+ * - `field` — RECORD-RELATIVE (#14103, maintainer ruling
+ * 2026-09-02): `recipient_id` names a user-typed
+ * field on the shared object, and the recipients
+ * are the user or users that column holds on EACH
+ * matched record (`multiple: true` honoured; an
+ * empty column shares with nobody). Expansion is
+ * per record, not per rule, and re-materialises on
+ * the record's own write. Executor: #15072.
+ * - `queue` — reserved: no `sys_queue` yet, `expandRecipient`
+ * returns `[]`, deliberately not authorable
*/
-export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'position' | 'unit_and_subordinates' | 'queue';
+export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'position' | 'unit_and_subordinates' | 'queue' | 'field';
/**
* Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule`
diff --git a/packages/spec/src/security/sharing.test.ts b/packages/spec/src/security/sharing.test.ts
index fbff229574..d3ef84c1b1 100644
--- a/packages/spec/src/security/sharing.test.ts
+++ b/packages/spec/src/security/sharing.test.ts
@@ -7,6 +7,7 @@ import {
OWDModel,
type SharingRule,
} from './sharing.zod';
+import { FieldSchema } from '../data/field.zod';
describe('SharingRuleType', () => {
it('should accept the enforced rule type', () => {
@@ -26,9 +27,11 @@ describe('SharingRuleType', () => {
});
describe('ShareRecipientType', () => {
- it('accepts exactly the enforced runtime recipients', () => {
- const enforced = ['user', 'team', 'position', 'unit_and_subordinates', 'business_unit'];
- enforced.forEach((t) => {
+ it('accepts exactly the authorable recipients', () => {
+ // `field` (#14103) is authorable from this release; its per-record executor
+ // is the services half (#15072) — see the `field` describe block below.
+ const authorable = ['user', 'team', 'position', 'unit_and_subordinates', 'business_unit', 'field'];
+ authorable.forEach((t) => {
expect(() => ShareRecipientType.parse(t)).not.toThrow();
});
});
@@ -177,6 +180,7 @@ describe('SharingRuleSchema', () => {
{ type: 'position', value: 'sales_manager' },
{ type: 'unit_and_subordinates', value: 'bu_field_ops' },
{ type: 'business_unit', value: 'bu_finance' },
+ { type: 'field', value: 'assignees' },
];
recipients.forEach((sharedWith) => {
@@ -461,3 +465,96 @@ describe('unknown keys are rejected, not stripped (#4001)', () => {
expect(issue!.message).toContain('`id` → `value`');
});
});
+
+// [#14103] The record-relative recipient — maintainer ruling 2026-09-02 (B):
+// `ShareRecipientType` gains `field` and ONLY `field`; ⛔ no `manager` member.
+// The schema does not know the object, so these pins assert the SHAPE: what
+// parses, what is refused, and where the refusal points. The runtime semantics
+// (per-record expansion, `multiple: true` honoured, empty column ⇒ nobody,
+// re-materialisation on the record's own write) are the executor's, #15072.
+describe("sharedWith.type: 'field' — the record-relative recipient (#14103)", () => {
+ const rule = (sharedWith: unknown) => ({
+ name: 'assignees_can_read',
+ object: 'duly_assignment',
+ type: 'criteria' as const,
+ condition: 'record.status == "open"',
+ sharedWith,
+ });
+ /** The issue at exactly `path`, or undefined when the value parses or the path is clean. */
+ const issueAt = (value: unknown, path: string[]) => {
+ const result = SharingRuleSchema.safeParse(value);
+ if (result.success) return undefined;
+ return result.error.issues.find((i) => i.path.join('.') === path.join('.'));
+ };
+
+ it('accepts a `field` recipient naming a user-typed field on the record', () => {
+ const parsed = SharingRuleSchema.parse(rule({ type: 'field', value: 'assignees' }));
+ expect(parsed.sharedWith).toEqual({ type: 'field', value: 'assignees' });
+ });
+
+ it('accepts the same shape for a `multiple: true` user field — the schema pins the shape, the executor the fan-out', () => {
+ // The object declaration and the rule are separate documents; the rule
+ // cannot see that `assignees` is multi-valued. What the pair pins: a
+ // multi-user column is authorable (FieldSchema) and a rule can name it
+ // (SharingRuleSchema) — "every user it names" is #15072's contract.
+ const column = FieldSchema.parse({ name: 'assignees', type: 'user', multiple: true });
+ expect(column.multiple).toBe(true);
+ const parsed = SharingRuleSchema.parse(rule({ type: 'field', value: column.name }));
+ expect(parsed.sharedWith.value).toBe('assignees');
+ });
+
+ it("refuses `type: 'manager'` by name — the ruling's ⛔ made falsifiable", () => {
+ const issue = issueAt(rule({ type: 'manager', value: 'x' }), ['sharedWith', 'type']);
+ expect(issue).toBeDefined();
+ expect(issue!.code).toBe('invalid_value');
+ expect(issue!.path).toEqual(['sharedWith', 'type']);
+ // First sentence: the enum's own refusal, listing what IS authorable. This
+ // pin stays deliberately independent of the accept set (it does not assert
+ // `"field"` is in the list — the positive control below does) so that
+ // ablating the `field` member reds the accept pins and leaves this one green.
+ const firstSentence = issue!.message.split('. ')[0];
+ expect(firstSentence).toMatch(/^Invalid option: expected one of /);
+ expect(firstSentence).not.toContain('"manager"');
+ });
+
+ it('positive control — the same rule with a `field` recipient parses clean', () => {
+ // Proves the refusal above is the enum's verdict on `manager`, not an
+ // artefact of the fixture around it: the manager IS reachable, as a user
+ // field the application stores on the record.
+ const result = SharingRuleSchema.safeParse(rule({ type: 'field', value: 'manager_user' }));
+ expect(result.success).toBe(true);
+ });
+
+ it('refuses a `field` recipient whose `value` names no field (empty)', () => {
+ // Measured before this member existed: `value` carried no constraint at all
+ // (`{ type: 'user', value: '' }` parses). The refusal below is therefore
+ // scoped to `field` — see the preservation pin at the end of this block.
+ const issue = issueAt(rule({ type: 'field', value: '' }), ['sharedWith', 'value']);
+ expect(issue).toBeDefined();
+ expect(issue!.code).toBe('custom');
+ expect(issue!.path).toEqual(['sharedWith', 'value']);
+ const firstSentence = issue!.message.split('. ')[0];
+ expect(firstSentence).toBe(
+ "`sharedWith.value` must name a user-typed field on the record when `type` is 'field' (got \"\")",
+ );
+ });
+
+ it('refuses a dotted path — a graph walk spelled as a value is not a field name', () => {
+ // `owner.manager_id` is the `manager` member by another spelling; the
+ // recipient is READ from one column on the record, never walked from it.
+ const issue = issueAt(rule({ type: 'field', value: 'owner.manager_id' }), ['sharedWith', 'value']);
+ expect(issue).toBeDefined();
+ expect(issue!.code).toBe('custom');
+ expect(issue!.message).toContain('not a dotted path');
+ expect(issue!.message).toContain("owner's manager");
+ });
+
+ it("preserves the other members' `value` contract — the refinement is scoped to `field`", () => {
+ // Accept-set widening only: a rule that parsed before this member existed
+ // parses identically after it. The bare-string `value` on the other members
+ // is the pre-existing contract (measured: no length or shape constraint),
+ // pinned here so the `field`-only refinement cannot quietly become global.
+ expect(SharingRuleSchema.safeParse(rule({ type: 'user', value: '' })).success).toBe(true);
+ expect(SharingRuleSchema.safeParse(rule({ type: 'team', value: 'Team.Sales' })).success).toBe(true);
+ });
+});
diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts
index 36603d40d9..c31b6d682b 100644
--- a/packages/spec/src/security/sharing.zod.ts
+++ b/packages/spec/src/security/sharing.zod.ts
@@ -70,7 +70,7 @@ export const SharingLevel = z.enum([
* Recipient Type
* Who receives the access?
*
- * Every member maps 1:1 onto an enforced runtime recipient expansion
+ * Every member maps 1:1 onto a runtime recipient expansion
* (`plugin-sharing` `expandRecipient`) — this enum is the authorable subset of
* the runtime `SharingRuleRecipientType` contract:
* - `user` — a single user id.
@@ -80,6 +80,28 @@ export const SharingLevel = z.enum([
* - `unit_and_subordinates` — a business unit plus every descendant unit's
* members (ADR-0057 D5 subtree widening).
* - `business_unit` — exactly one business unit's members (no subtree).
+ * - `field` — the RECORD-RELATIVE recipient (maintainer ruling 2026-09-02,
+ * #14103): `value` names a user-typed field ON THE RECORD, and each record
+ * the rule's criteria match is shared with the user or users that column
+ * names. A field with `multiple: true` shares with every user it names; an
+ * empty column shares with nobody (fail-closed — never a match-all
+ * principal). Unlike every member above, 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. That
+ * executor half is #15072 (`plugin-sharing`); until it lands, the
+ * declared-rule bootstrap skips a `field` rule with a logged warning
+ * (`mapRecipientType` → null) — it never seeds one as anything wider.
+ *
+ * ⛔ No `manager` member (same ruling). "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 matched record, re-introducing the obligation that removed the
+ * `owner` recipient type: a change in the graph (someone's manager changes)
+ * would have to re-materialise every row that ever pointed at the old
+ * manager, from a `sys_user` write the record materialiser never sees. With
+ * `field` the recipient stays visible on the record and re-materialisation
+ * rides the record's own writes.
*
* Removed (never enforced): `group` (renamed → `team`) and `guest` — anonymous
* access is served by the public-form grant and share links, not sharing rules;
@@ -93,6 +115,7 @@ export const ShareRecipientType = z.enum([
'position',
'unit_and_subordinates',
'business_unit',
+ 'field',
]);
/**
@@ -165,8 +188,38 @@ const BaseSharingRuleSchema = strictObject(
},
{
type: ShareRecipientType,
- value: z.string().describe('ID or code of the recipient (user / team / position / business unit)'),
- }).describe('The recipient of the shared access'),
+ value: z.string().describe(
+ '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',
+ ),
+ }).superRefine((recipient, ctx) => {
+ // [#14103] A `field` recipient's `value` is a FIELD NAME, not a principal id,
+ // so it has a grammar the other members' opaque ids do not: the machine-name
+ // shape every `FieldSchema.name` carries. Refusing here, at authoring, keeps
+ // two traps out of the executor: an empty name (a rule naming no column would
+ // be skipped at seed — the silent under-share this file's history keeps
+ // closing, ADR-0078) and a dotted path (`owner.manager_id` is a graph walk
+ // spelled as a value — exactly what the ruling's ⛔ on a `manager` member
+ // refuses). Scoped to `field`, so nothing that parsed before parses
+ // differently: the other members' `value` stays the opaque string it was.
+ if (recipient.type !== 'field') return;
+ if (!/^[a-z_][a-z0-9_]*$/.test(recipient.value)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['value'],
+ message:
+ `\`sharedWith.value\` must name a user-typed field on the record when \`type\` is 'field' ` +
+ `(got ${JSON.stringify(recipient.value)}). ` +
+ 'A field name is snake_case (`assignees`, `manager_user`) — not a principal id and not a dotted ' +
+ 'path: the recipient is read from that one column on each matched record, never walked from it. ' +
+ "To share with the owner's manager, store the manager in a user field on the record and name that field.",
+ });
+ }
+ }).describe(
+ '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',
+ ),
// ADR-0010 — runtime protection envelope (internal — set by loader).
//
@@ -203,7 +256,10 @@ export const CriteriaSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema.
* records matching the criteria materialise `sys_record_share` grants for the
* resolved recipients. Supported recipients: `user` / `team` / `position` /
* `unit_and_subordinates` / `business_unit` (ADR-0057 D5; ADR-0090 D3) — every
- * authorable recipient expands at runtime (`plugin-sharing` `expandRecipient`).
+ * authorable recipient expands at runtime (`plugin-sharing` `expandRecipient`)
+ * — plus `field`, the record-relative recipient (#14103): resolved per matched
+ * record from a user-typed column on it; its executor is the services half,
+ * #15072, and until that lands a `field` rule is skipped LOUDLY at seed.
*
* The whole authorable surface is enforced — nothing here validates and then
* silently does nothing (ADR-0078). Removed to keep it that way: `owner`-type