Skip to content

Commit 99e4eaa

Browse files
fix(plugin-sharing): a record share's Recipient column names the recipient through the recipient-picker face (#24831)
Fixes #24701 Clause-②: no Half (b) of the card: the record share's Recipient column draws the recipient's raw id. Half (a) landed earlier as PR #24733 ("Part of"). This PR does what the seat's ruling `6282390889` ordered (route B, a display declaration only). ## What changes `sys_record_share.recipient_id` (`packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts`) now declares: ```ts widget: 'recipient-picker', dependsOn: ['recipient_type'], ``` This is the display declaration `sys_sharing_rule.recipient_id` already carries, without `object_name` (see M1 below). No stored column, spec key, accepted value, list-view column or objectui file changes. The field stays a required `text` column with `maxLength: 100`. The object stays `engine-owned` with `enable.apiMethods: ['get', 'list']`. The other changed files are a new pin, `src/objects/sys-record-share.recipient-face.test.ts` (7 cases), and a patch changeset, `.changeset/24701-record-share-recipient-face.md`. ## Ruling premises, re-checked before the edit Re-checked on `origin/main` `a43405d59` (the branch point) and at the console pin `.objectui-sha` = `12dc7b47c340`, which is unchanged on main at `307ce4c4b`. The objectui source was read with `git show` and `git grep` at that sha. No browser reading was taken. - **P1 holds.** `all_shares` is the only view whose columns include `recipient_type`. `granted_by_me`, `by_object`, `manual_grants` and `rule_grants` show `recipient_id` without it. `granted_to_me` shows neither. - **P2 holds.** At the pin, `ObjectGrid.tsx` builds `extraFields` from predicate, grouping, concurrency, row-colour, deadline and `referenceVia` fields only. There is no `dependsOn` arm in that projection. `ListView.tsx` has no `dependsOn` hit at all. - **P3 holds.** The type-only cell renderer passes the whole row as `dependentValues` (`ObjectGrid.tsx`, objectui#12613). `useRecipientLabel` (`refLabelDirectory.ts`) builds an empty key when the row has no `recipient_type`, so the entry stays `unasked`. `RecipientCellFace` then falls through to `TextCellRenderer`, the stored text, as today. So at the pin, `all_shares` names the user, and the other four views and the H1 draw what they draw today. The seat files the two console rules (project a faced column's `dependsOn` siblings; draw a widget-hinted `nameField` through its face) after this lands. ## The PM's mechanism assumptions, measured - **M1: `['recipient_type']` is enough.** At the pin, `useRecipientLabel` reads `object_name` only when the kind is `field` (`FIELD_RECIPIENT_TYPE`). `sys_record_share.recipient_type` offers `user`, `group`, `position` and `unit_and_subordinates`, and has no `field` kind. The kinds map as follows: `user` and `unit_and_subordinates` are exact-id record kinds (`RECIPIENT_RECORD_KINDS`), `position` is a registry kind, and `group` is in neither table, so it draws as text. `grant()` writes only `user` (ADR-0111 D7). `object_name` was therefore not added. A pin derives `dependsOn` from the select's kinds, and its control derives the sibling's `['recipient_type', 'object_name']` from that object's `field` kind. - **M2: no write or edit path changes.** In this repository, `field.widget` is read by the spec schema and the liveness ledger only, and no runtime code reads it. The server-side `dependsOn` readers are the lint reference check (`validate-object-field-refs.ts`, which judges that a named sibling exists) and the FLS reference masking. Neither is a write path. The object is `engine-owned` with `apiMethods: ['get', 'list']` and no `userActions`. At the pin, the docblocks of the console's write gates (`plugin-form/src/fieldWriteGate.ts`, `plugin-grid/src/rowCrudAffordances.ts`) say engine-owned objects resolve closed unless they open `userActions`, so no form draws the picker as an editor. Fork case 2 does not apply. - **Fork case 1 does not apply either.** No gate refuses a `recipient-picker` whose `dependsOn` names only `recipient_type`. `validateObjectFieldRefs` over the package's three objects at the branch head gave 0 findings. Its control, with `dependsOn: ['recipient_kind']`, gave 1 finding: `fields.recipient_id.dependsOn[0] "recipient_kind" is not a field on object "sys_record_share"`. - **M3: which gates read the declaration.** See the gate list below. No derived family is specific to field `widget` or `dependsOn`. The ones that read the changed object file are the general census and source families. ## Pins `src/objects/sys-record-share.recipient-face.test.ts`: 1. The declaration, read off the object, is `recipient-picker`, the same value as `sys_sharing_rule.recipient_id`. 2. `dependsOn` names exactly the siblings the face reads for the kinds this object's select stores, and each one is a declared field. 3. Control: the same rule yields the sibling's `['recipient_type', 'object_name']`, because that object stores the `field` kind. 4. Negative: the declaration opens no write path. The stored column is unchanged (`text`, required, `maxLength: 100`, no `reference` or `referenceVia`), the object is `engine-owned`, `apiMethods` stays `['get', 'list']`, and there are no `userActions`. 5. Control: the insert census over this package's non-test `src/` reads the other inserts (`sys_share_link`, `sys_sharing_rule`), so it is never empty. 6. Negative: every `insert` call in the package names its table in a form the census reads: a string literal, or an identifier bound to a same-file string constant. 7. Negative: the only insert of a `sys_record_share` row is `grant()` in `sharing-service.ts`. The census is a TypeScript-AST walk scoped to this package's sources, not a whole-tree scan. The one writer measured outside the package, the CLI's organization-ownership apply, updates `source_id` and inserts nothing. The existing `sharing-pointer-pairs.test.ts`, `sys-sharing-rule.by-object.test.ts` and `field-recipient.test.ts` pins stay green. ### Ablations Each leg ran on the committed fix through `node scripts/ablation-replace.mjs` in wrap mode. Each anchor hit exactly once and the blob changed. A shell trap restored the files from `HEAD`. The pin imports the object and reads `sharing-service.ts` by source path, so no `dist/` sits between a mutation and the run, and no rebuild was needed. | Leg | Mutation | Red case(s) | Received | |:--|:--|:--|:--| | F | delete the two declaration lines (blob `ecfd40c1d865` to `82c721526026`) | 1, 2 | `expected undefined to be 'recipient-picker'`; `expected undefined to deeply equal [ 'recipient_type' ]` | | N1 | `apiMethods: ['get', 'list', 'update']` | 4 | `expected [ 'get', 'list', 'update' ] to deeply equal [ 'get', 'list' ]` | | N2 | add `this.engine.insert('sys_record_share', …)` in `revoke()` | 7 | `+ "sharing-service.ts#revoke"` | | N2b | add `this.engine.insert(tableFromElsewhere(), …)` in `revoke()` | 6 | `+ "sharing-service.ts#revoke: (unresolved: tableFromElsewhere())"` | Every other case stayed green in each leg (`Tests 2 failed / 5 passed`, then `1 failed / 6 passed` three times). Restore was checked per file: `sys-record-share.object.ts` blob `ecfd40c1d865` and `sharing-service.ts` blob `52704b19f94e` both equal `HEAD`, and `git diff HEAD` is empty on both. ## Verification All on head `1dc0ab1bf` (the branch merged with `origin/main` `307ce4c4b` before opening), each under `scripts/pm/os-verify-lock.sh`, with the exit code captured before any pipe: - `pnpm --filter '@objectstack/plugin-sharing...' build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-sharing test`: exit 0, `Test Files 52 passed (52)`, `Tests 1199 passed (1199)`. - `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0. `check:test-typecheck` reports the ledger unchanged (2 files, 3 errors, 3 signatures). `tsc --listFiles -p tsconfig.test.json` lists the new pin, and the only errors are the 3 ledgered ones. Gates on head `1dc0ab1bf`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 68 commands over the 3 changed files. Each was run with its exit code captured before any pipe. - 66 exited 0 on the first pass. - `pnpm check:i18n` first exited 3 (prerequisite: no CLI build). Its prescribed `turbo run build` closure gave VERDICT command-exit 0 (61 tasks). The rerun exited 0: `check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys)`. - `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it needs a whole-workspace build (57 packages without `dist/`). NOT MEASURED, a declared narrowing. The diff changes no entry point, export or build config, and CI's `Lint & Repo Gates` runs it. - `--ran`: `Run reconciliation — 68 derived, 67 run, 1 NOT-MEASURED, 0 UNRUN`, exit 0. Declared to CI and not run locally: `test:repo`, `pnpm lint`, the type-check lanes, `Build Core` and the `Test Core` shards. `check:dev-prereqs` and `check:skill-frame-freshness` were not run, because the diff touches neither the spec build nor the frame files. `check-platform-checklist` was not run, because no exported symbol is renamed or deleted. ## Acceptance notes - **The H1 and four list views are unchanged at this pin** (P2, P3). They follow the two console rules the ruling assigns to objectui, which the seat files after this lands. ⛔ No column was added to a view to feed the face, and no objectui file is edited. - **The `widget` liveness note is now incomplete.** `packages/spec/liveness/field.json` says `widget` is "live on the sys_sharing_rule trio alone". `sys_record_share.recipient_id` now declares one too, so the note under-counts. The verdict (`live`) is unchanged. It is not edited here, because this claim excludes `packages/spec/**`. Carrier: none. - **The HotCRM pass (`6283814156`) ran on 18.0.0-next.5**, published 2026-10-19T11:06Z. That is before `54159fd74` (#24611's fix, 2026-10-20T00:58Z), which took `record_id` out of `highlightFields` so the Details tab draws it as the pointer card. Its "Record shows a raw id on the record overlay" reading is therefore not a reading of current `main`. It is NOT MEASURED here (no browser reading). Its "no column names the granting rule" reading is a product question, not a defect class: `rule_grants` shows `source_id`, and `all_shares` does not. Neither item is edited. - `sys_record_share.recipient_type` still offers `group`, `position` and `unit_and_subordinates`, which no writer produces. That is ruling C's protocol question, which the seat kept off this card. Not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7697ddc commit 99e4eaa

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
fix(plugin-sharing): a record share's Recipient column names the recipient instead of drawing its raw id
6+
7+
Clause-②: no
8+
9+
- **`sys_record_share.recipient_id` declares `widget: 'recipient-picker'` and `dependsOn: ['recipient_type']`**, the display declaration `sys_sharing_rule.recipient_id` already carries. The console draws a field with this widget by naming the stored id through the row's `recipient_type`, so a user grant reads as the user's name in a list that shows the Recipient Type column beside it (Setup → Record Shares → All). A list that does not fetch `recipient_type` still draws the stored id.
10+
- **`object_name` is not a dependency here**: the face reads it only for the `field` recipient kind, which a record share cannot store.
11+
- **Unchanged:** `recipient_id` stays the same required text column, the object stays engine-owned and read-only through the data API, and `grant()` stays the only writer of a share row.
12+
13+
A patch: one display hint on one field of a shipped platform object. No export, type, accepted value, stored column or authorable key changes.

