Skip to content

Commit c245986

Browse files
fix(plugin-sharing): the record-share grant names each non-user value's sharing-rule recipient, so group is sent to team (#23678)
Fixes #23325 Clause-②: no ## What was wrong `ISharingService.grant` writes a record share for a `user` only (ADR-0078). It refused every other declared value with one remedy: > VALIDATION_FAILED: recipientType must be 'user' (received "group") — group/position recipients are delivered via sharing rules (ADR-0111 D7) The sharing-rule vocabulary has no `group`. Neither the authoring `sharedWith.type` enum (`ShareRecipientType`, `packages/spec/src/security/sharing.zod.ts:126`) nor the `sys_sharing_rule.recipient_type` select (`packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts:172`) offers it. A rule spells it `team`: the v17 ADR-0087 entry `sharing-rule-recipient-reconcile` (`packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts:11`) records "`group` → `team`" as the ADR-0090 D3 rename. A caller or an AI that followed the remedy wrote a rule naming a recipient no rule takes. A `unit_and_subordinates` caller read a sentence that did not name its value. ## What changed `packages/plugins/plugin-sharing/src/sharing-service.ts`: - `RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT` names, for each declared non-`user` value, the rule recipient that delivers it. It is typed against both vocabularies rather than restating either as a second runtime list: - keys are every `RecordShareRecipientType` member except `user` (the union with `user` excluded, as a TypeScript `Record` key type), so a record-share member added in the spec fails to compile here until it names its route; - values are the authorable `ShareRecipientType` (the list the `sys_sharing_rule` select is held equal to by `field-recipient.test.ts`), so the refusal can never name a recipient a rule cannot be written with. The reserved `queue` is excluded by construction. - The refusal keeps its code, its 400 (the `VALIDATION_FAILED` prefix arm, `packages/rest/src/rest-server.ts:11301`) and its first clause. Only the remedy after the dash changes: | received | remedy, TO | |---|---| | `group` | a group recipient is delivered via a sharing rule with recipient type 'team', its name since the ADR-0090 D3 rename (ADR-0111 D7) | | `position` | a position recipient is delivered via a sharing rule with recipient type 'position' (ADR-0111 D7) | | `unit_and_subordinates` | a unit_and_subordinates recipient is delivered via a sharing rule with recipient type 'unit_and_subordinates' (ADR-0111 D7) | | a value no record share declares (an untyped REST body sending `team`, say) | "team" is not a record-share recipient type (ADR-0111 D7) | - The retired `guest` keeps its own prescription (ADR-0138 D8), byte for byte. - ⛔ No change to which values `grant()` accepts (`user` only), and none to the record-share vocabulary. `content/docs/kernel/runtime-services/sharing-service.mdx` (Typical Errors, `VALIDATION_FAILED`) stated the old remedy. It now states the per-value mapping. **This file is outside the claim's declared file surface.** The dispatch asked that everything in this repo stating the old remedy be updated, and this was the one other hit. `.changeset/23325-record-share-remedy-text.md`: `patch` on `@objectstack/plugin-sharing`. The sentence ships: after the build, `dist/index.js` and `dist/index.mjs` each hold the new undeclared-value text once and the old sentence zero times, with the guest prescription as positive control (1 each). `files[]` is `["dist","README.md","CHANGELOG.md"]`. ## Measured before writing (on `origin/main` at `cad343e201`, re-read after merging `5477020809`) - **Premise holds.** The sentence was at `sharing-service.ts:1396-1397`. The existing pin `refuses non-user recipient types instead of persisting rows no gate reads` asserted `VALIDATION_FAILED` only, never the remedy. - **No declared value lacks a rule route.** The record-share vocabulary is `user`, `group`, `position`, `unit_and_subordinates` (`RecordShareRecipientType`, and the `sys_record_share` select). The rule vocabulary is `user`, `team`, `business_unit`, `position`, `unit_and_subordinates`, `field`. `group` maps to `team` by the recorded rename. The other two keep their own names. - **Where the old remedy was stated or asserted:** `git grep` for the sentence and its phrases hits only `sharing-service.ts` and the docs line above in this repo. There are zero hits in the i18n bundles. In `objectstack-ai/objectui` at the pinned `.objectui-sha` `b9814b334d` there are zero hits. The control word `recipientType` does hit there, for example `packages/fields/src/widgets/RecipientPickerField.tsx` (8 hits). Nothing outside this repo states it. - **On the citation.** ADR-0090 D3's own rename table lists `role` → `position`, not `group` → `team`. D3's naming commandment reads "collaboration = `team`". The spec's `ShareRecipientType` docblock ("ADR-0090 D3 renamed the pre-D3 `group` vocabulary to `team`") and the v17 entry attribute the rename to D3. The remedy cites "the ADR-0090 D3 rename", as triage directed, which matches the repository's own record. ## Pins `sharing-service.test.ts`, describe `[ADR-0111 D7] the refusal names the sharing-rule recipient that delivers each non-user value`, following the pattern PR #23324 set for `guest`: - `covers exactly the non-user values a record-share row declares, and routes each to a rule recipient`: the table's keys equal the `sys_record_share` select's non-`user` options. Each route is an option of the `sys_sharing_rule` select, and `group` is not. - `group → … 'team'`, `position → … 'position'`, `unit_and_subordinates → … 'unit_and_subordinates'`: the code prefix and the received value, the named rule recipient, the `(ADR-0111 D7)` ending, and that nothing is written. - `group's remedy cites the rename and never names a group rule recipient`. The other two carry no rename clause. - `a value no record share declares is told so, and is named no route`: `team`, the exact sentence, no "delivered via". - The guest pin's guard widens from `/sharing rules/` to `/sharing rule/`, so that it still catches a remedy naming a rule route under the new singular wording. The guest message itself is unchanged. ### Ablation (one-off, committed fix first; nothing left in the tree) `node scripts/ablation-replace.mjs` swapped `+ nonUserRecipientTypeRemedy(recipientType),` for the old sentence. It recorded anchor x1 → x0, replacement x0 → x1, blob `52704b19f94e` → `97ac1ab79692`. It then ran `vitest run src/sharing-service.test.ts -t 'inert grants'`, with the test importing `./sharing-service.js` from source (no dist in the path): - **Mutated: 5 failed.** The three per-value cases, the rename case, and the undeclared-value case. For example: `Received: "VALIDATION_FAILED: recipientType must be 'user' (received "team") — group/position recipients are delivered via sharing rules (ADR-0111 D7)"`. - **Green under the mutation, as predicted:** the vocabulary-coverage case (it reads the selects, not the message), the pre-existing refusal pin, and the guest pin. - **Restored:** `git checkout HEAD -- …`. Blob after restore `52704b19f94e` = HEAD blob, `git diff HEAD` empty. Old-sentence count 0, new-call count 1. ## Tests (at `6f42b8d08a`, the head this PR opens with) - `pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2`: 46 files, 1067 tests passed. - `pnpm --filter @objectstack/plugin-sharing typecheck` (tsc, tsconfig.scripts.json, `check:test-typecheck`): exit 0. `sharing-service.test.ts` is in the `tsconfig.test.json` program (`--listFilesOnly`: 1 hit, 46 test files), with no debt entry. - Dependency closure `pnpm --filter '@objectstack/plugin-sharing^...' build`: exit 0. It was rebuilt after the merge of `origin/main`, which brought a `packages/core` change. - The require entry `dist/index.js` loads (`require()` → 62 exports). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6f42b8d08a`: 95 commands for 4 paths. Each was run byte for byte, with its exit captured before any pipe. - **92 exit 0 on the first pass.** 3 exit 3 (PREREQUISITE NOT MET): - `pnpm --filter @objectstack/spec run check:skill-examples` needed `client`/`client-react` dist. Built, re-run: exit 0, "263 prose examples type-check across 3 surface(s)". - `pnpm check:i18n` needed the CLI's extract closure. Built with the command the gate prints, re-run: exit 0, "OK (10 package(s) — all bundles in sync, no undeclared authoring keys)". - `pnpm check:dual-build-cjs-loads` **NOT MEASURED**: it needs every package's dist (57 named missing). That whole-tree build is CI's. The one package this diff touches was measured by hand above: its require entry loads, and the diff adds only type-only imports, which are erased. - **`--ran` reconciliation:** "95 derived, 94 run, 1 NOT-MEASURED, 0 UNRUN". - **One `UNRECOGNISED` line, verbatim, from `pnpm check:engine-double-contract`** (a verdict, not a failure): `UNRECOGNISED [engine-double-contract]: 22 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 225 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (#9747, ruling of 2026-08-18).` - CI's own families are read from this PR's check-runs. ## Acceptance notes - A caller that sends a rule spelling to the record-share door (`team`, `business_unit`, `field`) is told the value is not a record-share recipient type. It is deliberately not routed: the card scopes the remedy to declared record-share values, and routing undeclared ones would mean a second read of the rule vocabulary at this door. - A sharing rule shares the records its criteria match. A caller who meant one record for a team needs a criteria naming that record. The remedy names the rule recipient only, as the card asks. - `SharingRuleService.defineRule` does not check the recipient type itself; it relies on the `sys_sharing_rule` select at insert. This is read from source only and not measured at a public door, so it is not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09dd537 commit c245986

