From 94af6a395432574eba9ccece2713ea2d7e889ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:56:34 +0000 Subject: [PATCH 1/4] feat(platform-objects)!: the four Setup identity objects declare enable.feeds: false sys_organization, sys_team, sys_business_unit and sys_user took the enable.feeds default (true), so the console drew a Discussion feed with a comment composer on every identity record in Setup. Declaring the flag false hides the feed and makes plugin-audit's existing enable.feeds gate refuse a comment create on these objects with 403 FEEDS_DISABLED; rows stored before the flag stay readable and body-editable. The pins boot a real ObjectQL with the real identity definitions and the real installAuditWriters hooks (plugin-audit), one refusal case per object, the grandfathered-row case on sys_user, and a business-object CONTROL. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .changeset/23838-identity-feeds-off.md | 23 ++ .../src/identity/sys-business-unit.object.ts | 3 + .../src/identity/sys-organization.object.ts | 3 + .../src/identity/sys-team.object.ts | 3 + .../src/identity/sys-user.object.ts | 3 + .../src/identity-objects-feeds-off.test.ts | 238 ++++++++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 .changeset/23838-identity-feeds-off.md create mode 100644 packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts diff --git a/.changeset/23838-identity-feeds-off.md b/.changeset/23838-identity-feeds-off.md new file mode 100644 index 00000000000..7a2a56c47d8 --- /dev/null +++ b/.changeset/23838-identity-feeds-off.md @@ -0,0 +1,23 @@ +--- +'@objectstack/platform-objects': minor +--- + +feat(platform-objects)!: Setup's organization, team, business-unit and user records carry no Discussion feed: `sys_organization`, `sys_team`, `sys_business_unit` and `sys_user` declare `enable.feeds: false`, so a comment create on one of their records answers `403 FEEDS_DISABLED` + +Clause-②: no (narrowing: the comment create door refuses on four platform objects that accept it today) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Setup's identity records are directory entries, not discussion surfaces. The four objects declared no `enable.feeds`, so they took the default (`true`), and the console drew "Discussion (0)" with a comment composer on every organization, team, business unit and user record. + +What changes: + +- **The record page.** The console reads the flag (`enable.feeds !== false`), so it no longer draws the Discussion feed or its composer on these four objects' records. The record's activity history is unchanged. +- **The data API.** A `sys_comment` create whose `thread_id` names one of these objects (`sys_user:USER_ID`, `sys_organization:ORG_ID`, `sys_team:TEAM_ID` or `sys_business_unit:UNIT_ID`) answers `403 FEEDS_DISABLED` and writes nothing, through REST, the SDK and every other caller of the data engine. An update that moves an existing comment into one of these threads is refused the same way. +- **Comments already stored** on these records are kept. They stay readable through the data API, and an edit that does not change `thread_id`, such as a body edit, still saves. + +Unchanged: every other platform object and every application object keeps its own `enable.feeds` setting, and an object that declares none still accepts comments. + +Remedy: stop creating comments on these four objects' records. A discussion about a user, a team or an organization belongs on a record of an object that keeps its feed on. diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 03f1ce69b29..0db7f124c9d 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -267,6 +267,9 @@ export const SysBusinessUnit = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // No `apiMethods` — default-open (#3543 audit). The HRIS org-tree sync // scenario (#3025) needs every primitive, and a whitelist naming all six is // equivalent to no whitelist while NOT tracking future primitives; the P1 diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 3baca62dc3a..cfcbe12495a 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -376,6 +376,9 @@ export const SysOrganization = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // #1591 closed the generic writes on this table — "refused by the identity // write guard (ADR-0092 D2) and owned by better-auth; HTTP answers 405 // before the 403" — and that reasoning still holds for better-auth's own diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 9a3f52ffb7d..3a095490964 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -194,6 +194,9 @@ export const SysTeam = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // Generic writes are refused by the plugin-auth identity write guard // (ADR-0092 D2) and better-auth owns create/update/delete via the // team actions above — so the generic data API exposes reads only. diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 03b402b256e..df41d5b3c3f 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -1044,6 +1044,9 @@ export const SysUser = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, + // Setup's identity records are not discussion surfaces (objectui#12614 item 1): + // no Discussion feed, and a comment create on one answers 403 FEEDS_DISABLED. + feeds: false, // #1591 — create/delete are refused by the identity write guard // (ADR-0092 D2) and owned by better-auth (Invite / Create User / admin // actions), so they are not exposed. `update` stays: it is the ONE diff --git a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts new file mode 100644 index 00000000000..b61ce2ac6a4 --- /dev/null +++ b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup's four identity objects — `sys_organization`, `sys_team`, + * `sys_business_unit` and `sys_user` — declare `enable.feeds: false`, so a + * comment cannot be created on their records (objectui#12614 item 1: the + * record page drew a Discussion feed with a composer on each of them). + * + * The gate itself is generic and pinned elsewhere: `audit-writers.test.ts` + * (#2707, insert) and `capability-gate-update-verb.test.ts` (#10170, the + * update verb and the grandfathered body edit). What this file pins is the + * COMPOSITION — the REAL platform-object definitions, imported rather than + * restated, registered in a REAL `ObjectQL` with the real `installAuditWriters` + * hooks — so a `feeds: false` dropped from any one of the four reds its own + * case below. `@objectstack/platform-objects/identity` resolves to source + * (this package's vitest alias), so the verdict is about the checkout, not + * about `platform-objects/dist`. + * + * Every refusal asserts the ADR-0112 envelope (`code` AND `status`) plus the + * object the gate named, and the CONTROL proves the harness does not refuse + * everything: a business object that never mentions the flag still takes a + * comment. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data'; +import { + SysBusinessUnit, + SysOrganization, + SysTeam, + SysUser, +} from '@objectstack/platform-objects/identity'; +import { installAuditWriters } from './audit-writers.js'; + +type Row = Record; + +const text = (name: string, primaryKey = false) => ({ + name, + label: name, + type: 'text' as const, + ...(primaryKey ? { primaryKey: true } : {}), +}); + +const fieldMap = (...names: string[]) => + Object.fromEntries(names.map((n) => [n, text(n, n === 'id')])); + +/** + * A stub driver with just enough storage for the write paths under test. Its + * matcher implements the operators these reads send and REFUSES any other, so + * it can never answer a query it does not understand silently wrong + * (`check:where-matcher-conformance`); `find` applies the caller's `limit` by + * presence, after the filter (`check:objectql-double-limit`). + */ +function makeStubDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { + s = new Map(); + stores.set(o, s); + } + return s; + }; + let nextId = 0; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Record)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((sub) => matches(row, sub))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((sub) => matches(row, sub))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`stub driver: unsupported where operator '${k}'`); + if (v && typeof v === 'object') { + const ops = Object.keys(v); + if (ops.length === 1 && ops[0] === '$eq') { + if ((row[k] ?? null) !== ((v as { $eq: unknown }).$eq ?? null)) return false; + continue; + } + if (ops.length === 1 && ops[0] === '$in' && Array.isArray((v as { $in: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes(row[k])) return false; + continue; + } + throw new Error(`stub driver: unsupported where predicate on '${k}': ${JSON.stringify(v)}`); + } + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { + return true; + }, + async execute() { + return null; + }, + async syncSchema() {}, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Row) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Row) { + const s = storeFor(o); + const cur = s.get(id); + if (!cur) return null; + const u = { ...cur, ...data, id }; + s.set(id, u); + return u; + }, + async delete() { + return true; + }, + async count(o: string, ast: any) { + return (await driver.find(o, { where: ast?.where })).length; + }, + async aggregate() { + return []; + }, + }; + return { driver, storeFor }; +} + +/** The four Setup identity objects, as `platform-objects` declares them. */ +const IDENTITY_OBJECTS = [SysOrganization, SysTeam, SysBusinessUnit, SysUser] as const; + +/** + * Boot a real engine carrying the four REAL identity definitions, a business + * object that never mentions `enable.feeds`, `sys_comment`, and the audit + * sinks (registered so the `afterInsert` writers have somewhere real to land — + * their failures are swallowed by design, and a swallowed failure would make + * this harness lie about which write happened). + */ +async function boot() { + const engine = new ObjectQL(); + const { driver, storeFor } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + + const reg = engine.registry; + for (const def of IDENTITY_OBJECTS) reg.registerObject(def as any, 'test.fixture'); + reg.registerObject({ + name: 'crm_account', + label: 'Account', + fields: fieldMap('id', 'name'), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_comment', + label: 'Comment', + fields: fieldMap('id', 'thread_id', 'body'), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_audit_log', + label: 'Audit Log', + fields: fieldMap( + 'id', 'action', 'user_id', 'actor', 'object_name', 'record_id', 'old_value', 'new_value', 'tenant_id', + ), + } as any, 'test.fixture'); + reg.registerObject({ + name: 'sys_activity', + label: 'Activity', + fields: fieldMap( + 'id', 'type', 'timestamp', 'summary', 'actor_id', 'object_name', 'record_id', 'record_label', 'metadata', + ), + } as any, 'test.fixture'); + + installAuditWriters(engine as any, 'test.audit'); + return { engine, driver, storeFor }; +} + +describe('Setup identity objects declare enable.feeds: false — a comment create on their records is refused', () => { + it.each(IDENTITY_OBJECTS.map((def) => [def.name] as const))( + '%s: a new comment on one of its records → 403 FEEDS_DISABLED, and no row lands', + async (objectName) => { + const { engine, storeFor } = await boot(); + + await expect( + engine.insert('sys_comment', { thread_id: `${objectName}:rec-1`, body: 'hello' } as any), + ).rejects.toMatchObject({ code: 'FEEDS_DISABLED', status: 403, object: objectName }); + + expect(storeFor('sys_comment').size).toBe(0); + }, + ); + + it('a comment row stored before the flag stays readable, and an ordinary body edit on it still saves', async () => { + // The grandfather rule in `audit-writers.ts` (`enforceFeedsCapability`): + // on update, an ABSENT `thread_id` is not a re-thread, so the narrowing + // reaches new comments and re-points only — never a row that already + // targets the object. The row is written straight through the driver, + // below every hook, which is what a row stored before this change is. + const { engine, driver } = await boot(); + const stored = await driver.create('sys_comment', { thread_id: 'sys_user:usr-1', body: 'before the flag' }); + const id = String(stored.id); + + const read: any[] = await engine.find('sys_comment', { + where: { thread_id: 'sys_user:usr-1' }, + } satisfies EngineQueryOptions); + expect(read.map((r) => [r.id, r.body])).toEqual([[id, 'before the flag']]); + + await expect( + engine.update('sys_comment', { body: 'edited after the flag' } as any, { + where: { id }, + } satisfies EngineUpdateOptions), + ).resolves.toBeTruthy(); + + const after: any = await engine.findOne('sys_comment', { where: { id } } satisfies EngineQueryOptions); + expect(after).toMatchObject({ id, thread_id: 'sys_user:usr-1', body: 'edited after the flag' }); + }); + + it('CONTROL: a business object that never mentions enable.feeds still accepts a comment', async () => { + const { engine, storeFor } = await boot(); + + await expect( + engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello' } as any), + ).resolves.toBeTruthy(); + + expect(Array.from(storeFor('sys_comment').values()).map((r) => r.thread_id)).toEqual(['crm_account:acc-1']); + }); +}); From 11d48eeda19835426f5f810f06d51c59b943f02f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:01:34 +0000 Subject: [PATCH 2/4] test(plugin-audit): the grandfathered-row pin first proves the gate is live on that thread Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../plugin-audit/src/identity-objects-feeds-off.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts index b61ce2ac6a4..4ac58e197c5 100644 --- a/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts +++ b/packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts @@ -211,6 +211,12 @@ describe('Setup identity objects declare enable.feeds: false — a comment creat const stored = await driver.create('sys_comment', { thread_id: 'sys_user:usr-1', body: 'before the flag' }); const id = String(stored.id); + // The gate is live on this very thread — so what follows is a grandfathered + // row surviving the flag, not a harness in which the flag never applied. + await expect( + engine.insert('sys_comment', { thread_id: 'sys_user:usr-1', body: 'after the flag' } as any), + ).rejects.toMatchObject({ code: 'FEEDS_DISABLED', status: 403, object: 'sys_user' }); + const read: any[] = await engine.find('sys_comment', { where: { thread_id: 'sys_user:usr-1' }, } satisfies EngineQueryOptions); From 941eb8a2efd5c3a008a4180885f4c2bba5896cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:22:23 +0000 Subject: [PATCH 3/4] fix(plugin-audit): sys_comment's thread_id example names a business-object thread The thread_id description and the docblock illustrated the thread format with `sys_user:abc123`. sys_user now declares enable.feeds: false, so a comment created by following that example answers 403 FEEDS_DISABLED. The example becomes `account:9001`, already the docblock's second one. The zh-CN, ja-JP and es-ES leaves swap only the example; the en bundle is regenerated from the source in the next commit. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../plugins/plugin-audit/src/objects/sys-comment.object.ts | 4 ++-- .../plugin-audit/src/translations/es-ES.objects.generated.ts | 2 +- .../plugin-audit/src/translations/ja-JP.objects.generated.ts | 2 +- .../plugin-audit/src/translations/zh-CN.objects.generated.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts index 0f1303748e6..0bd44fb5cfd 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts @@ -7,7 +7,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Generic threaded discussion attached to any record via `thread_id`. * `thread_id` is conventionally formatted as `{object}:{record_id}` - * (e.g. `sys_user:abc123`, `account:9001`) so a single index covers + * (e.g. `account:9001`) so a single index covers * "all comments on this record" lookups across every object. * * Distinct from `feed_item` (Salesforce-style Chatter timeline that @@ -126,7 +126,7 @@ export const SysComment = ObjectSchema.create({ searchable: true, maxLength: 255, description: - 'Thread identifier — conventionally `{object}:{record_id}` (e.g. `sys_user:abc123`)', + 'Thread identifier — conventionally `{object}:{record_id}` (e.g. `account:9001`)', group: 'Thread', }), diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 8823ec5ac22..5e35559b5f1 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -202,7 +202,7 @@ export const esESObjects: NonNullable = { }, thread_id: { label: "Hilo", - help: "Identificador del hilo; por convención `{object}:{record_id}` (p. ej. `sys_user:abc123`)." + help: "Identificador del hilo; por convención `{object}:{record_id}` (p. ej. `account:9001`)." }, parent_id: { label: "Comentario principal", diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index b109f732998..59ede934ec5 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -202,7 +202,7 @@ export const jaJPObjects: NonNullable = { }, thread_id: { label: "スレッド", - help: "スレッド識別子 — 通常は `{object}:{record_id}`(例: `sys_user:abc123`)" + help: "スレッド識別子 — 通常は `{object}:{record_id}`(例: `account:9001`)" }, parent_id: { label: "親コメント", diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 7bbdef13154..1df7465a740 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -202,7 +202,7 @@ export const zhCNObjects: NonNullable = { }, thread_id: { label: "线程", - help: "线程标识——约定格式为 `{object}:{record_id}`(例如 `sys_user:abc123`)" + help: "线程标识——约定格式为 `{object}:{record_id}`(例如 `account:9001`)" }, parent_id: { label: "父评论", From 01f606f4b8101cd53d2e93c95d8de97cd549167e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:24:42 +0000 Subject: [PATCH 4/4] chore(plugin-audit): regenerate the en bundle for the thread_id example Written by `node scripts/check-i18n-bundles.mjs --write`; the only leaf that moved is sys_comment.fields.thread_id.help. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../plugin-audit/src/translations/en.objects.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 794caf03079..80b2c0d4498 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -202,7 +202,7 @@ export const enObjects: NonNullable = { }, thread_id: { label: "Thread", - help: "Thread identifier — conventionally `{object}:{record_id}` (e.g. `sys_user:abc123`)" + help: "Thread identifier — conventionally `{object}:{record_id}` (e.g. `account:9001`)" }, parent_id: { label: "Parent Comment",