‎packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,23 @@ export const SysRecordShare = ObjectSchema.create({
215215
maxLength: 100,
216216
description: 'ID of the user/group/position that receives access',
217217
group: 'Recipient',
218+
// [#24701] Drawn as the recipient it names, not as the stored id: the
219+
// console's `recipient-picker` face (objectui#12613) reads the kind from
220+
// the row's `recipient_type` and names the id by that kind's directory
221+
// (a `user` id by an exact-id `sys_user` read). It is the declaration
222+
// `sys_sharing_rule.recipient_id` carries, less `object_name`: the face
223+
// reads that sibling only for the `field` recipient kind, which this
224+
// object's `recipient_type` does not offer, and `grant()` writes `user`
225+
// alone (ADR-0111 D7).
226+
//
227+
// A display hint only. The column stays this text column, and no form
228+
// ever draws the picker as an editor: the object is engine-owned and its
229+
// data API is read-only (`enable.apiMethods` below), so `grant()` stays
230+
// the one writer of a recipient. A list that does not fetch
231+
// `recipient_type` gives the face no kind, and the cell draws the stored
232+
// id as before (`sys-record-share.recipient-face.test.ts`).
233+
widget: 'recipient-picker',
234+
dependsOn: ['recipient_type'],
218235
}),
219236