4 files changed

Lines changed: 149 additions & 7 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
fix(plugin-sharing): a record-share grant refused for a non-user recipient names the sharing-rule recipient that delivers it, so a `group` caller is sent to `team`
6+
7+
Clause-②: no
8+
9+
**What was wrong.** `ISharingService.grant` writes a record share for a `user` only (ADR-0078), and refuses `group`, `position` and `unit_and_subordinates` with one sentence. FROM: "VALIDATION_FAILED: recipientType must be 'user' (received "group") — group/position recipients are delivered via sharing rules (ADR-0111 D7)". The sharing-rule vocabulary has no `group` recipient: a rule spells it `team`, since the ADR-0090 D3 rename. A caller or an AI that followed the remedy wrote a rule naming `group`, which neither the authoring `sharedWith.type` enum nor the `sys_sharing_rule.recipient_type` select offers. A `unit_and_subordinates` caller read a sentence that did not name its value.
10+
11+
**What changed.** Each declared value's remedy names its own rule recipient. TO:
12+
13+
- `group`: "… — a group recipient is delivered via a sharing rule with recipient type 'team', its name since the ADR-0090 D3 rename (ADR-0111 D7)"
14+
- `position`: "… — a position recipient is delivered via a sharing rule with recipient type 'position' (ADR-0111 D7)"
15+
- `unit_and_subordinates`: "… — a unit_and_subordinates recipient is delivered via a sharing rule with recipient type 'unit_and_subordinates' (ADR-0111 D7)"
16+
17+
A value no record share declares (an untyped REST body sending `team`, say) is told so, and is named no route: "… — "team" is not a record-share recipient type (ADR-0111 D7)".
18+
19+
**Unchanged.** Which values `grant()` accepts (`user` only), the `VALIDATION_FAILED` code and its 400, the first clause of every refusal, the retired `guest` value's own prescription, and the record-share vocabulary. Nothing is written for a refused grant.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
5656
## Typical Errors
5757

