Skip to content

Commit 54159fd

Browse files
fix(plugin-audit,plugin-sharing): record pages, by_object and the Config list stop naming the target object by its API name (#24698)
Fixes #24611 Clause-②: no The record pages of `sys_record_share`, `sys_share_link` and `sys_audit_log`, the `by_object` grouping of record shares and the `config_changes` audit list stop naming the target object by its API name (`object_name`, such as `showcase_announcement`). On the record page the target is now named by the Details tab's pointer card, headed by the target object's label (the route PR #24500 took for approval requests). This is the record-page and grouping half of the family whose list-column half was PR #24600. Responsibility: `plugin-sharing` and `plugin-audit`'s own declarations (`nameField`, `titleFormat`, `highlightFields`, two list views) | the platform path that names the target by its label is the console's pointer card for the declared `referenceVia` pair, which the default record page draws in its Details tab | reached by every admin who opens a record share, a share link or an audit row in Setup, live today. Readings are against objectstack `92ce3af40` (this branch's base; `origin/main` `486745ec7` is merged in at `5fd7d943f` and touches neither package) and objectui at the console pin `.objectui-sha` = `12dc7b47c3`, by `git show` and `git grep`. objectui `fe11eb12e5` (the type-half fetch PR #24600 waited on) is an ancestor of that pin: `git merge-base --is-ancestor` exit 0. The pages were not driven in a browser; the last section says why. ## What the measurement changed about the card The card read the titles off `titleFormat`. Measured, `titleFormat` titles none of these rows on the record page: - **The served title pointer.** A real `SchemaRegistry` (built at `92ce3af40`, `registerObject` then `getObject`, with `multiTenant` true and false) serves `nameField: 'object_name'` for `sys_record_share`, `nameField: 'label'` for `sys_share_link`, and the declared `nameField: 'action'` for `sys_audit_log`. The registry's designate-only pass (`provisionPrimary(…, { synthesize: false })`) derives a pointer when none is declared, and `object_name` is the first `*_name` text field on a record share. This matches the six non-id designations #20044 listed. - **The console's H1 ranks that pointer first.** `page:header` (`containers.tsx`) draws the declared pointer's value, then the `titleFormat` render (with select values as their option labels), then the derivation. So a record share's H1 was the bare `object_name` value, and its `titleFormat` was never reached. A share link's H1 was its `label`, or `{object_name}/{record_id} ({permission})` when the label is empty. An audit row's H1 is its `action`. - **No `titleFormat` token renders a pointer pair as a label.** `formatTitleTemplate` renders `{record_id}` and `{object_name}` as their stored text. - **The strip and the Details tab split the fields.** The default record page (`RecordDetailView` → `buildDefaultPageSchema`) draws `highlightFields` minus the title field in the strip. `HeaderHighlight` draws each as its cell type, so `record_id` (a `text` field) reads as a raw id: the strip has no pointer face. `record:details` draws every visible field minus the strip fields and the H1 field. Only there does `record_id` draw as the pointer card (`DetailSection` → `resolveRecordPointer` → `RecordPreviewCard`, headed by `useObjectLabel`). Before this PR, `record_id` was a strip field on all three objects, so the card never drew. ## What changes | Object | H1 | Strip | Details tab | |---|---|---|---| | `sys_record_share`, before | `object_name` (stamped) | Record (raw id), Recipient, Access Level, Source | no target row | | `sys_record_share`, after | `recipient_id` (declared) | Access Level, Source | Record card (target object's label, record title); Object row | | `sys_share_link`, before | `label`, else `{object_name}/{record_id} ({permission})` | Object (API name), Record (raw id), Permission, Audience, Expires At, Revoked At | no target row | | `sys_share_link`, after | `label`, else `{permission} · {audience}` | Permission, Audience, Expires At, Revoked At | Record card; Object row | | `sys_audit_log`, before | `action` | Timestamp, Object (API name), Record ID (raw id), User | no target row | | `sys_audit_log`, after | `action` | Timestamp, User | Record ID card; Object row | - `sys_record_share` declares `nameField: 'recipient_id'`, the principal the grant is for, and drops its `titleFormat`, which the declared required pointer always outranks. - `sys_share_link` declares `nameField: 'label'`, the field the registry already derived, and its `titleFormat` becomes `'{permission} · {audience}'`, for example "View · Anyone with the link". - `sys_audit_log` drops `titleFormat: '{action} · {object_name}'`. The required `action` pointer outranks it on every title reader, so it never titled a row. - All three `highlightFields` lose `object_name` and `record_id`. - `sys_record_share.by_object` keeps its `object_name` sort and loses its `grouping`. - `sys_audit_log.config_changes` loses its `object_name` column. - `object_name` stays a declared field, and the pair, its indexes and every query on it are untouched. **Why `recipient_id` for a record share's title.** Only a declared pointer moves the H1, because the registry's derivation would otherwise land on `object_name` again. The candidates on the object are its text fields. `record_id` as the title would hide the record card: the Details tab drops the H1 field. `id` is the raw-id title #20044 removed elsewhere. A computed `display_title` formula, the shape #20059 used, adds a field, which is outside this card's surface, and would show the same stored id plus the access level, which the strip already shows. `recipient_id` is required, so the title is never blank. It is the principal's stored id, the value the strip's Recipient chip showed before this PR. ## `by_object` and `config_changes`: the four axes **`by_object`.** (i) Group on a column the pair serves with a label: none exists. The object has no field holding a label, and a group header draws a `text` value as stored (PR #24600's reading). (ii) Drop the grouping and keep the sort: chosen. (iii) Keep the grouping. - *Real business need.* The view answers "grants per object". The sort keeps one object's grants together, and each Record cell draws its target with that object's icon (`RecordPointerCell`, readable state). What the change gives up is the collapsible header and its count. No nav entry names the view (`nav_record_shares` opens `all_shares`). - *Long-term soundness.* No column and no mapping. The view stops modelling "the type half as visible text", which is the rule PR #24600 set for list columns. Header grouping can return if a label-bearing grouping key is ever served. - *Making AI mistakes harder.* The pin now refuses a grouping on a `referenceVia` type half in either package. - *Startup focus.* One key removed. Keeping it leaves the family's defect on the view the card names. **`config_changes`.** (i) A label-served column: none. These rows carry `record_id: null`, so no pointer names their target. (ii) Drop the Object column: chosen. (iii) Keep it. - *Real business need.* The column drew one of three stored constants. Its writers are `service-settings`' `config-change-audit.ts` (`sys_setting`, or `sys_platform_setting` for a global-scope change) and `plugin-auth`'s admin import (`sys_user`). It never said which setting changed: that is in `metadata` and `new_value` (namespace, key, scope). What it gives up is the global-versus-organization hint in the list. The row's page still shows it in `object_name` and in the empty `tenant_id` a global change leaves. - *Long-term soundness.* Naming the object by label needs a stored label column or a console-side mapping, and the card rules out both. Dropping it follows #24375's rule: name the object by its label, or do not name it at all. - *Making AI mistakes harder.* The audit pin now refuses a type-half column with or without its id half, so this view is no longer an exception to the list rule. - *Startup focus.* One column removed. No dogfood or unit test reads this view's columns: `settings-config-change-audit.dogfood.test.ts` reads its filter only. ## Pins Both pointer-pair test files gain a block enumerated over every object their package ships (`objects/index.ts`). Each block first asserts which objects carry a pair, so it cannot pass on an empty set. - **The served title pointer is never a type half.** It is read through `provisionPrimary`, the pass the registry runs. Sharing also asserts it is declared: `recipient_id` and `label`. - **No `titleFormat` interpolates a type half.** - **`highlightFields` lists neither half of a pair.** - **No list view groups on a type half.** In audit, no list view shows a type half either, with or without its id half. - **Controls:** `by_object` still sorts on `object_name`, `config_changes` keeps its filter, and both `record_id` fields still declare `referenceVia: 'object_name'`. **Ablation.** Each run was done once at `acb5b7eb9`, through `node scripts/ablation-replace.mjs` in wrap mode, under `scripts/pm/os-verify-lock.sh`. The tests import the objects by relative source path, and `@objectstack/spec` was built, so no rebuild sits between a mutation and its run. Each mutation landed: the anchor went from 1 hit to 0 and the file's blob changed. Each restore put the blob back equal to `HEAD`, left `git diff HEAD` empty, and the tree ended with an empty `git status`. | Mutation | File | Red | |---|---|---| | delete `nameField: 'recipient_id',` | `sys-record-share.object.ts` | `the served title pointer is declared and is never a type half`: received `served: "object_name", typeHalf: true` · `Tests 1 failed / 15 passed` | | put a `grouping` on `object_name` back into `by_object` | `sys-record-share.object.ts` | `no list view groups on a type half` · `1 failed / 15 passed` | | put `record_id` back into `highlightFields` | `sys-audit-log.object.ts` | `highlightFields lists neither half of a pair`: `[ 'sys_audit_log: record_id' ]` · `1 failed / 11 passed` | | put `object_name` back into `config_changes` columns | `sys-audit-log.object.ts` | `no list view shows or groups on a type half…` and the control · `2 failed / 10 passed` | The first attempt at the grouping mutation was refused by the tool and ran nothing: its replacement contained its anchor, so the anchor count did not move. It was re-run with a disjoint anchor (the second row). ## Verification Under `scripts/pm/os-verify-lock.sh`, each with `VERDICT command-exit 0`: - The build of both packages and their dependency closure, at `acb5b7eb9` and again at `5fd7d943f`, exited 0. - At `5fd7d943f`: `pnpm --filter @objectstack/plugin-sharing --filter @objectstack/plugin-audit test` gave `plugin-sharing` 49 files and 1169 tests passed, and `plugin-audit` 57 files and 937 tests passed. - At `5fd7d943f`: the `typecheck` of both packages exited 0, with `check:test-typecheck: OK` for both. No debt-ledger entry moved: sharing's ledger still holds 2 files and 3 errors, audit's holds none. - The two edited test files alone: sharing `Tests 16 passed (16)` (10 before), audit `Tests 12 passed (12)` (6 before). **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5fd7d943f` derived 67 commands for this diff. All 67 ran locally at `5fd7d943f`, each exit code captured before any pipe: **67 exited 0.** On the first pass two refused with exit 3, PREREQUISITE NOT MET, because there was no full build yet: `pnpm check:i18n` and `pnpm check:dual-build-cjs-loads`. They re-ran with exit 0 after `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2` (`76 successful, 76 total`, under the lock). Some verdict lines: - `pnpm check:i18n`: `check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys)`. - `pnpm check:nul-bytes`: `OK (scanned 12431 text file(s) … no raw ASCII control bytes)`. - `node scripts/check-platform-object-tenancy-census.mjs`: `✓ platform-object tenancy census matches the tree: 85 platform-namespace objects`. - `pnpm check:test-source-alias`: `OK — 76 packages with tests scanned`. - `pnpm check:dual-build-cjs-loads`: `✓ … 111 published require entry point(s) across 69 package(s) load`. `node scripts/pm/dispatch-gates.mjs --ran` reported `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. Declared to CI: the repo-wide scans (`pnpm lint` and the rest of the battery), the vitest `repo` project, the type-check lanes and Build Core. Their PR check-runs are the reading. `check:dev-prereqs` and `check:skill-frame-freshness` were not run: the diff touches neither `packages/spec` nor the four-axis frame files. `scripts/check-platform-checklist.mjs` was not run: no exported symbol is renamed or removed. ## Not measured: a browser reading There is no console build in this container (`packages/console/dist` is absent), so a screen reading needs objectui's console built at the pin plus a booted backend. The static chain above stands in for it. ## Acceptance notes - **The Details tab still draws `object_name` as a row of its stored text**, beside the pointer card that names the same object by its label. The default record page draws every visible field minus the strip and the H1 field. So within this card's surface, `object_name` is drawn somewhere on all three pages: as the H1, as a strip chip, or as a Details row. This PR picks the Details row. The positions that would remove it are outside this card's surface: `hidden: true` on the type half (a field-level declaration, `Field.hidden`, "Hidden from default UI"), or a console rule that folds a `referenceVia` type half into its card. Reported to the seat. - **An audit row's H1 reads its `action` as stored** (`config_change`, not "Config Change"). The console's declared-pointer rung reads the record as served, while its `titleFormat` rung maps select values to option labels. This is unchanged by this PR. A record share's H1 is likewise the recipient's stored id. Reported to the seat. - **Other `by_object` views group on an object API name** outside any pointer pair: `sys_sharing_rule.by_object` and `sys_webhook.by_object` (`object_name` is the rule's or hook's own target-object field, with no `record_id` beside it). They are outside this card and its pin. - #20044, the ADR-0079 designation card, listed `sys_record_share → object_name` and `sys_share_link → label` among the non-id designations it noted as possibly intended. This PR declares a pointer on both, so neither title is derived any more. --- _Generated by [Claude Code](https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 758c77d commit 54159fd

6 files changed

Lines changed: 256 additions & 14 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
fix(plugin-audit, plugin-sharing): record pages, the `by_object` grouping and the Config list no longer name the target object by its API name
7+
8+
Clause-②: no
9+
10+
- **A record share's title is its recipient.** `sys_record_share` declares `nameField: 'recipient_id'`. It declared no title pointer, so the registry derived `object_name` (the first `*_name` text field), and a record share's page heading read the shared record's object API name, such as `showcase_announcement`. Its `titleFormat` is removed: the declared pointer outranks it everywhere.
11+
- **A share link's title is its label, else what it grants.** `sys_share_link` declares `nameField: 'label'`, the field the registry already derived. A link with no label is titled by `titleFormat: '{permission} · {audience}'` (for example "View · Anyone with the link") instead of `{object_name}/{record_id} ({permission})`.
12+
- **`sys_audit_log` drops its `titleFormat`.** It read `{action} · {object_name}`, but the required `action` pointer outranks it, so it never titled a row.
13+
- **The strip lists neither half of the pointer pair.** `highlightFields` no longer lists `object_name` or `record_id` on the three objects. The record page's strip drew them as stored text, an API name and a raw id. A strip field is also hidden from the Details tab, which is where `record_id` draws as the record card, headed by the target object's label.
14+
- **`sys_record_share.by_object` sorts by object instead of grouping on it.** A group header drew `object_name` as stored text. The sort still keeps one object's grants together.
15+
- **`sys_audit_log.config_changes` drops its Object column.** Its rows carry no `record_id`, so the column drew a stored API name (`sys_setting`, `sys_platform_setting` or `sys_user`) with no record beside it.
16+
- **Unchanged:** `object_name` stays a declared field on all three objects, and the pointer pair, its indexes and every query on it are untouched. On the record page, the Details tab still shows `object_name` as a row of its stored text.
17+
18+
A patch: title and highlight declarations and list view keys change on shipped platform objects. No export, type, accepted value, stored column or authorable key changes.

‎packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,24 @@ export const SysAuditLog = ObjectSchema.create({
4141
description: 'Immutable audit trail for platform events',
4242
displayNameField: 'action',
4343
nameField: 'action', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
44-
titleFormat: '{action} · {object_name}',
45-
highlightFields: ['created_at', 'action', 'object_name', 'record_id', 'user_id'],
44+
// [#24611] No `titleFormat`. It read `{action} · {object_name}`, but the
45+
// declared `action` pointer is required and outranks the template on every
46+
// title reader, so the template never titled a row; it only kept the target's
47+
// API name in the object's title declaration.
48+
//
49+
// Neither half of the `record_id` / `object_name` pointer pair is listed. The
50+
// record page's strip draws a listed field as its stored text, so the type
51+
// half read as an API name and the id half as a raw id; a field the strip
52+
// draws is also hidden from the Details tab, which is where `record_id`
53+
// draws as the pointer card headed by the target object's label
54+
// (`sys-audit-log-pointer-pair.test.ts`).
55+
highlightFields: ['created_at', 'action', 'user_id'],
4656

4757
// A list names the target with the pointer's id half, `record_id`: the
4858
// console draws it as the target record and fetches the type half
4959
// `object_name` for that cell without a column (objectui#13036). A list
50-
// view never shows the type half beside it, which would draw the stored API
51-
// name as text (`sys-audit-log-pointer-pair.test.ts`). `config_changes`
52-
// keeps `object_name`: its rows carry no `record_id`, so no pointer names
53-
// their target.
60+
// view never shows the type half, which would draw the stored API name as
61+
// text (`sys-audit-log-pointer-pair.test.ts`).
5462
listViews: {
5563
recent: {
5664
type: 'grid',
@@ -128,7 +136,13 @@ export const SysAuditLog = ObjectSchema.create({
128136
name: 'config_changes',
129137
label: 'Config',
130138
data: { provider: 'object', object: 'sys_audit_log' },
131-
columns: ['created_at', 'action', 'object_name', 'user_id'],
139+
// [#24611] No Object column. Its rows carry no `record_id`, so no pointer
140+
// names their target, and the column drew one of three stored API names
141+
// (`sys_setting`, `sys_platform_setting` for a global-scope change,
142+
// `sys_user` for an import). Which setting changed is in `metadata` and
143+
// `new_value`, and the row's page still shows `object_name` and the
144+
// `tenant_id` a global change leaves empty.
145+
columns: ['created_at', 'action', 'user_id'],
132146
// `export` removed (#8147) — retired from the enum, nothing ever wrote it.
133147
// `import` KEPT deliberately: `plugin-auth`'s admin user-import writes a
134148
// real run-level row (`admin-import-users.ts`, `action: 'import'` with

‎packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { provisionPrimary } from '@objectstack/spec/data';
45
import { SysAuditLog } from './objects/sys-audit-log.object.js';
56
import * as AuditObjects from './objects/index.js';
67

@@ -102,3 +103,94 @@ describe('audit list views — a pointer shows as its id half, never its type ha
102103
]));
103104
});
104105
});
106+
107+
/**
108+
* [#24611] The record page names a row's target by the Details tab's pointer
109+
* card, and no audit surface draws a pair's type half as text.
110+
*
111+
* The console's default record page (objectui at this repository's
112+
* `.objectui-sha`) draws the H1 from the object's title pointer, draws each
113+
* `highlightFields` entry in the strip as its stored text, and hides every
114+
* strip field and the H1 field from the Details tab. The Details tab is where
115+
* a `referenceVia` id half draws as the pointer card, headed by the target
116+
* object's label. So the type half must be neither the title nor a strip
117+
* field, and the id half must not be a strip field either. The title pointer
118+
* is read as the registry serves it (`provisionPrimary`, the designate-only
119+
* pass `SchemaRegistry.registerObject` runs).
120+
*
121+
* The list half widens #24501's rule above: a type half is not a column even
122+
* where its id half is absent. `config_changes` listed `object_name` alone,
123+
* and its rows carry no `record_id`, so the column drew a stored API name with
124+
* no pointer beside it.
125+
*/
126+
describe('audit record pages and lists — the type half never draws as text (#24611)', () => {
127+
type TitledObject = {
128+
name: string;
129+
nameField?: string;
130+
titleFormat?: string | { source?: string };
131+
highlightFields?: readonly string[];
132+
fields: Record<string, { referenceVia?: unknown } | undefined>;
133+
listViews?: Record<string, {
134+
columns?: ReadonlyArray<string | { field: string }>;
135+
grouping?: { fields?: ReadonlyArray<{ field: string }> };
136+
}>;
137+
};
138+
139+
const objects = Object.values(AuditObjects) as unknown as TitledObject[];
140+
141+
/** Each declared pair as `[id half, type half]`. */
142+
function pairs(object: TitledObject): Array<[string, string]> {
143+
return Object.entries(object.fields).flatMap(([name, def]) =>
144+
typeof def?.referenceVia === 'string' ? [[name, def.referenceVia] as [string, string]] : []);
145+
}
146+
147+
const paired = objects.filter((o) => pairs(o).length > 0);
148+
149+
it('enumerates the pointer-pair objects, so no case below passes on an empty set', () => {
150+
expect(paired.map((o) => o.name).sort()).toEqual(['sys_activity', 'sys_audit_log']);
151+
});
152+
153+
it('the served title pointer is never a type half', () => {
154+
const served = paired.map((o) => {
155+
const designated = provisionPrimary(structuredClone(o) as never, { synthesize: false }) as { nameField?: string };
156+
return { object: o.name, served: designated.nameField, typeHalf: pairs(o).some(([, via]) => via === designated.nameField) };
157+
});
158+
expect(served).toEqual(expect.arrayContaining([
159+
{ object: 'sys_audit_log', served: 'action', typeHalf: false },
160+
]));
161+
expect(served.filter((s) => s.typeHalf)).toEqual([]);
162+
});
163+
164+
it('no titleFormat interpolates a type half', () => {
165+
const hits = paired.flatMap((o) => {
166+
const raw = o.titleFormat;
167+
const source = typeof raw === 'string' ? raw : raw?.source ?? '';
168+
return pairs(o).filter(([, via]) => source.includes(`{${via}}`)).map(([, via]) => `${o.name}: ${via}`);
169+
});
170+
expect(hits).toEqual([]);
171+
});
172+
173+
it('highlightFields lists neither half of a pair', () => {
174+
const hits = paired.flatMap((o) =>
175+
pairs(o).flatMap((pair) => pair.filter((f) => (o.highlightFields ?? []).includes(f)).map((f) => `${o.name}: ${f}`)));
176+
expect(hits).toEqual([]);
177+
});
178+
179+
it('no list view shows or groups on a type half, with or without its id half', () => {
180+
const hits = paired.flatMap((o) => {
181+
const typeHalves = new Set(pairs(o).map(([, via]) => via));
182+
return Object.entries(o.listViews ?? {}).flatMap(([view, def]) => [
183+
...(def.columns ?? []).map((c) => (typeof c === 'string' ? c : c.field)),
184+
...(def.grouping?.fields ?? []).map((g) => g.field),
185+
].filter((f) => typeHalves.has(f)).map((f) => `${o.name}.${view}: ${f}`));
186+
});
187+
expect(hits).toEqual([]);
188+
});
189+
190+
it('control: config_changes is still declared with its filter, and the pair stays declared', () => {
191+
const view = SysAuditLog.listViews?.config_changes as { filter?: unknown; columns?: unknown } | undefined;
192+
expect(view?.filter).toEqual([{ field: 'action', operator: 'in', value: ['config_change', 'import'] }]);
193+
expect(view?.columns).toEqual(['created_at', 'action', 'user_id']);
194+
expect((SysAuditLog.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
195+
});
196+
});

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,26 @@ export const SysRecordShare = ObjectSchema.create({
4242
// Each row widens one record beyond the object's sharing model (its OWD
4343
// baseline) for one recipient; the sentence below is what Setup shows.
4444
description: 'Records shared beyond their owner, one row per grant.',
45-
titleFormat: '{object_name}/{record_id} → {recipient_id} ({access_level})',
46-
highlightFields: ['object_name', 'record_id', 'recipient_id', 'access_level', 'source'],
45+
// [#24611] The record title is the recipient, the principal the grant is
46+
// for. Declared, because the registry's designate-only pass otherwise
47+
// derives `object_name` (the first `*_name` text field), and a record share's
48+
// page H1 read the shared record's object API name. The shared record itself
49+
// is named on the record page by the Details tab's pointer card, which heads
50+
// it with its object's label. No `titleFormat`: a declared, required pointer
51+
// outranks it on every title reader, so it would only be dead text.
52+
nameField: 'recipient_id',
53+
// Neither half of the `record_id` / `object_name` pointer pair is listed. The
54+
// record page's strip draws a listed field as its stored text, so the type
55+
// half read as an API name and the id half as a raw id; a field the strip
56+
// draws is also hidden from the Details tab, which is where `record_id`
57+
// draws as the labelled pointer card (`sharing-pointer-pairs.test.ts`).
58+
highlightFields: ['recipient_id', 'access_level', 'source'],
4759

4860
// A list names the shared record with the pointer's id half, `record_id`:
4961
// the console draws it as the target record and fetches the type half
5062
// `object_name` for that cell without a column (objectui#13036). No list
51-
// view shows the type half, which would draw the stored API name as text
52-
// (`sharing-pointer-pairs.test.ts`).
63+
// view shows the type half or groups on it, which would draw the stored API
64+
// name as text or as a group header (`sharing-pointer-pairs.test.ts`).
5365
listViews: {
5466
// [#21972] Declared FIRST on purpose: a route that names no view opens the
5567
// first declared list view, so a caller-scoped view (`granted_to_me`,
@@ -95,8 +107,11 @@ export const SysRecordShare = ObjectSchema.create({
95107
label: 'By Object',
96108
data: { provider: 'object', object: 'sys_record_share' },
97109
columns: ['record_id', 'recipient_id', 'access_level', 'source', 'created_at'],
110+
// [#24611] Ordered by the shared record's object, not grouped on it: a
111+
// group header draws `object_name` as its stored API name, and no column
112+
// holds the object's label. The sort keeps one object's grants together,
113+
// and each Record cell draws its target with that object's icon.
98114
sort: [{ field: 'object_name', order: 'asc' }, { field: 'created_at', order: 'desc' }],
99-
grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] },
100115
pagination: { pageSize: 100 },
101116
},
102117
manual_grants: {

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,19 @@ export const SysShareLink = ObjectSchema.create({
4848
isSystem: true,
4949
managedBy: 'engine-owned',
5050
description: 'Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.',
51-
titleFormat: '{object_name}/{record_id} ({permission})',
52-
highlightFields: ['object_name', 'record_id', 'permission', 'audience', 'expires_at', 'revoked_at'],
51+
// [#24611] The record title is the link's own label, the free text its
52+
// creator gave it. Declared rather than left to the registry's designation,
53+
// which derives the same field today. A link created without a label is
54+
// titled by `titleFormat`: what the link grants and to whom, each value drawn
55+
// as its option label on the record page. Neither title names the shared
56+
// record's object; the Details tab's pointer card names it by its label.
57+
nameField: 'label',
58+
titleFormat: '{permission} · {audience}',
59+
// Neither half of the pointer pair is listed, as on `sys_record_share`: the
60+
// strip would draw the type half as an API name and the id half as a raw id,
61+
// and would hide `record_id` from the Details tab, where it draws as the
62+
// labelled pointer card (`sharing-pointer-pairs.test.ts`).
63+
highlightFields: ['permission', 'audience', 'expires_at', 'revoked_at'],
5364

5465
// Lists name the target as `sys_record_share`'s do: the id half alone, the
5566
// type half fetched for its cell without a column (`sharing-pointer-pairs.test.ts`).

‎packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { provisionPrimary } from '@objectstack/spec/data';
45
import { SysRecordShare } from './objects/sys-record-share.object.js';
56
import { SysShareLink } from './objects/sys-share-link.object.js';
67
import * as SharingObjects from './objects/index.js';
@@ -128,3 +129,94 @@ describe('sharing list views — a pointer shows as its id half, never its type
128129
]));
129130
});
130131
});
132+
133+
/**
134+
* [#24611] The record page names the shared record by the Details tab's
135+
* pointer card, never by the pair's type half.
136+
*
137+
* The console's default record page (objectui at this repository's
138+
* `.objectui-sha`) draws the H1 from the object's title pointer, draws each
139+
* `highlightFields` entry in the strip as its stored text, and hides every
140+
* strip field and the H1 field from the Details tab. The Details tab is where
141+
* a `referenceVia` id half draws as the pointer card, headed by the target
142+
* object's label. So the type half must be neither the title nor a strip
143+
* field, and the id half must not be a strip field either.
144+
*
145+
* The title pointer is read as the registry serves it: `provisionPrimary` is
146+
* the designate-only pass `SchemaRegistry.registerObject` runs. With no
147+
* declared pointer it derived `object_name` for `sys_record_share`, and the
148+
* page H1 read the shared record's object API name.
149+
*/
150+
describe('sharing record pages — the pointer card names the target, never the type half (#24611)', () => {
151+
type TitledObject = {
152+
name: string;
153+
nameField?: string;
154+
titleFormat?: string | { source?: string };
155+
highlightFields?: readonly string[];
156+
fields: Record<string, { referenceVia?: unknown } | undefined>;
157+
listViews?: Record<string, {
158+
sort?: ReadonlyArray<{ field: string }>;
159+
grouping?: { fields?: ReadonlyArray<{ field: string }> };
160+
}>;
161+
};
162+
163+
const objects = Object.values(SharingObjects) as unknown as TitledObject[];
164+
165+
/** Each declared pair as `[id half, type half]`. */
166+
function pairs(object: TitledObject): Array<[string, string]> {
167+
return Object.entries(object.fields).flatMap(([name, def]) =>
168+
typeof def?.referenceVia === 'string' ? [[name, def.referenceVia] as [string, string]] : []);
169+
}
170+
171+
const paired = objects.filter((o) => pairs(o).length > 0);
172+
173+
it('enumerates both pointer-pair objects, so no case below passes on an empty set', () => {
174+
expect(paired.map((o) => o.name).sort()).toEqual(['sys_record_share', 'sys_share_link']);
175+
});
176+
177+
it('the served title pointer is declared and is never a type half', () => {
178+
const served = paired.map((o) => {
179+
const designated = provisionPrimary(structuredClone(o) as never, { synthesize: false }) as { nameField?: string };
180+
return {
181+
object: o.name,
182+
declared: o.nameField,
183+
served: designated.nameField,
184+
typeHalf: pairs(o).some(([, via]) => via === designated.nameField),
185+
};
186+
});
187+
expect(served).toEqual([
188+
{ object: 'sys_record_share', declared: 'recipient_id', served: 'recipient_id', typeHalf: false },
189+
{ object: 'sys_share_link', declared: 'label', served: 'label', typeHalf: false },
190+
]);
191+
});
192+
193+
it('no titleFormat interpolates a type half', () => {
194+
const hits = paired.flatMap((o) => {
195+
const raw = o.titleFormat;
196+
const source = typeof raw === 'string' ? raw : raw?.source ?? '';
197+
return pairs(o).filter(([, via]) => source.includes(`{${via}}`)).map(([, via]) => `${o.name}: ${via}`);
198+
});
199+
expect(hits).toEqual([]);
200+
});
201+
202+
it('highlightFields lists neither half of a pair', () => {
203+
const hits = paired.flatMap((o) =>
204+
pairs(o).flatMap((pair) => pair.filter((f) => (o.highlightFields ?? []).includes(f)).map((f) => `${o.name}: ${f}`)));
205+
expect(hits).toEqual([]);
206+
});
207+
208+
it('no list view groups on a type half', () => {
209+
const hits = paired.flatMap((o) => {
210+
const typeHalves = new Set(pairs(o).map(([, via]) => via));
211+
return Object.entries(o.listViews ?? {}).flatMap(([view, def]) =>
212+
(def.grouping?.fields ?? []).filter((g) => typeHalves.has(g.field)).map((g) => `${o.name}.${view}: ${g.field}`));
213+
});
214+
expect(hits).toEqual([]);
215+
});
216+
217+
it('control: by_object still orders the grants by their object, and the pair stays declared', () => {
218+
expect(SysRecordShare.listViews?.by_object?.sort?.[0]).toEqual({ field: 'object_name', order: 'asc' });
219+
expect((SysRecordShare.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
220+
expect((SysShareLink.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
221+
});
222+
});

0 commit comments

Comments
 (0)