220237
access_level: Field.select(
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { readFileSync, readdirSync } from 'node:fs';
5+
import { dirname, join, relative, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import ts from 'typescript';
8+
import { SysRecordShare } from './sys-record-share.object.js';
9+
import { SysSharingRule } from './sys-sharing-rule.object.js';
10+
11+
/**
12+
* [#24701] A record share's Recipient column names the recipient through the
13+
* console's `recipient-picker` face, by a display declaration alone.
14+
*
15+
* The face (objectui#12613, at this repository's `.objectui-sha`) reads the
16+
* row's `recipient_type` from the field's declared siblings, names a `user` id
17+
* by an exact-id `sys_user` read, and draws the stored text when the row
18+
* carries no kind. It reads `object_name` only for the `field` recipient kind.
19+
*
20+
* Pinned here:
21+
* 1. the declaration, read off the object, is the sibling object's widget;
22+
* 2. `dependsOn` names exactly the siblings the face reads for the kinds this
23+
* object's select can store;
24+
* 3. the declaration opens no write path: the column keeps its stored shape,
25+
* the data API stays read-only, so no form draws the picker as an editor;
26+
* 4. `grant()` stays the one insert of a share row in this package, so every
27+
* stored recipient is the `user` id it writes (ADR-0111 D7).
28+
*/
29+
30+
type FieldDef = Record<string, unknown>;
31+
32+
const shareFields = SysRecordShare.fields as Record<string, FieldDef>;
33+
const ruleFields = SysSharingRule.fields as Record<string, FieldDef>;
34+
35+
/** The stored values a select field offers. */
36+
function selectValues(field: FieldDef): string[] {
37+
const options = (field.options as unknown[] | undefined) ?? [];
38+
return options.map((o) => String(typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o));
39+
}
40+
41+
/**
42+
* The siblings the `recipient-picker` face reads for an object whose
43+
* `recipient_type` offers `kinds`: the kind itself always, and the shared
44+
* object's name only when the `field` kind can be stored.
45+
*/
46+
function siblingsTheFaceReads(kinds: readonly string[]): string[] {
47+
return ['recipient_type', ...(kinds.includes('field') ? ['object_name'] : [])];
48+
}
49+
50+
describe('sys_record_share.recipient_id — drawn through the recipient-picker face (#24701)', () => {
51+
it('declares the recipient-picker widget, the one sys_sharing_rule.recipient_id declares', () => {
52+
expect(shareFields.recipient_id.widget).toBe('recipient-picker');
53+
expect(shareFields.recipient_id.widget).toBe(ruleFields.recipient_id.widget);
54+
});
55+
56+
it('dependsOn names exactly the siblings the face reads for the kinds this object stores', () => {
57+
const kinds = selectValues(shareFields.recipient_type);
58+
expect(kinds).not.toContain('field');
59+
expect(shareFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds));
60+
for (const sibling of shareFields.recipient_id.dependsOn as string[]) {
61+
expect(shareFields[sibling], `dependsOn names '${sibling}', a field this object does not declare`).toBeDefined();
62+
}
63+
});
64+
65+
it('control: the same rule gives sys_sharing_rule its object_name sibling, because it stores the field kind', () => {
66+
const kinds = selectValues(ruleFields.recipient_type);
67+
expect(kinds).toContain('field');
68+
expect(ruleFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds));
69+
});
70+
71+
it('the declaration opens no write path: the stored column is unchanged and the data API stays read-only', () => {
72+
const recipient = shareFields.recipient_id;
73+
expect({
74+
type: recipient.type,
75+
required: recipient.required,
76+
maxLength: recipient.maxLength,
77+
reference: recipient.reference,
78+
referenceVia: recipient.referenceVia,
79+
}).toEqual({ type: 'text', required: true, maxLength: 100, reference: undefined, referenceVia: undefined });
80+
const object = SysRecordShare as unknown as {
81+
managedBy?: unknown;
82+
enable?: { apiMethods?: unknown };
83+
userActions?: unknown;
84+
};
85+
expect(object.managedBy).toBe('engine-owned');
86+
expect(object.enable?.apiMethods).toEqual(['get', 'list']);
87+
expect(object.userActions).toBeUndefined();
88+
});
89+
});
90+
91+
/**
92+
* Every `<receiver>.insert(<table>, …)` call in this package's non-test
93+
* sources, with the table it names and the function it sits in. A table given
94+
* as an identifier is resolved through a same-file string constant; anything
95+
* else is reported as unresolved, so the census cannot be passed by spelling a
96+
* table some other way. Scoped to this package's `src/` on purpose: the share
97+
* rows are this plugin's, and outside it the only measured writer (the CLI's
98+
* organization-ownership apply) updates `source_id` and inserts nothing.
99+
*/
100+
describe('sys_record_share — grant() stays the one insert of a share row (#24701)', () => {
101+
const HERE = dirname(fileURLToPath(import.meta.url));
102+
const SRC = resolve(HERE, '..');
103+
104+
type InsertSite = { file: string; fn: string; table: string };
105+
106+
function sourceFiles(dir: string): string[] {
107+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
108+
const path = join(dir, entry.name);
109+
if (entry.isDirectory()) return sourceFiles(path);
110+
return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [];
111+
});
112+
}
113+
114+
function enclosingFunction(node: ts.Node): string {
115+
for (let at: ts.Node | undefined = node.parent; at; at = at.parent) {
116+
if ((ts.isMethodDeclaration(at) || ts.isFunctionDeclaration(at)) && at.name) return at.name.getText();
117+
}
118+
return '(module)';
119+
}
120+
121+
function insertSites(): InsertSite[] {
122+
const sites: InsertSite[] = [];
123+
for (const path of sourceFiles(SRC)) {
124+
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
125+
const constants = new Map<string, string>();
126+
const calls: ts.CallExpression[] = [];
127+
const visit = (node: ts.Node): void => {
128+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer
129+
&& ts.isStringLiteralLike(node.initializer)) {
130+
constants.set(node.name.text, node.initializer.text);
131+
}
132+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
133+
&& node.expression.name.text === 'insert' && node.arguments.length > 0) {
134+
calls.push(node);
135+
}
136+
ts.forEachChild(node, visit);
137+
};
138+
visit(source);
139+
for (const call of calls) {
140+
const arg = call.arguments[0]!;
141+
const table = ts.isStringLiteralLike(arg)
142+
? arg.text
143+
: (ts.isIdentifier(arg) && constants.get(arg.text)) || `(unresolved: ${arg.getText(source)})`;
144+
sites.push({ file: relative(SRC, path), fn: enclosingFunction(call), table });
145+
}
146+
}
147+
return sites;
148+
}
149+
150+
const sites = insertSites();
151+
152+
it('control: the census reads this package\'s other inserts, so the case below cannot pass on an empty set', () => {
153+
expect(sites.map((s) => s.table)).toEqual(expect.arrayContaining(['sys_share_link', 'sys_sharing_rule']));
154+
});
155+
156+
it('every insert names its table in a form the census reads', () => {
157+
expect(sites.filter((s) => s.table.startsWith('(unresolved')).map((s) => `${s.file}#${s.fn}: ${s.table}`)).toEqual([]);
158+
});
159+
160+
it('the only insert of a sys_record_share row is grant() in sharing-service.ts', () => {
161+
expect(sites.filter((s) => s.table === 'sys_record_share').map((s) => `${s.file}#${s.fn}`))
162+
.toEqual(['sharing-service.ts#grant']);
163+
});
164+
});

0 commit comments

Comments
 (0)