5858
- `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing.
59-
- `VALIDATION_FAILED` (400) — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`), or `grant` with a non-`user` `recipientType` (only `user` rows are enforced by the gates; group/position recipients are delivered via sharing rules).
59+
- `VALIDATION_FAILED` (400) — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`), or `grant` with a non-`user` `recipientType` (only `user` rows are enforced by the gates). The refusal names the sharing-rule recipient that delivers each declared value: `group` → `team` (a rule spells it `team` since the ADR-0090 D3 rename), `position` → `position`, `unit_and_subordinates` → `unit_and_subordinates`.
6060
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). On the sharing-**rule** surface (`ISharingRuleService`, declared in the same canonical source — `listRules` / `getRule` / `defineRule` / `deleteRule` / `evaluateRule`) the same code carries a second condition: the caller holds `manage_sharing` but their session resolves **no active organization**, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (`manage_platform_settings`, or the `platform_admin` position) are unaffected — see [Rule administration](/docs/permissions/sharing-rules).
6161
- `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record.
6262
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.

‎packages/plugins/plugin-sharing/src/sharing-service.test.ts‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { assertEngineDeleteDispatch } from '@objectstack/objectql';
55
import { SharingService, type SharingServiceOptions } from './sharing-service.js';
66
import { buildSharingMiddleware } from './sharing-plugin.js';
77
import { bootRequestContext } from './exec-context-seam.testkit.js';
8+
import { SysRecordShare } from './objects/sys-record-share.object.js';
9+
import { SysSharingRule } from './objects/sys-sharing-rule.object.js';
810

