Skip to content
25 changes: 25 additions & 0 deletions .changeset/23228-record-share-guest-recipient-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-sharing': minor
---

fix(spec,plugin-sharing)!: `sys_record_share` no longer offers a `guest` recipient, which no grant ever honoured (ADR-0138 D8)

Clause-②: no (narrowing: a declared recipient value no grant honours is refused)

<!-- adr-0087: not-required (no-migration-prescription) No working use of the value exists to rewrite. `ISharingService.grant` refused every non-`user` recipient before this change, so no grant naming `guest` ever succeeded, and the census pinned in `@objectstack/spec` finds no seed, fixture, example, template, migration or test row in this repository that writes `recipient_type` as `guest`, and no stored metadata surface that carries it. Seed data, the one metadata surface that can carry a row value, targets `sys_record_share` nowhere in the tree (the examples seed `sys_user` and `sys_business_unit`, never a record share). What narrows is a published TypeScript union in `packages/spec/src/contracts/` and the option set of a platform object's select: a caller that still spells the value meets a compile error or a located refusal, and there is nothing for `objectstack migrate meta` to derive from metadata. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this value and this diff adds none (not registered or already-registered); the union lives under `packages/spec/src/contracts/` and the diff touches `packages/spec` (not runtime-interface-only or type-surface-only); no export is removed (not runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

A record share to the anonymous principal was declared in two places and honoured in none. Both declarations are gone:

- `RecordShareRecipientType` (`@objectstack/spec/contracts`) is now `'user' | 'group' | 'position' | 'unit_and_subordinates'`. Passing `recipientType: 'guest'` to `ISharingService.grant`, or typing a `RecordShare.recipient_type` as `'guest'`, is a compile error.
- The `recipient_type` select on `sys_record_share` (`@objectstack/plugin-sharing`) offers those same four options, and its `guest` label is gone from the four translation bundles.

Why: the grant refused `guest` (ADR-0078), and no gate reads such a row, because the read and write gates match `recipient_type: 'user'` only. ADR-0090 D11 routes guest access through the guest position and share links, so a record share to the guest had nothing to gain. The sharing-rule vocabulary retired the same recipient in v17.

What a caller sees now:

- **The grant door** (`POST /data/:object/:id/shares`, or `grant()` from untyped code). A body naming `recipientType: 'guest'` is still refused with `VALIDATION_FAILED` (`400`), as before. The sentence now says the value was removed and that guest access flows through the guest position and share links. It used to say that sharing rules deliver non-`user` recipients, which was never true of a guest. To let a guest reach a record, use a public form or a share link.
- **The row door** (a system-context engine write: a seed, a plugin, a migration script). A row carrying `recipient_type: 'guest'` is now refused with `VALIDATION_FAILED`, an `invalid_option` error on `recipient_type` that lists the four values a row may carry. Before this change such a write stored a row that looked granted and enforced nothing.
- **Unchanged:** every `user` grant, the other three row values, the sharing-rule service, and the gates. A `guest` row that a system write stored before this release is read by no gate, both before and after.
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ export const SysRecordShare = ObjectSchema.create({

// ── Recipient (who receives access) ──────────────────────────
recipient_type: Field.select(
['user', 'group', 'position', 'unit_and_subordinates', 'guest'],
// `guest` was removed (ADR-0138 D8): a share to the anonymous principal
// was declared here and honoured nowhere — `grant()` refused it and no
// gate reads such a row — since guest access flows through the guest
// position and share links only (ADR-0090 D11). Held equal to
// `RecordShareRecipientType` in `@objectstack/spec/contracts`.
['user', 'group', 'position', 'unit_and_subordinates'],
{
label: 'Recipient Type',
required: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0138 D8] `sys_record_share`'s `guest` recipient is retired at the ROW
* door — on a REAL engine over a REAL driver, with the real `SysRecordShare`.
*
* The grant door refuses `'guest'` with its prescription
* (`sharing-service.test.ts`, "[ADR-0138 D8]"). What it cannot prove is the
* door every other producer of a row crosses: a system-context engine write —
* a seed, a plugin, a migration — which `grant()` never sees. Before the
* retirement the `recipient_type` select offered `guest`, so such a write
* PERSISTED a row that no gate reads (the gates match `recipient_type: 'user'`
* only): a share that looked granted and enforced nothing (ADR-0078). Now the
* select's option arm refuses it, naming the values a row may carry.
*
* The control row (`user`) is the same write with one value changed, so a red
* on the guest case cannot come from the harness.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { SysRecordShare } from './objects/sys-record-share.object.js';

const SYS = { context: { isSystem: true } } as never;

/** The four values a row may carry, in the select's declared order. */
const ROW_RECIPIENTS = ['user', 'group', 'position', 'unit_and_subordinates'];

const row = (id: string, recipientType: string) => ({
id,
object_name: 'account',
record_id: 'acc_1',
recipient_type: recipientType,
recipient_id: 'usr_target',
access_level: 'read',
source: 'manual',
});

let engine: ObjectQL;

beforeAll(async () => {
engine = new ObjectQL();
engine.registerDriver(
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
true,
);
await engine.init();
engine.registerApp({
id: 'com.objectstack.qa.record-share-guest-recipient-retired',
name: 'Record share: the retired guest recipient',
version: '1.0.0',
type: 'plugin',
scope: 'system',
objects: [SysRecordShare],
} as never);
await engine.syncSchemas();
});

afterAll(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
});

describe('[ADR-0138 D8] the row door refuses a guest recipient_type', () => {
it('the declared select offers exactly the four row recipients, guest not among them', () => {
const options = (SysRecordShare.fields.recipient_type as { options?: unknown[] }).options ?? [];
const values = options.map((o) => (typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o));
expect(values).toEqual(ROW_RECIPIENTS);
});

it('a system-context insert carrying recipient_type guest is refused on that field, and nothing lands', async () => {
const refusal = await engine.insert('sys_record_share', [row('shr_guest', 'guest')], SYS)
.then(() => null, (e: unknown) => e as { code?: unknown; fields?: unknown[] });
expect(refusal, 'the guest row was admitted').not.toBeNull();
expect(refusal!.code).toBe('VALIDATION_FAILED');
const fieldErrors = (refusal!.fields ?? []) as Array<{ field?: string; code?: string; options?: string[] }>;
expect(fieldErrors).toHaveLength(1);
expect(fieldErrors[0]).toMatchObject({ field: 'recipient_type', code: 'invalid_option' });
expect(fieldErrors[0]!.options).toEqual(ROW_RECIPIENTS);
const stored = await engine.findOne('sys_record_share', { where: { id: 'shr_guest' }, context: { isSystem: true } } as never);
expect(stored ?? null).toBeNull();
});

it('control: the same insert with recipient_type user lands', async () => {
await engine.insert('sys_record_share', [row('shr_user', 'user')], SYS);
const stored = await engine.findOne('sys_record_share', { where: { id: 'shr_user' }, context: { isSystem: true } } as never) as { recipient_type?: string } | null;
expect(stored?.recipient_type).toBe('user');
});
});
22 changes: 21 additions & 1 deletion packages/plugins/plugin-sharing/src/sharing-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ describe('[ADR-0111 D7] no inert grants', () => {
it('refuses non-user recipient types instead of persisting rows no gate reads', async () => {
// Every non-`user` member of RecordShareRecipientType — no `as any`
// needed since #4539 aligned the contract type to the storage select.
for (const recipientType of ['group', 'position', 'unit_and_subordinates', 'guest'] as const) {
for (const recipientType of ['group', 'position', 'unit_and_subordinates'] as const) {
await expect(
svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'g1', recipientType },
Expand All @@ -1057,6 +1057,26 @@ describe('[ADR-0111 D7] no inert grants', () => {
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
});

it("[ADR-0138 D8] refuses the retired 'guest' recipient with its prescription, never the sharing-rule route", async () => {
// `guest` left RecordShareRecipientType, so only an untyped caller (a REST
// body) can still send it. The grant's one error channel is the message
// prefix (`rest-server.ts`'s `respondSharingError` maps it to a 400); the
// pin asserts that prefix, the prescription, and that nothing was written.
// A sharing rule cannot deliver a guest either, so the generic sentence
// ("delivered via sharing rules") would misdirect the caller.
const refusal = await svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'anyone', recipientType: 'guest' as never },
{ userId: 'alice' },
).then(() => null, (e: unknown) => e);
expect(refusal).toBeInstanceOf(Error);
const message = (refusal as Error).message;
expect(message.startsWith("VALIDATION_FAILED: recipientType 'guest' was removed (ADR-0138 D8)")).toBe(true);
expect(message).toMatch(/guest access flows through the guest position and share links \(ADR-0090 D11\)/);
expect(message).toMatch(/Expose the record through a public form or a share link instead\.$/);
expect(message).not.toMatch(/sharing rules/);
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
});

it('refuses a grant on a public object (no gate would ever consult it)', async () => {
await expect(
svc.grant({ object: 'whiteboard', recordId: 'w1', recipientId: 'bob' }, { userId: 'alice' }),
Expand Down
31 changes: 29 additions & 2 deletions packages/plugins/plugin-sharing/src/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ function makeShareId(): string {
/** System-elevated context for the plugin's own queries / mutations. */
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;

/**
* [ADR-0138 D8] Recipient values `RecordShareRecipientType` no longer declares,
* each with the refusal {@link SharingService.grant} answers when one still
* arrives at runtime — a REST body, an untyped caller. A retired value is told
* where its access actually lives; every other non-`user` value keeps the
* generic refusal in `grant()`. Keyed by the value exactly as a caller sends it.
*/
const RETIRED_RECIPIENT_TYPE_REFUSALS: Readonly<Record<string, string>> = {
guest:
"recipientType 'guest' was removed (ADR-0138 D8) — a record share to the anonymous principal "
+ 'was never honoured, because guest access flows through the guest position and share links '
+ '(ADR-0090 D11). Expose the record through a public form or a share link instead.',
};

/** The retired-value refusal for a `recipientType` a caller sent, or `undefined`. */
function retiredRecipientTypeRefusal(value: unknown): string | undefined {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(RETIRED_RECIPIENT_TYPE_REFUSALS, value)
? RETIRED_RECIPIENT_TYPE_REFUSALS[value]
: undefined;
}

/**
* Owner field convention. Hard-coded to `owner_id` for MVP — the
* sharing model in Salesforce / ServiceNow / Dynamics all assume a
Expand Down Expand Up @@ -1343,9 +1364,15 @@ export class SharingService implements ISharingService {
// (ADR-0078). Group / business-unit principals are delivered via sharing
// rules, whose evaluator expands them into per-user rows.
if (recipientType !== 'user') {
// [ADR-0138 D8] A retired value no longer typechecks, yet still reaches
// here untyped. It is told where guest access lives, never that a sharing
// rule delivers it: the rule vocabulary retired the same recipient.
const retired = retiredRecipientTypeRefusal(recipientType);
throw new Error(
`VALIDATION_FAILED: recipientType must be 'user' (received ${JSON.stringify(recipientType)}) — `
+ `group/position recipients are delivered via sharing rules (ADR-0111 D7)`,
retired !== undefined
? `VALIDATION_FAILED: ${retired}`
: `VALIDATION_FAILED: recipientType must be 'user' (received ${JSON.stringify(recipientType)}) — `
+ `group/position recipients are delivered via sharing rules (ADR-0111 D7)`,
);
}
// Validate BEFORE any write. Previously anything at all was persisted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
user: "user",
group: "group",
position: "position",
unit_and_subordinates: "unit_and_subordinates",
guest: "guest"
unit_and_subordinates: "unit_and_subordinates"
}
},
recipient_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
user: "Usuario",
group: "Grupo",
position: "Puesto",
unit_and_subordinates: "Unidad de negocio y subordinados",
guest: "Invitado"
unit_and_subordinates: "Unidad de negocio y subordinados"
}
},
recipient_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
user: "ユーザー",
group: "グループ",
position: "ポジション",
unit_and_subordinates: "ビジネスユニットと下位階層",
guest: "ゲスト"
unit_and_subordinates: "ビジネスユニットと下位階層"
}
},
recipient_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
user: "用户",
group: "组",
position: "岗位",
unit_and_subordinates: "业务单元及下级",
guest: "访客"
unit_and_subordinates: "业务单元及下级"
}
},
recipient_id: {
Expand Down
Loading
Loading