From 050e8eda5bdce5af21548cdb7e7443b23c97e90a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:03:59 +0000 Subject: [PATCH 1/5] wip(spec,plugin-sharing): retire the record-share guest recipient (ADR-0138 D8) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../src/objects/sys-record-share.object.ts | 7 ++++- .../plugin-sharing/src/sharing-service.ts | 31 +++++++++++++++++-- .../spec/src/contracts/sharing-service.ts | 12 +++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index a8a16f177ef..b2faffca45e 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -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, diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 7d173f74220..0f9c977f574 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -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> = { + 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 @@ -1346,9 +1367,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 diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index d76cec0bc7e..26a2e9669ed 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -87,6 +87,15 @@ import type { ExecutionContext } from '../kernel/execution-context.zod.js'; * as expanded `user` rows. The remaining members are persisted-for-forward- * compatibility vocabulary, kept in lockstep with the select. * + * `guest` is NOT a member: it was removed (ADR-0138 D8). A record share to the + * anonymous principal was declared and never honoured — the grant refused it, + * and no gate reads such a row — because guest access flows through the guest + * position and share links only (ADR-0090 D11). A record a guest must reach is + * exposed through a public form or a share link. {@link ISharingService.grant} + * refuses a `'guest'` that still arrives at runtime with that prescription, and + * the select refuses it on a row write. The sharing-RULE vocabulary retired the + * same recipient earlier (the ADR-0087 entry `sharing-rule-recipient-reconcile`). + * * NOT the sharing-RULE recipient vocabulary — that is * {@link SharingRuleRecipientType} here, whose authorable subset is * `ShareRecipientType` in `spec/security` (a different concept that shares @@ -96,8 +105,7 @@ export type RecordShareRecipientType = | 'user' | 'group' | 'position' - | 'unit_and_subordinates' - | 'guest'; + | 'unit_and_subordinates'; /** * Access level on a single record — the authorable subset, mirroring From 844142d24da45538eb0b6c12dfc6dade02742cd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:25:13 +0000 Subject: [PATCH 2/5] test(spec,plugin-sharing): pin the guest recipient's refusal at both doors and its census; regenerate translations Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- ...cord-share-guest-recipient-retired.test.ts | 90 ++++++++ .../src/sharing-service.test.ts | 22 +- .../src/translations/en.objects.generated.ts | 3 +- .../translations/es-ES.objects.generated.ts | 3 +- .../translations/ja-JP.objects.generated.ts | 3 +- .../translations/zh-CN.objects.generated.ts | 3 +- ...ecord-share-guest-recipient-census.test.ts | 214 ++++++++++++++++++ .../src/contracts/sharing-service.test.ts | 17 +- packages/spec/vitest.repo-tests.json | 1 + 9 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts create mode 100644 packages/spec/src/contracts/record-share-guest-recipient-census.test.ts diff --git a/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts b/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts new file mode 100644 index 00000000000..253249d71a9 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts @@ -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'); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index f38e449cef0..fa8a6ed0883 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -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 }, @@ -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' }), diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index d7b0eb5e929..91bc66e89c9 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -38,8 +38,7 @@ export const enObjects: NonNullable = { user: "user", group: "group", position: "position", - unit_and_subordinates: "unit_and_subordinates", - guest: "guest" + unit_and_subordinates: "unit_and_subordinates" } }, recipient_id: { diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index a5b48a9ec22..f6e04ca6e95 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -38,8 +38,7 @@ export const esESObjects: NonNullable = { 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: { diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 14aabe67067..0b5e05abd97 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -38,8 +38,7 @@ export const jaJPObjects: NonNullable = { user: "ユーザー", group: "グループ", position: "ポジション", - unit_and_subordinates: "ビジネスユニットと下位階層", - guest: "ゲスト" + unit_and_subordinates: "ビジネスユニットと下位階層" } }, recipient_id: { diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index d716fd31a4b..1cb1048573c 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -38,8 +38,7 @@ export const zhCNObjects: NonNullable = { user: "用户", group: "组", position: "岗位", - unit_and_subordinates: "业务单元及下级", - guest: "访客" + unit_and_subordinates: "业务单元及下级" } }, recipient_id: { diff --git a/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts new file mode 100644 index 00000000000..4aab191c559 --- /dev/null +++ b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_record_share`'s `guest` recipient RETIRED (ADR-0138 D8) — the census. + * + * The record's row: "remove, on its own card"; the pin it names: "a census + * showing no stored row carries it". The value was declared in two places — + * the `recipient_type` select on `SysRecordShare` (`@objectstack/plugin-sharing`) + * and the contract union `RecordShareRecipientType` here — and honoured by + * none: `grant()` refused it and no gate reads such a row, because guest access + * flows through the guest position and share links (ADR-0090 D11). + * + * Three legs, one walk: + * 1. PRODUCERS — no source inside the declared radius writes + * `recipient_type` / `recipientType` as `guest` (a seed, a fixture, an + * example, a template, a test row, a REST body, a migration). The key is + * shared with `sys_sharing_rule`, whose vocabulary retired the same value + * earlier (`sharing-rule-recipient-reconcile`), so a hit on either row + * kind is a resurrection of a dead value, never a live one. + * 2. THE SELECT — the `sys_record_share` object's `recipient_type` options, + * read from its declaration, do not offer `guest`. + * 3. THE UNION — `RecordShareRecipientType`'s literal members do not name it + * (the compile-time half is `sharing-service.test.ts`'s `@ts-expect-error`). + * + * Each leg carries a planted positive control (the matcher finds the value + * where it is written) and a lit control on the real tree (the matcher finds + * the LIVE values — `user` producers, the `user` option, the `user` member) so + * a zero is a measurement, not a matcher that reads nothing. + * + * The bound, stated: a value assembled at runtime (`recipient_type: kind`, a + * spread, a computed key) is invisible to a text walk; `docs/**`, `.claude/**`, + * `.github/**` and the repo-root files are outside the radius, which is the + * one declared for `@objectstack/spec` in `scripts/cross-package-test-inputs.mjs`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); +const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + +/** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ +const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; +const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); +/** Under `examples/` the non-code extensions everywhere, and `.ts` under each app's `src/` only — what is declared. */ +const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); +const EXAMPLES_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; +const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); +/** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ +const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + +/** + * Structural exclusions, each with its reason. ⛔ NOT an allowlist file: every + * entry's JOB is to spell the retired value. + */ +const EXCLUDED = new Set([ + // This census plants the value in its positive controls. + THIS_FILE, + // The grant door's refusal pin sends `recipientType: 'guest'` on purpose. + 'packages/plugins/plugin-sharing/src/sharing-service.test.ts', +]); +const EXCLUDED_PREFIXES = [ + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', +]; + +/** Leg 1: a key spelling of the recipient type, then `:` (or a lone `=`), then `guest` QUOTED. */ +const PRODUCER_CODE = /(?:^|[^\w$])['"]?recipient_?[tT]ype['"]?\s*(?::|=(?![=>]))\s*(['"`])guest\1/g; +/** Leg 1, YAML: a mapping line whose key is the recipient type and whose value is `guest`, quoted or not. */ +const PRODUCER_YAML = /^\s*(?:-\s+)?recipient_?[tT]ype\s*:\s*(['"]?)guest\1\s*(?:#.*)?$/gm; + +const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; + +const producersIn = (ext: string, text: string, live = 'guest'): number[] => { + if (!text.includes(live)) return []; + const out: number[] = []; + const code = new RegExp(PRODUCER_CODE.source.replace('guest', live), 'g'); + for (let m = code.exec(text); m; m = code.exec(text)) out.push(lineOf(text, m.index)); + if (ext === '.yaml' || ext === '.yml' || ext === '.md' || ext === '.mdx') { + const yaml = new RegExp(PRODUCER_YAML.source.replace('guest', live), 'gm'); + for (let m = yaml.exec(text); m; m = yaml.exec(text)) out.push(lineOf(text, m.index)); + } + return [...new Set(out)].sort((a, b) => a - b); +}; + +/** The string literals inside the bracketed span that starts at `open`, comments stripped. */ +const literalsInBrackets = (text: string, open: number): string[] => { + let depth = 0; + let end = -1; + for (let i = open; i < text.length; i += 1) { + if (text[i] === '[') depth += 1; + else if (text[i] === ']') { depth -= 1; if (depth === 0) { end = i; break; } } + } + if (end < 0) return []; + const body = text.slice(open + 1, end).replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + return [...body.matchAll(/(['"`])([^'"`]*)\1/g)].map((m) => m[2]!); +}; + +/** Leg 2: the options of the `recipient_type` select in a file that declares `sys_record_share`, or `null`. */ +const recordShareSelectOptions = (text: string): string[] | null => { + if (!/name:\s*['"]sys_record_share['"]/.test(text)) return null; + const at = text.search(/recipient_type\s*:\s*Field\.select\(/); + if (at < 0) return null; + const open = text.indexOf('[', at); + return open < 0 ? null : literalsInBrackets(text, open); +}; + +/** Leg 3: the literal members of `RecordShareRecipientType`, or `null` when the file does not declare it. */ +const unionMembers = (text: string): string[] | null => { + const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(text); + if (!m) return null; + return [...m[1]!.replace(/\/\/[^\n]*/g, '').matchAll(/'([^']*)'/g)].map((x) => x[1]!); +}; + +describe('record-share guest recipient census — the matchers can see the value (planted positive controls)', () => { + it('leg 1 finds a producer in each spelling the walk reads, and ignores the neighbours', () => { + expect(producersIn('.ts', "insert('sys_record_share', [{ id: 's', recipient_type: 'guest', recipient_id: 'x' }])")).toEqual([1]); + expect(producersIn('.ts', "svc.grant({ object: 'a', recordId: 'r',\n recipientType: \"guest\" })")).toEqual([2]); + expect(producersIn('.ts', "const input = {};\ninput.recipientType = 'guest';")).toEqual([2]); + expect(producersIn('.json', '{ "records": [ { "recipient_type": "guest" } ] }')).toEqual([1]); + expect(producersIn('.yaml', 'records:\n - recipient_type: guest\n recipient_id: x\n')).toEqual([2]); + expect(producersIn('.md', "Seed:\n\n```ts\n({ recipient_type: 'guest' });\n```\n")).toEqual([4]); + // Neighbours that must NOT match: a comparison (a reader), another key, + // the anchor vocabulary, a different value, and prose naming the word. + expect(producersIn('.ts', "if (row.recipient_type === 'guest') return;")).toEqual([]); + expect(producersIn('.ts', "({ anchor: 'guest', principalKind: 'guest', type: 'guest' })")).toEqual([]); + expect(producersIn('.ts', "({ recipient_type: 'guest_portal' })")).toEqual([]); + expect(producersIn('.md', 'The guest recipient type was removed.')).toEqual([]); + }); + + it('leg 2 reads the options of the record-share select, and finds a planted guest option', () => { + const planted = "ObjectSchema.create({ name: 'sys_record_share', fields: {\n" + + " recipient_type: Field.select(\n // a comment naming 'nothing'\n ['user', 'guest'],\n { label: 'x' },\n ),\n} })"; + expect(recordShareSelectOptions(planted)).toEqual(['user', 'guest']); + // Another object's `recipient_type` select is not this object's. + expect(recordShareSelectOptions("ObjectSchema.create({ name: 'sys_sharing_rule', fields: { recipient_type: Field.select(['user', 'guest']) } })")).toBeNull(); + }); + + it('leg 3 reads the union members, and finds a planted guest member', () => { + expect(unionMembers("export type RecordShareRecipientType =\n | 'user'\n | 'guest';")).toEqual(['user', 'guest']); + expect(unionMembers("export type SharingRuleRecipientType = 'user' | 'guest';")).toBeNull(); + }); +}); + +describe('record-share guest recipient census — nothing inside the declared radius carries it', () => { + it('no producer writes it, the select does not offer it, the union does not name it', () => { + const producers: string[] = []; + const liveProducers: string[] = []; + const selects: { rel: string; options: string[] }[] = []; + const unions: { rel: string; members: string[] }[] = []; + let visited = 0; + let recordShareFiles = 0; + + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const declared = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLES_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!declared) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + let text: string; + try { + text = fs.readFileSync(full, 'utf-8'); + } catch (err) { + // Tolerate ONLY a path that vanished mid-walk (a concurrent build). + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') continue; + throw err; + } + visited += 1; + if (text.includes('sys_record_share')) recordShareFiles += 1; + for (const line of producersIn(ext, text)) producers.push(`${rel}:${line}`); + for (const line of producersIn(ext, text, 'user')) liveProducers.push(`${rel}:${line}`); + if (ext === '.ts') { + const options = recordShareSelectOptions(text); + if (options) selects.push({ rel, options }); + const members = unionMembers(text); + if (members) unions.push({ rel, members }); + } + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + + // Lit controls: the walk covered the tree, it read the files that hold + // record shares, and the producer matcher finds the LIVE value at scale. + expect(visited).toBeGreaterThan(1000); + expect(recordShareFiles).toBeGreaterThan(20); + expect(liveProducers.length).toBeGreaterThan(20); + // Leg 1. + expect(producers, 'a recipient_type / recipientType written as guest is a share no gate honours (ADR-0138 D8)').toEqual([]); + // Leg 2: exactly one declaration, which the matcher really read (`user` is there). + expect(selects.map((s) => s.rel)).toEqual(['packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts']); + expect(selects[0]!.options).toContain('user'); + expect(selects[0]!.options).not.toContain('guest'); + // Leg 3: exactly one declaration, likewise lit. + expect(unions.map((u) => u.rel)).toEqual(['packages/spec/src/contracts/sharing-service.ts']); + expect(unions[0]!.members).toContain('user'); + expect(unions[0]!.members).not.toContain('guest'); + }); +}); diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts index e289b316cdc..4537b4f0103 100644 --- a/packages/spec/src/contracts/sharing-service.test.ts +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -43,19 +43,30 @@ describe('Sharing Service Contract — recipient vocabularies, each under its ow 'group', 'position', 'unit_and_subordinates', - 'guest', ]; // @ts-expect-error `role` never was a persistable recipient_type value const notARowRecipient: RecordShareRecipientType = 'role'; - expect(rowRecipients).toHaveLength(5); + expect(rowRecipients).toHaveLength(4); expect(notARowRecipient).toBe('role'); }); + it('`guest` is not a record-share recipient (ADR-0138 D8)', () => { + // Declared and never honoured: the grant refused it and no gate reads such + // a row, because guest access flows through the guest position and share + // links (ADR-0090 D11). Removed from the union and from the select together. + // @ts-expect-error `guest` was removed from RecordShareRecipientType (ADR-0138 D8) + const retired: RecordShareRecipientType = 'guest'; + const isMember: Assert, never>> = true; + expect(retired).toBe('guest'); + expect(isMember).toBe(true); + }); + it('security ShareRecipientType is the RULE vocabulary and shares no declaration', () => { // The authorable rule-recipient enum: SharingRuleRecipientType minus the // reserved `queue`. Distinct concept, distinct values — `group` / `guest` // were deliberately removed from it (ADR-0078) while the record-share ROW - // vocabulary keeps persisting them for forward compatibility. + // vocabulary keeps persisting `group` for forward compatibility (its + // `guest` went too, ADR-0138 D8). expect(ShareRecipientType.options).toEqual([ 'user', 'team', diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 703544c3f08..a0bbb35c6d4 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -29,6 +29,7 @@ "src/api/error-catalog-docs.test.ts", "src/api/export-job-family-retirement.test.ts", "src/api/rest-api-config-dead-keys-retirement.test.ts", + "src/contracts/record-share-guest-recipient-census.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/data/cube-member-inner-name-retirement.test.ts", "src/data/cube-refresh-key-retirement.test.ts", From a3b7487ddeac80ce0bcdc1034b550dd397888794 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:26:29 +0000 Subject: [PATCH 3/5] chore(changeset): the record-share guest recipient retirement, not-required (no-migration-prescription) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- ...28-record-share-guest-recipient-retired.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/23228-record-share-guest-recipient-retired.md diff --git a/.changeset/23228-record-share-guest-recipient-retired.md b/.changeset/23228-record-share-guest-recipient-retired.md new file mode 100644 index 00000000000..887a088a2a8 --- /dev/null +++ b/.changeset/23228-record-share-guest-recipient-retired.md @@ -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) + + + +**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. From ebcdce5d354da317717b3f85038f4ed93d304345 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:09:03 +0000 Subject: [PATCH 4/5] test(spec): the census reads code through the shared comment mask Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- ...ecord-share-guest-recipient-census.test.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts index 4aab191c559..866149d52dc 100644 --- a/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts +++ b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts @@ -39,6 +39,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); @@ -75,8 +77,13 @@ const PRODUCER_YAML = /^\s*(?:-\s+)?recipient_?[tT]ype\s*:\s*(['"]?)guest\1\s*(? const lineOf = (text: string, index: number): number => text.slice(0, index).split('\n').length; -const producersIn = (ext: string, text: string, live = 'guest'): number[] => { - if (!text.includes(live)) return []; +/** Extensions read as JS/TS source: their comments are blanked first, since a comment writes no row. */ +const CODE_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']); + +const producersIn = (ext: string, raw: string, live = 'guest'): number[] => { + if (!raw.includes(live)) return []; + // `maskComments` blanks comment content and keeps offsets and line numbers. + const text = CODE_EXT.has(ext) ? maskComments(raw) : raw; const out: number[] = []; const code = new RegExp(PRODUCER_CODE.source.replace('guest', live), 'g'); for (let m = code.exec(text); m; m = code.exec(text)) out.push(lineOf(text, m.index)); @@ -87,7 +94,7 @@ const producersIn = (ext: string, text: string, live = 'guest'): number[] => { return [...new Set(out)].sort((a, b) => a - b); }; -/** The string literals inside the bracketed span that starts at `open`, comments stripped. */ +/** The string literals inside the bracketed span that starts at `open`, in comment-masked source. */ const literalsInBrackets = (text: string, open: number): string[] => { let depth = 0; let end = -1; @@ -96,12 +103,12 @@ const literalsInBrackets = (text: string, open: number): string[] => { else if (text[i] === ']') { depth -= 1; if (depth === 0) { end = i; break; } } } if (end < 0) return []; - const body = text.slice(open + 1, end).replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); - return [...body.matchAll(/(['"`])([^'"`]*)\1/g)].map((m) => m[2]!); + return [...text.slice(open + 1, end).matchAll(/(['"`])([^'"`]*)\1/g)].map((m) => m[2]!); }; /** Leg 2: the options of the `recipient_type` select in a file that declares `sys_record_share`, or `null`. */ -const recordShareSelectOptions = (text: string): string[] | null => { +const recordShareSelectOptions = (raw: string): string[] | null => { + const text = maskComments(raw); if (!/name:\s*['"]sys_record_share['"]/.test(text)) return null; const at = text.search(/recipient_type\s*:\s*Field\.select\(/); if (at < 0) return null; @@ -110,10 +117,10 @@ const recordShareSelectOptions = (text: string): string[] | null => { }; /** Leg 3: the literal members of `RecordShareRecipientType`, or `null` when the file does not declare it. */ -const unionMembers = (text: string): string[] | null => { - const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(text); +const unionMembers = (raw: string): string[] | null => { + const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(maskComments(raw)); if (!m) return null; - return [...m[1]!.replace(/\/\/[^\n]*/g, '').matchAll(/'([^']*)'/g)].map((x) => x[1]!); + return [...m[1]!.matchAll(/'([^']*)'/g)].map((x) => x[1]!); }; describe('record-share guest recipient census — the matchers can see the value (planted positive controls)', () => { @@ -130,6 +137,8 @@ describe('record-share guest recipient census — the matchers can see the value expect(producersIn('.ts', "({ anchor: 'guest', principalKind: 'guest', type: 'guest' })")).toEqual([]); expect(producersIn('.ts', "({ recipient_type: 'guest_portal' })")).toEqual([]); expect(producersIn('.md', 'The guest recipient type was removed.')).toEqual([]); + // A comment writes no row; the same words in a string literal do. + expect(producersIn('.ts', "// once: recipient_type: 'guest'\nconst x = 1;")).toEqual([]); }); it('leg 2 reads the options of the record-share select, and finds a planted guest option', () => { @@ -143,6 +152,8 @@ describe('record-share guest recipient census — the matchers can see the value it('leg 3 reads the union members, and finds a planted guest member', () => { expect(unionMembers("export type RecordShareRecipientType =\n | 'user'\n | 'guest';")).toEqual(['user', 'guest']); expect(unionMembers("export type SharingRuleRecipientType = 'user' | 'guest';")).toBeNull(); + // A member named only in a comment is not a member. + expect(unionMembers("export type RecordShareRecipientType =\n | 'user'\n // | 'guest'\n ;")).toEqual(['user']); }); }); From f0d76f257893ce16ea7e004b3e99c9aa4d55855b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:20:14 +0000 Subject: [PATCH 5/5] test(spec): the census masks only files that spell the key, so the walk fits the repo tier's timeout Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../contracts/record-share-guest-recipient-census.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts index 866149d52dc..42be812b688 100644 --- a/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts +++ b/packages/spec/src/contracts/record-share-guest-recipient-census.test.ts @@ -80,8 +80,11 @@ const lineOf = (text: string, index: number): number => text.slice(0, index).spl /** Extensions read as JS/TS source: their comments are blanked first, since a comment writes no row. */ const CODE_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']); +/** Cheap pre-filter: a file that never spells the key cannot hold a producer (and is never masked). */ +const SPELLS_RECIPIENT_KEY = /recipient_?[tT]ype/; + const producersIn = (ext: string, raw: string, live = 'guest'): number[] => { - if (!raw.includes(live)) return []; + if (!raw.includes(live) || !SPELLS_RECIPIENT_KEY.test(raw)) return []; // `maskComments` blanks comment content and keeps offsets and line numbers. const text = CODE_EXT.has(ext) ? maskComments(raw) : raw; const out: number[] = []; @@ -108,6 +111,7 @@ const literalsInBrackets = (text: string, open: number): string[] => { /** Leg 2: the options of the `recipient_type` select in a file that declares `sys_record_share`, or `null`. */ const recordShareSelectOptions = (raw: string): string[] | null => { + if (!raw.includes('sys_record_share') || !raw.includes('recipient_type')) return null; const text = maskComments(raw); if (!/name:\s*['"]sys_record_share['"]/.test(text)) return null; const at = text.search(/recipient_type\s*:\s*Field\.select\(/); @@ -118,6 +122,7 @@ const recordShareSelectOptions = (raw: string): string[] | null => { /** Leg 3: the literal members of `RecordShareRecipientType`, or `null` when the file does not declare it. */ const unionMembers = (raw: string): string[] | null => { + if (!raw.includes('RecordShareRecipientType')) return null; const m = /export\s+type\s+RecordShareRecipientType\s*=([^;]*);/.exec(maskComments(raw)); if (!m) return null; return [...m[1]!.matchAll(/'([^']*)'/g)].map((x) => x[1]!);