911
// ─────────────────────────────────────────────────────────────────────
1012
// In-memory fake engine
@@ -1111,8 +1113,8 @@ describe('[ADR-0111 D7] no inert grants', () => {
11111113
// body) can still send it. The grant's one error channel is the message
11121114
// prefix (`rest-server.ts`'s `respondSharingError` maps it to a 400); the
11131115
// pin asserts that prefix, the prescription, and that nothing was written.
1114-
// A sharing rule cannot deliver a guest either, so the generic sentence
1115-
// ("delivered via sharing rules") would misdirect the caller.
1116+
// A sharing rule cannot deliver a guest either, so a remedy naming a rule
1117+
// route ("delivered via a sharing rule …") would misdirect the caller.
11161118
const refusal = await svc.grant(
11171119
{ object: 'account', recordId: 'a1', recipientId: 'anyone', recipientType: 'guest' as never },
11181120
{ userId: 'alice' },
@@ -1122,10 +1124,82 @@ describe('[ADR-0111 D7] no inert grants', () => {
11221124
expect(message.startsWith("VALIDATION_FAILED: recipientType 'guest' was removed (ADR-0138 D8)")).toBe(true);
11231125
expect(message).toMatch(/guest access flows through the guest position and share links \(ADR-0090 D11\)/);
11241126
expect(message).toMatch(/Expose the record through a public form or a share link instead\.$/);
1125-
expect(message).not.toMatch(/sharing rules/);
1127+
expect(message).not.toMatch(/sharing rule/);
11261128
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
11271129
});
11281130

1131+
describe('[ADR-0111 D7] the refusal names the sharing-rule recipient that delivers each non-user value', () => {
1132+
/** A select field's option values, in declared order. */
1133+
const selectValues = (field: unknown): unknown[] =>
1134+
((field as { options?: unknown[] }).options ?? [])
1135+
.map((o) => (typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o));
1136+
1137+
// The pin the guest case above sets for its own value, for every value the
1138+
// generic refusal answers. A rule spells `group` as `team` (the ADR-0090 D3
1139+
// rename, recorded by the ADR-0087 entry `sharing-rule-recipient-reconcile`);
1140+
// the other two keep their own name.
1141+
const RULE_ROUTES = {
1142+
group: 'team',
1143+
position: 'position',
1144+
unit_and_subordinates: 'unit_and_subordinates',
1145+
} as const;
1146+
1147+
const refusalFor = async (recipientType: string): Promise<string> => {
1148+
const refusal = await svc.grant(
1149+
{ object: 'account', recordId: 'a1', recipientId: 'g1', recipientType: recipientType as never },
1150+
{ userId: 'alice' },
1151+
).then(() => null, (e: unknown) => e);
1152+
expect(refusal, `${recipientType} was granted`).toBeInstanceOf(Error);
1153+
return (refusal as Error).message;
1154+
};
1155+
1156+
it('covers exactly the non-user values a record-share row declares, and routes each to a rule recipient', () => {
1157+
// Read from both selects, so neither vocabulary moves without this table.
1158+
expect(Object.keys(RULE_ROUTES)).toEqual(
1159+
selectValues(SysRecordShare.fields.recipient_type).filter((v) => v !== 'user'),
1160+
);
1161+
const ruleRecipients = selectValues(SysSharingRule.fields.recipient_type);
1162+
for (const rule of Object.values(RULE_ROUTES)) expect(ruleRecipients).toContain(rule);
1163+
// The record-share spelling `group` is no rule recipient: naming it would
1164+
// send the caller to a route that does not exist.
1165+
expect(ruleRecipients).not.toContain('group');
1166+
});
1167+
1168+
for (const [recipientType, rule] of Object.entries(RULE_ROUTES)) {
1169+
it(`${recipientType} → a sharing rule with recipient type '${rule}'`, async () => {
1170+
const message = await refusalFor(recipientType);
1171+
expect(message.startsWith(
1172+
`VALIDATION_FAILED: recipientType must be 'user' (received "${recipientType}") — `
1173+
+ `a ${recipientType} recipient is delivered via a sharing rule with recipient type '${rule}'`,
1174+
)).toBe(true);
1175+
expect(message).toMatch(/\(ADR-0111 D7\)$/);
1176+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
1177+
});
1178+
}
1179+
1180+
it("group's remedy cites the rename and never names a `group` rule recipient", async () => {
1181+
const message = await refusalFor('group');
1182+
expect(message).toMatch(/recipient type 'team', its name since the ADR-0090 D3 rename \(ADR-0111 D7\)$/);
1183+
expect(message).not.toMatch(/recipient type 'group'/);
1184+
// The two values whose rule spelling is their own carry no rename clause.
1185+
for (const unchanged of ['position', 'unit_and_subordinates']) {
1186+
expect(await refusalFor(unchanged)).not.toMatch(/rename/);
1187+
}
1188+
});
1189+
1190+
it('a value no record share declares is told so, and is named no route', async () => {
1191+
// `team` is a RULE recipient an untyped caller (a REST body) may send here.
1192+
// It is no record-share value, so no route is invented for it.
1193+
const message = await refusalFor('team');
1194+
expect(message).toBe(
1195+
`VALIDATION_FAILED: recipientType must be 'user' (received "team") — `
1196+
+ '"team" is not a record-share recipient type (ADR-0111 D7)',
1197+
);
1198+
expect(message).not.toMatch(/delivered via/);
1199+
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
1200+
});
1201+
});
1202+
11291203
it('refuses a grant on a public object (no gate would ever consult it)', async () => {
11301204
await expect(
11311205
svc.grant({ object: 'whiteboard', recordId: 'w1', recipientId: 'bob' }, { userId: 'alice' }),

‎packages/plugins/plugin-sharing/src/sharing-service.ts‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ISharingService,
88
IHierarchyScopeResolver,
99
RecordShare,
10+
RecordShareRecipientType,
1011
GrantShareInput,
1112
ShareAccessLevel,
1213
SharingWriteVerdict,
@@ -15,6 +16,7 @@ import {
1516
normalizeTenancyPosture,
1617
postureEnforcesWall,
1718
type ExplainDecision,
19+
type ShareRecipientType,
1820
type TenancyPosture,
1921
} from '@objectstack/spec/security';
2022
// [#7136] Every enforcement method below takes the FULL `resolveAuthzContext`
@@ -101,6 +103,50 @@ function retiredRecipientTypeRefusal(value: unknown): string | undefined {
101103
: undefined;
102104
}
103105

106+
/**
107+
* [ADR-0111 D7] The sharing-rule recipient that delivers each declared
108+
* non-`user` record-share value: the route {@link SharingService.grant} names
109+
* when it refuses one. Only a `user` row is ever written (ADR-0078), so a
110+
* caller who meant a set of people is sent to the sharing rule that expands
111+
* that set into per-user rows, spelled the way a rule spells it.
112+
*
113+
* Typed against both vocabularies, so neither can move away from it unnoticed.
114+
* The keys are every non-`user` `RecordShareRecipientType` member: a member
115+
* added there fails to compile here until it names its route. Each value is an
116+
* authorable `ShareRecipientType` (`spec/security`), the list the
117+
* `sys_sharing_rule.recipient_type` select is held equal to, so the refusal can
118+
* never name a recipient a rule cannot be written with (the reserved `queue`
119+
* included). A rule spells `group` as `team`: the ADR-0090 D3 rename, recorded
120+
* by the ADR-0087 entry `sharing-rule-recipient-reconcile`.
121+
*/
122+
const RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT: Readonly<Record<
123+
Exclude<RecordShareRecipientType, 'user'>,
124+
{ readonly recipientType: ShareRecipientType; readonly renamedBy?: string }
125+
>> = {
126+
group: { recipientType: 'team', renamedBy: 'ADR-0090 D3' },
127+
position: { recipientType: 'position' },
128+
unit_and_subordinates: { recipientType: 'unit_and_subordinates' },
129+
};
130+
131+
/**
132+
* The remedy `grant()` appends to its refusal of a non-`user` `recipientType`
133+
* that is not retired: the sharing-rule recipient that delivers a declared
134+
* value, or, for a value no record share declares, that it is not one. It
135+
* never names a route for a value it does not know.
136+
*/
137+
function nonUserRecipientTypeRemedy(value: unknown): string {
138+
const route = typeof value === 'string'
139+
&& Object.prototype.hasOwnProperty.call(RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT, value)
140+
? RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT[value as keyof typeof RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT]
141+
: undefined;
142+
if (route === undefined) {
143+
return `${JSON.stringify(value)} is not a record-share recipient type (ADR-0111 D7)`;
144+
}
145+
return `a ${value as string} recipient is delivered via a sharing rule with recipient type '${route.recipientType}'`
146+
+ (route.renamedBy !== undefined ? `, its name since the ${route.renamedBy} rename` : '')
147+
+ ' (ADR-0111 D7)';
148+
}
149+
104150
/**
105151
* Owner field convention. Hard-coded to `owner_id` for MVP — the
106152
* sharing model in Salesforce / ServiceNow / Dynamics all assume a
@@ -1383,8 +1429,11 @@ export class SharingService implements ISharingService {
13831429
const recipientType = input.recipientType ?? 'user';
13841430
// [ADR-0111 D7] Only `user` recipients are consulted by the read/write
13851431
// gates; persisting any other type would be a silently inert grant
1386-
// (ADR-0078). Group / business-unit principals are delivered via sharing
1387-
// rules, whose evaluator expands them into per-user rows.
1432+
// (ADR-0078). Group / position / business-unit principals are delivered
1433+
// via sharing rules, whose evaluator expands them into per-user rows; the
1434+
// refusal names the rule recipient for each declared value
1435+
// (`RULE_ROUTE_BY_RECORD_SHARE_RECIPIENT`), because the rule vocabulary
1436+
// does not spell every one of them the same way (`group` is `team`).
13881437
if (recipientType !== 'user') {
13891438
// [ADR-0138 D8] A retired value no longer typechecks, yet still reaches
13901439
// here untyped. It is told where guest access lives, never that a sharing
@@ -1394,7 +1443,7 @@ export class SharingService implements ISharingService {
13941443
retired !== undefined
13951444
? `VALIDATION_FAILED: ${retired}`
13961445
: `VALIDATION_FAILED: recipientType must be 'user' (received ${JSON.stringify(recipientType)}) — `
1397-
+ `group/position recipients are delivered via sharing rules (ADR-0111 D7)`,
1446+
+ nonUserRecipientTypeRemedy(recipientType),
13981447
);
13991448
}
14001449
// Validate BEFORE any write. Previously anything at all was persisted

0 commit comments

Comments
 (0)