Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/24611-share-audit-record-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-audit': patch
'@objectstack/plugin-sharing': patch
---

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

Clause-②: no

- **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.
- **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})`.
- **`sys_audit_log` drops its `titleFormat`.** It read `{action} · {object_name}`, but the required `action` pointer outranks it, so it never titled a row.
- **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.
- **`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.
- **`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.
- **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.

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.
28 changes: 21 additions & 7 deletions packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,24 @@ export const SysAuditLog = ObjectSchema.create({
description: 'Immutable audit trail for platform events',
displayNameField: 'action',
nameField: 'action', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: '{action} · {object_name}',
highlightFields: ['created_at', 'action', 'object_name', 'record_id', 'user_id'],
// [#24611] No `titleFormat`. It read `{action} · {object_name}`, but the
// declared `action` pointer is required and outranks the template on every
// title reader, so the template never titled a row; it only kept the target's
// API name in the object's title declaration.
//
// Neither half of the `record_id` / `object_name` pointer pair is listed. The
// record page's strip draws a listed field as its stored text, so the type
// half read as an API name and the id half as a raw id; a field the strip
// draws is also hidden from the Details tab, which is where `record_id`
// draws as the pointer card headed by the target object's label
// (`sys-audit-log-pointer-pair.test.ts`).
highlightFields: ['created_at', 'action', 'user_id'],

// A list names the target with the pointer's id half, `record_id`: the
// console draws it as the target record and fetches the type half
// `object_name` for that cell without a column (objectui#13036). A list
// view never shows the type half beside it, which would draw the stored API
// name as text (`sys-audit-log-pointer-pair.test.ts`). `config_changes`
// keeps `object_name`: its rows carry no `record_id`, so no pointer names
// their target.
// view never shows the type half, which would draw the stored API name as
// text (`sys-audit-log-pointer-pair.test.ts`).
listViews: {
recent: {
type: 'grid',
Expand Down Expand Up @@ -128,7 +136,13 @@ export const SysAuditLog = ObjectSchema.create({
name: 'config_changes',
label: 'Config',
data: { provider: 'object', object: 'sys_audit_log' },
columns: ['created_at', 'action', 'object_name', 'user_id'],
// [#24611] No Object column. Its rows carry no `record_id`, so no pointer
// names their target, and the column drew one of three stored API names
// (`sys_setting`, `sys_platform_setting` for a global-scope change,
// `sys_user` for an import). Which setting changed is in `metadata` and
// `new_value`, and the row's page still shows `object_name` and the
// `tenant_id` a global change leaves empty.
columns: ['created_at', 'action', 'user_id'],
// `export` removed (#8147) — retired from the enum, nothing ever wrote it.
// `import` KEPT deliberately: `plugin-auth`'s admin user-import writes a
// real run-level row (`admin-import-users.ts`, `action: 'import'` with
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

Expand Down Expand Up @@ -102,3 +103,94 @@ describe('audit list views — a pointer shows as its id half, never its type ha
]));
});
});

/**
* [#24611] The record page names a row's target by the Details tab's pointer
* card, and no audit surface draws a pair's type half as text.
*
* The console's default record page (objectui at this repository's
* `.objectui-sha`) draws the H1 from the object's title pointer, draws each
* `highlightFields` entry in the strip as its stored text, and hides every
* strip field and the H1 field from the Details tab. The Details tab is where
* a `referenceVia` id half draws as the pointer card, headed by the target
* object's label. So the type half must be neither the title nor a strip
* field, and the id half must not be a strip field either. The title pointer
* is read as the registry serves it (`provisionPrimary`, the designate-only
* pass `SchemaRegistry.registerObject` runs).
*
* The list half widens #24501's rule above: a type half is not a column even
* where its id half is absent. `config_changes` listed `object_name` alone,
* and its rows carry no `record_id`, so the column drew a stored API name with
* no pointer beside it.
*/
describe('audit record pages and lists — the type half never draws as text (#24611)', () => {
type TitledObject = {
name: string;
nameField?: string;
titleFormat?: string | { source?: string };
highlightFields?: readonly string[];
fields: Record<string, { referenceVia?: unknown } | undefined>;
listViews?: Record<string, {
columns?: ReadonlyArray<string | { field: string }>;
grouping?: { fields?: ReadonlyArray<{ field: string }> };
}>;
};

const objects = Object.values(AuditObjects) as unknown as TitledObject[];

/** Each declared pair as `[id half, type half]`. */
function pairs(object: TitledObject): Array<[string, string]> {
return Object.entries(object.fields).flatMap(([name, def]) =>
typeof def?.referenceVia === 'string' ? [[name, def.referenceVia] as [string, string]] : []);
}

const paired = objects.filter((o) => pairs(o).length > 0);

it('enumerates the pointer-pair objects, so no case below passes on an empty set', () => {
expect(paired.map((o) => o.name).sort()).toEqual(['sys_activity', 'sys_audit_log']);
});

it('the served title pointer is never a type half', () => {
const served = paired.map((o) => {
const designated = provisionPrimary(structuredClone(o) as never, { synthesize: false }) as { nameField?: string };
return { object: o.name, served: designated.nameField, typeHalf: pairs(o).some(([, via]) => via === designated.nameField) };
});
expect(served).toEqual(expect.arrayContaining([
{ object: 'sys_audit_log', served: 'action', typeHalf: false },
]));
expect(served.filter((s) => s.typeHalf)).toEqual([]);
});

it('no titleFormat interpolates a type half', () => {
const hits = paired.flatMap((o) => {
const raw = o.titleFormat;
const source = typeof raw === 'string' ? raw : raw?.source ?? '';
return pairs(o).filter(([, via]) => source.includes(`{${via}}`)).map(([, via]) => `${o.name}: ${via}`);
});
expect(hits).toEqual([]);
});

it('highlightFields lists neither half of a pair', () => {
const hits = paired.flatMap((o) =>
pairs(o).flatMap((pair) => pair.filter((f) => (o.highlightFields ?? []).includes(f)).map((f) => `${o.name}: ${f}`)));
expect(hits).toEqual([]);
});

it('no list view shows or groups on a type half, with or without its id half', () => {
const hits = paired.flatMap((o) => {
const typeHalves = new Set(pairs(o).map(([, via]) => via));
return Object.entries(o.listViews ?? {}).flatMap(([view, def]) => [
...(def.columns ?? []).map((c) => (typeof c === 'string' ? c : c.field)),
...(def.grouping?.fields ?? []).map((g) => g.field),
].filter((f) => typeHalves.has(f)).map((f) => `${o.name}.${view}: ${f}`));
});
expect(hits).toEqual([]);
});

it('control: config_changes is still declared with its filter, and the pair stays declared', () => {
const view = SysAuditLog.listViews?.config_changes as { filter?: unknown; columns?: unknown } | undefined;
expect(view?.filter).toEqual([{ field: 'action', operator: 'in', value: ['config_change', 'import'] }]);
expect(view?.columns).toEqual(['created_at', 'action', 'user_id']);
expect((SysAuditLog.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,26 @@ export const SysRecordShare = ObjectSchema.create({
// Each row widens one record beyond the object's sharing model (its OWD
// baseline) for one recipient; the sentence below is what Setup shows.
description: 'Records shared beyond their owner, one row per grant.',
titleFormat: '{object_name}/{record_id} → {recipient_id} ({access_level})',
highlightFields: ['object_name', 'record_id', 'recipient_id', 'access_level', 'source'],
// [#24611] The record title is the recipient, the principal the grant is
// for. Declared, because the registry's designate-only pass otherwise
// derives `object_name` (the first `*_name` text field), and a record share's
// page H1 read the shared record's object API name. The shared record itself
// is named on the record page by the Details tab's pointer card, which heads
// it with its object's label. No `titleFormat`: a declared, required pointer
// outranks it on every title reader, so it would only be dead text.
nameField: 'recipient_id',
// Neither half of the `record_id` / `object_name` pointer pair is listed. The
// record page's strip draws a listed field as its stored text, so the type
// half read as an API name and the id half as a raw id; a field the strip
// draws is also hidden from the Details tab, which is where `record_id`
// draws as the labelled pointer card (`sharing-pointer-pairs.test.ts`).
highlightFields: ['recipient_id', 'access_level', 'source'],

// A list names the shared record with the pointer's id half, `record_id`:
// the console draws it as the target record and fetches the type half
// `object_name` for that cell without a column (objectui#13036). No list
// view shows the type half, which would draw the stored API name as text
// (`sharing-pointer-pairs.test.ts`).
// view shows the type half or groups on it, which would draw the stored API
// name as text or as a group header (`sharing-pointer-pairs.test.ts`).
listViews: {
// [#21972] Declared FIRST on purpose: a route that names no view opens the
// first declared list view, so a caller-scoped view (`granted_to_me`,
Expand Down Expand Up @@ -95,8 +107,11 @@ export const SysRecordShare = ObjectSchema.create({
label: 'By Object',
data: { provider: 'object', object: 'sys_record_share' },
columns: ['record_id', 'recipient_id', 'access_level', 'source', 'created_at'],
// [#24611] Ordered by the shared record's object, not grouped on it: a
// group header draws `object_name` as its stored API name, and no column
// holds the object's label. The sort keeps one object's grants together,
// and each Record cell draws its target with that object's icon.
sort: [{ field: 'object_name', order: 'asc' }, { field: 'created_at', order: 'desc' }],
grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] },
pagination: { pageSize: 100 },
},
manual_grants: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,19 @@ export const SysShareLink = ObjectSchema.create({
isSystem: true,
managedBy: 'engine-owned',
description: 'Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.',
titleFormat: '{object_name}/{record_id} ({permission})',
highlightFields: ['object_name', 'record_id', 'permission', 'audience', 'expires_at', 'revoked_at'],
// [#24611] The record title is the link's own label, the free text its
// creator gave it. Declared rather than left to the registry's designation,
// which derives the same field today. A link created without a label is
// titled by `titleFormat`: what the link grants and to whom, each value drawn
// as its option label on the record page. Neither title names the shared
// record's object; the Details tab's pointer card names it by its label.
nameField: 'label',
titleFormat: '{permission} · {audience}',
// Neither half of the pointer pair is listed, as on `sys_record_share`: the
// strip would draw the type half as an API name and the id half as a raw id,
// and would hide `record_id` from the Details tab, where it draws as the
// labelled pointer card (`sharing-pointer-pairs.test.ts`).
highlightFields: ['permission', 'audience', 'expires_at', 'revoked_at'],

// Lists name the target as `sys_record_share`'s do: the id half alone, the
// type half fetched for its cell without a column (`sharing-pointer-pairs.test.ts`).
Expand Down
92 changes: 92 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-pointer-pairs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { provisionPrimary } from '@objectstack/spec/data';
import { SysRecordShare } from './objects/sys-record-share.object.js';
import { SysShareLink } from './objects/sys-share-link.object.js';
import * as SharingObjects from './objects/index.js';
Expand Down Expand Up @@ -128,3 +129,94 @@ describe('sharing list views — a pointer shows as its id half, never its type
]));
});
});

/**
* [#24611] The record page names the shared record by the Details tab's
* pointer card, never by the pair's type half.
*
* The console's default record page (objectui at this repository's
* `.objectui-sha`) draws the H1 from the object's title pointer, draws each
* `highlightFields` entry in the strip as its stored text, and hides every
* strip field and the H1 field from the Details tab. The Details tab is where
* a `referenceVia` id half draws as the pointer card, headed by the target
* object's label. So the type half must be neither the title nor a strip
* field, and the id half must not be a strip field either.
*
* The title pointer is read as the registry serves it: `provisionPrimary` is
* the designate-only pass `SchemaRegistry.registerObject` runs. With no
* declared pointer it derived `object_name` for `sys_record_share`, and the
* page H1 read the shared record's object API name.
*/
describe('sharing record pages — the pointer card names the target, never the type half (#24611)', () => {
type TitledObject = {
name: string;
nameField?: string;
titleFormat?: string | { source?: string };
highlightFields?: readonly string[];
fields: Record<string, { referenceVia?: unknown } | undefined>;
listViews?: Record<string, {
sort?: ReadonlyArray<{ field: string }>;
grouping?: { fields?: ReadonlyArray<{ field: string }> };
}>;
};

const objects = Object.values(SharingObjects) as unknown as TitledObject[];

/** Each declared pair as `[id half, type half]`. */
function pairs(object: TitledObject): Array<[string, string]> {
return Object.entries(object.fields).flatMap(([name, def]) =>
typeof def?.referenceVia === 'string' ? [[name, def.referenceVia] as [string, string]] : []);
}

const paired = objects.filter((o) => pairs(o).length > 0);

it('enumerates both pointer-pair objects, so no case below passes on an empty set', () => {
expect(paired.map((o) => o.name).sort()).toEqual(['sys_record_share', 'sys_share_link']);
});

it('the served title pointer is declared and is never a type half', () => {
const served = paired.map((o) => {
const designated = provisionPrimary(structuredClone(o) as never, { synthesize: false }) as { nameField?: string };
return {
object: o.name,
declared: o.nameField,
served: designated.nameField,
typeHalf: pairs(o).some(([, via]) => via === designated.nameField),
};
});
expect(served).toEqual([
{ object: 'sys_record_share', declared: 'recipient_id', served: 'recipient_id', typeHalf: false },
{ object: 'sys_share_link', declared: 'label', served: 'label', typeHalf: false },
]);
});

it('no titleFormat interpolates a type half', () => {
const hits = paired.flatMap((o) => {
const raw = o.titleFormat;
const source = typeof raw === 'string' ? raw : raw?.source ?? '';
return pairs(o).filter(([, via]) => source.includes(`{${via}}`)).map(([, via]) => `${o.name}: ${via}`);
});
expect(hits).toEqual([]);
});

it('highlightFields lists neither half of a pair', () => {
const hits = paired.flatMap((o) =>
pairs(o).flatMap((pair) => pair.filter((f) => (o.highlightFields ?? []).includes(f)).map((f) => `${o.name}: ${f}`)));
expect(hits).toEqual([]);
});

it('no list view groups on a type half', () => {
const hits = paired.flatMap((o) => {
const typeHalves = new Set(pairs(o).map(([, via]) => via));
return Object.entries(o.listViews ?? {}).flatMap(([view, def]) =>
(def.grouping?.fields ?? []).filter((g) => typeHalves.has(g.field)).map((g) => `${o.name}.${view}: ${g.field}`));
});
expect(hits).toEqual([]);
});

it('control: by_object still orders the grants by their object, and the pair stays declared', () => {
expect(SysRecordShare.listViews?.by_object?.sort?.[0]).toEqual({ field: 'object_name', order: 'asc' });
expect((SysRecordShare.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
expect((SysShareLink.fields as Record<string, Record<string, unknown>>).record_id.referenceVia).toBe('object_name');
});
});
Loading