From 0723723c5f1cdb77c855aab517421a9cbfae1b2d Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 11:32:55 +0800 Subject: [PATCH] feat(data): enable.feeds/activities become real opt-out gates; trackHistory contract defined (#2707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposition per flag (enforce-or-remove; removal rejected — trackHistory is live in the console, spec removal = platform major, and the flags match the industry-standard object-capability model): - enable.activities: opt-OUT writer gate. Spec default false→true; plugin-audit skips ONLY the sys_activity timeline mirror on explicit `activities:false` (audit row untouched). Behavior-preserving; gives ADR-0057 its per-object activity-growth lever. - enable.feeds: opt-OUT + server-side enforcement. Spec default false→true; explicit `feeds:false` rejects sys_comment creation at the engine hook seam, 403 FEEDS_DISABLED (fail-closed, CLONE_DISABLED pattern) + a mapDataError branch so generic data routes surface the 403 (they bypass sendError's .status passthrough — caught in the runtime smoke test). - enable.trackHistory: ledger misclassified it dead — objectui has gated the record History tab on it since 2026-05 (a live consumer under the ledger's own rules). Reclassified live; the old authorWarn hint ("use Field.trackHistory instead") would have lost users the History tab and is replaced by the two-grain contract (object flag = History-tab master switch; Field.trackHistory = diff selector; audit capture stays unconditional as a compliance ledger). - enable.files: stays dead + authorWarn — reserved for the generic Attachments related-list, tracked in #2727; describe() now says so instead of advertising a capability that doesn't exist. Default flips are required for opt-out semantics: default(false) materializes `false` for every enable-block object, making explicit opt-out indistinguishable from the default. Verified: unit (plugin-audit gates, mapDataError, lint contract tests, spec defaults), liveness gate green, app-todo compile now warns only on enable.files, and a live showcase run — default object: activity mirrored + comment 201; probe object with explicit false flags: 0 activity rows, 1 audit row, comment → 403 FEEDS_DISABLED. Companion objectui PR gates the record feed/timeline UI on the same flags. Closes #2707. Co-Authored-By: Claude Fable 5 --- .changeset/enable-capability-gates-2707.md | 39 ++++++++ content/docs/references/data/object.mdx | 8 +- packages/cli/src/commands/compile.ts | 2 +- .../utils/lint-liveness-properties.test.ts | 29 ++++-- .../plugin-audit/src/audit-writers.test.ts | 91 +++++++++++++++++++ .../plugins/plugin-audit/src/audit-writers.ts | 43 ++++++++- packages/rest/src/rest-server.ts | 16 ++++ packages/rest/src/rest.test.ts | 20 ++++ packages/spec/liveness/README.md | 4 +- packages/spec/liveness/object.json | 22 ++--- packages/spec/src/data/object.test.ts | 6 +- packages/spec/src/data/object.zod.ts | 78 +++++++++++----- 12 files changed, 303 insertions(+), 55 deletions(-) create mode 100644 .changeset/enable-capability-gates-2707.md diff --git a/.changeset/enable-capability-gates-2707.md b/.changeset/enable-capability-gates-2707.md new file mode 100644 index 0000000000..8a82c18f35 --- /dev/null +++ b/.changeset/enable-capability-gates-2707.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-audit': minor +'@objectstack/rest': patch +'@objectstack/cli': patch +--- + +feat(data): make object `enable.feeds`/`enable.activities` real opt-out gates; define the `enable.trackHistory` contract (#2707) + +`ObjectSchema.enable.{files,trackHistory,activities,feeds}` were parsed but +(mostly) unconsumed — an author setting them got nothing, silently. Per the +enforce-or-remove doctrine, each flag now has a defined enforcement contract: + +- `enable.activities` — opt-OUT writer gate. Spec default flips + `false → true`; plugin-audit keeps mirroring CRUD into the `sys_activity` + timeline unless the object declares an explicit `activities: false` + (behavior-preserving for every existing stack; the off-switch is the + per-object lever for activity-row growth, ADR-0057). The compliance + `sys_audit_log` row is NOT gated. +- `enable.feeds` — opt-OUT with server-side enforcement. Spec default flips + `false → true`; an explicit `feeds: false` now rejects `sys_comment` + creation targeting that object at the engine hook seam + (403 `FEEDS_DISABLED`, fail-closed like `CLONE_DISABLED`). +- `enable.trackHistory` — was misclassified `dead` in the liveness ledger: + the console has gated the record History tab on it since 2026-05. + Reclassified live with the two-grain contract documented (object flag = + History-tab master switch; per-field `trackHistory` = diff selector; audit + *capture* stays unconditional as a compliance ledger). +- `enable.files` — stays dead + authorWarn (reserved for the future generic + Attachments panel; use `Field.file`/`Field.image` meanwhile). Its + `describe()` now says so instead of advertising a capability that + doesn't exist. + +The default flips can't be avoided: with `default(false)`, compiled output +materializes `false` for every object with an `enable` block, making +"author explicitly opted out" indistinguishable from "schema default" — so +opt-out semantics require the default to be `true` (same posture as +`trash`/`mru`/`clone`). Liveness ledger + reference docs regenerated; +compile-time authorWarn now fires only for `enable.files`. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 6dc1cd6d87..c62e9b8045 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -77,13 +77,13 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trackHistory** | `boolean` | ✅ | Enable field history tracking for audit compliance | +| **trackHistory** | `boolean` | ✅ | Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance | | **searchable** | `boolean` | ✅ | Index records for global search | | **apiEnabled** | `boolean` | ✅ | Expose object via automatic APIs | | **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Whitelist of allowed API operations | -| **files** | `boolean` | ✅ | Enable file attachments and document management | -| **feeds** | `boolean` | ✅ | Enable social feed, comments, and mentions (Chatter-like) | -| **activities** | `boolean` | ✅ | Enable standard tasks and events tracking | +| **files** | `boolean` | ✅ | RESERVED (no runtime effect yet) — use Field.file/Field.image for attachments; this flag will gate the future generic Attachments panel | +| **feeds** | `boolean` | ✅ | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object | +| **activities** | `boolean` | ✅ | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline | | **trash** | `boolean` | ✅ | Enable soft-delete with restore capability | | **mru** | `boolean` | ✅ | Track Most Recently Used (MRU) list for users | | **clone** | `boolean` | ✅ | Allow record deep cloning | diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 2008472cad..38a663eaad 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -250,7 +250,7 @@ export default class Compile extends Command { // 3d-bis. Liveness author-warning lint — close the spec-liveness loop on // the author side: an authored property the ledger marks dead-and- - // misleading (e.g. `object.enable.feeds`, `field.columnName`) or + // misleading (e.g. `object.enable.files`, `field.columnName`) or // experimental is set hopefully but does nothing / isn't enforced at // runtime. Advisory only; ledger-driven (entries opt in via // `authorWarn`), so it's high-signal and NEVER fails the build. diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 1bcab90638..495ca66fa1 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -9,7 +9,7 @@ import { /** * These run against the REAL ledgers shipped by `@objectstack/spec` (the same * files the gate enforces), so they double as a contract test: if an - * `authorWarn` annotation is removed from `enable.feeds` / `columnName` / etc., + * `authorWarn` annotation is removed from `enable.files` / `columnName` / etc., * the matching assertion fails. */ @@ -18,14 +18,14 @@ const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule); const paths = (findings: { message: string }[]) => findings.map((f) => f.message); describe('lintLivenessProperties', () => { - it('warns on an authored dead capability flag (enable.feeds: true)', () => { - const findings = lintLivenessProperties(objStack({ enable: { feeds: true } })); + it('warns on an authored dead capability flag (enable.files: true)', () => { + const findings = lintLivenessProperties(objStack({ enable: { files: true } })); expect(findings.length).toBeGreaterThan(0); - const feeds = findings.find((f) => f.message.includes('enable.feeds')); - expect(feeds).toBeDefined(); - expect(feeds!.rule).toBe(LIVENESS_DEAD_PROPERTY); - expect(feeds!.where).toBe("object 'widget'"); - expect(feeds!.hint.length).toBeGreaterThan(0); + const files = findings.find((f) => f.message.includes('enable.files')); + expect(files).toBeDefined(); + expect(files!.rule).toBe(LIVENESS_DEAD_PROPERTY); + expect(files!.where).toBe("object 'widget'"); + expect(files!.hint.length).toBeGreaterThan(0); }); it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => { @@ -33,6 +33,17 @@ describe('lintLivenessProperties', () => { expect(paths(findings).some((m) => m.includes('enable.trash'))).toBe(false); }); + // #2707: feeds/activities/trackHistory went LIVE (opt-out writer/UI gates + + // History-tab master switch) — authoring them must no longer warn. + it('does NOT warn on the now-live capability flags (feeds/activities/trackHistory)', () => { + const findings = lintLivenessProperties( + objStack({ enable: { feeds: true, activities: true, trackHistory: true } }), + ); + expect(paths(findings).some((m) => m.includes('enable.feeds'))).toBe(false); + expect(paths(findings).some((m) => m.includes('enable.activities'))).toBe(false); + expect(paths(findings).some((m) => m.includes('enable.trackHistory'))).toBe(false); + }); + it('does NOT warn when a dead boolean flag is explicitly false (enable.files: false)', () => { const findings = lintLivenessProperties(objStack({ enable: { files: false } })); expect(paths(findings).some((m) => m.includes('enable.files'))).toBe(false); @@ -72,7 +83,7 @@ describe('lintLivenessProperties', () => { it('handles objects as a keyed record (not just arrays)', () => { const findings = lintLivenessProperties({ - objects: { widget: { name: 'widget', enable: { feeds: true } } }, + objects: { widget: { name: 'widget', enable: { files: true } } }, }); expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 9a75071035..898c0fe877 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -294,3 +294,94 @@ describe('audit writers — declarative milestones (ADR-0052 §5b.2)', () => { expect(activity?.row.summary).toBe('Stage: proposal → Negotiation'); }); }); + +describe('audit writers — enable.activities opt-out gate (#2707)', () => { + const SCHEMA = { + sys_audit_log: SINGLE_TENANT.sys_audit_log, + sys_activity: SINGLE_TENANT.sys_activity, + crm_lead: ['id', 'name'], + }; + + it('mirrors CRUD into sys_activity by default (absent enable block)', async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-1' }, + result: { id: 'lead-1', name: 'Acme' }, + session: {}, + }); + + expect(created.some((c) => c.object === 'sys_audit_log')).toBe(true); + expect(created.some((c) => c.object === 'sys_activity')).toBe(true); + }); + + it('skips ONLY the sys_activity mirror on explicit activities:false — audit row still written', async () => { + const { engine, fire, created } = makeEngine(SCHEMA, { + crm_lead: { enable: { activities: false } }, + }); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-1' }, + result: { id: 'lead-1', name: 'Acme' }, + session: {}, + }); + + // Compliance ledger is NOT gated by the capability flag… + expect(created.some((c) => c.object === 'sys_audit_log')).toBe(true); + // …but the timeline mirror is. + expect(created.some((c) => c.object === 'sys_activity')).toBe(false); + }); +}); + +describe('audit writers — enable.feeds server-side enforcement (#2707)', () => { + const SCHEMA = { + sys_audit_log: SINGLE_TENANT.sys_audit_log, + sys_activity: SINGLE_TENANT.sys_activity, + crm_lead: ['id', 'name'], + }; + + const commentInsert = (threadId?: unknown) => ({ + object: 'sys_comment', + input: { data: { thread_id: threadId, body: 'hello' } }, + session: {}, + }); + + it('rejects sys_comment creation targeting an object with explicit feeds:false (403 FEEDS_DISABLED)', async () => { + const { engine, fire } = makeEngine(SCHEMA, { + crm_lead: { enable: { feeds: false } }, + }); + installAuditWriters(engine as any, 'test.audit'); + + await expect(fire('beforeInsert', commentInsert('crm_lead:rec-1'))).rejects.toMatchObject({ + code: 'FEEDS_DISABLED', + status: 403, + object: 'crm_lead', + }); + }); + + it('allows comments when feeds is absent (opt-out default) or explicitly true', async () => { + const absent = makeEngine(SCHEMA); + installAuditWriters(absent.engine as any, 'test.audit'); + await expect(absent.fire('beforeInsert', commentInsert('crm_lead:rec-1'))).resolves.toBeUndefined(); + + const explicit = makeEngine(SCHEMA, { crm_lead: { enable: { feeds: true } } }); + installAuditWriters(explicit.engine as any, 'test.audit'); + await expect(explicit.fire('beforeInsert', commentInsert('crm_lead:rec-1'))).resolves.toBeUndefined(); + }); + + it('lets unconventional/missing thread_id through (capability gate, not access control)', async () => { + const { engine, fire } = makeEngine(SCHEMA, { + crm_lead: { enable: { feeds: false } }, + }); + installAuditWriters(engine as any, 'test.audit'); + + await expect(fire('beforeInsert', commentInsert(undefined))).resolves.toBeUndefined(); + await expect(fire('beforeInsert', commentInsert('free-form-thread'))).resolves.toBeUndefined(); + // Unknown target object → no def → allowed. + await expect(fire('beforeInsert', commentInsert('ghost_object:rec-9'))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 646bf860b0..d7158fc284 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -488,10 +488,17 @@ export function installAuditWriters( activityRow.organization_id = tenantId ?? null; } + // `enable.activities` is an opt-OUT capability (#2707): absent block/flag + // = mirror on (spec default `true`), explicit `false` = this object's CRUD + // is not mirrored into the sys_activity timeline. This is the per-object + // lever for activity-row growth (ADR-0057). The compliance audit row is + // NOT gated — sys_audit_log capture stays unconditional. + const activitiesEnabled = getObjectDef(ctx.object)?.enable?.activities !== false; + try { const sys = api.sudo(); await sys.object('sys_audit_log').create(auditRow); - await sys.object('sys_activity').create(activityRow); + if (activitiesEnabled) await sys.object('sys_activity').create(activityRow); // M10.8 / ADR-0030: notify the assignee. Best-effort; never throws into // the user-facing CRUD path. Goes through the messaging single ingress // (`emit`) — the inbox channel materializes the bell row — rather than @@ -521,6 +528,40 @@ export function installAuditWriters( engine.registerHook('afterUpdate', writeAudit, { packageId }); engine.registerHook('afterDelete', writeAudit, { packageId }); + /** + * `enable.feeds` server-side enforcement (#2707). Comments are created + * through the generic data path (`dataSource.create('sys_comment', …)`), + * so the engine hook seam is the one gate every caller crosses — REST, + * SDK, and feed-service alike. Opt-OUT semantics matching `enable.clone` + * (cloneData in metadata-protocol): absent block/flag = allowed, only an + * explicit `feeds: false` on the *target* object rejects. The thrown + * error carries `.status`, which the REST layer's `sendError` forwards + * verbatim → 403 FEEDS_DISABLED, fail-closed like CLONE_DISABLED. + * + * The target object is resolved from `thread_id` (conventionally + * `{object}:{record_id}` — see sys-comment.object.ts). A missing or + * unconventional thread_id is allowed through: this is capability + * gating, not access control, and free-form threads have no object to + * gate on. + */ + const enforceFeedsCapability = async (ctx: HookContext) => { + const data: any = (ctx.input as any)?.data; + const threadId = data?.thread_id; + if (typeof threadId !== 'string') return; + const sep = threadId.indexOf(':'); + if (sep <= 0) return; + const targetObject = threadId.slice(0, sep); + const def = getObjectDef(targetObject); + if (def?.enable?.feeds === false) { + const err: any = new Error(`Comments are disabled for object '${targetObject}' (enable.feeds: false)`); + err.code = 'FEEDS_DISABLED'; + err.status = 403; + err.object = targetObject; + throw err; + } + }; + engine.registerHook('beforeInsert', enforceFeedsCapability, { object: 'sys_comment', packageId }); + /** * M10.8: Dedicated hook on `sys_comment` afterInsert that parses the * `mentions` JSON field and writes one sys_notification per mentioned diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9603a06a66..eef869f5fc 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -104,6 +104,22 @@ export function mapDataError(error: any, object?: string): { status: number; bod }, }; } + // Capability gate (#2707): the target object opted out of comments + // (`enable.feeds: false`) — plugin-audit's engine hook rejects the + // sys_comment insert fail-closed. 403 like CLONE_DISABLED; surfaced by + // `code` because the generic data routes map through here (they never + // reach sendError's `.status` passthrough). `error.object` names the + // gated TARGET object (not sys_comment), so prefer it. + if (error?.code === 'FEEDS_DISABLED') { + return { + status: 403, + body: { + error: error?.message ?? 'Comments are disabled for this object', + code: 'FEEDS_DISABLED', + ...(error?.object || object ? { object: error?.object ?? object } : {}), + }, + }; + } // Short-circuit: explicit security denial → 403. Match by `code` / // `name` to avoid pulling a runtime dependency on plugin-security. if ( diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 798f7e9bab..689e09e95a 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1927,6 +1927,26 @@ describe('mapDataError — schema/constraint envelopes', () => { const sqliteError = (message: string, code = 'SQLITE_ERROR') => Object.assign(new Error(message), { code }); + // #2707: plugin-audit's engine hook rejects sys_comment creation when the + // TARGET object declares `enable.feeds: false`. The generic data routes map + // errors through mapDataError (not sendError's `.status` passthrough), so + // the code needs its own branch — without it the 403 degraded to the + // catch-all 400 (caught live in the runtime smoke test). + it('maps FEEDS_DISABLED → 403 with the gated target object', () => { + const r = mapDataError( + Object.assign(new Error("Comments are disabled for object 'gate_probe' (enable.feeds: false)"), { + code: 'FEEDS_DISABLED', + status: 403, + object: 'gate_probe', + }), + 'sys_comment', + ); + expect(r.status).toBe(403); + expect(r.body.code).toBe('FEEDS_DISABLED'); + // Prefers the gated target object over the route's object (sys_comment). + expect(r.body.object).toBe('gate_probe'); + }); + it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => { const r = mapDataError( sqliteError( diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 0d648fa1ee..fae1c14423 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -105,7 +105,7 @@ Signal over noise is the whole point, so warnings are **opt-in per entry**: Two rules keep it false-positive-free, **both of which the marker author must respect**: 1. **Only mark genuinely *misleading* dead props** — ones that imply a capability/behavior - that doesn't exist (`enable.feeds`, `field.columnName`, `versioning`). Benign display/doc + that doesn't exist (`enable.files`, `field.columnName`, `versioning`). Benign display/doc metadata that's "dead" (no runtime reader) — `description`, `tags`, `icon` — must NOT be marked; an author isn't misled by them. 2. **Booleans: only mark `default(false)` flags.** The lint warns on a boolean only when set @@ -163,7 +163,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type | Type | live | exp | dead | Notes | |---|---|---|---|---| -| object | 31 | – | 17 | `enable`/ObjectCapabilities + versioning/partitioning/cdc tier dead; `apiEnabled` unenforced | +| object | 34 | – | 14 | versioning/partitioning/cdc tier dead; ObjectCapabilities mostly live post-#2707 (`apiEnabled`/`apiMethods` enforced #1937; `feeds`/`activities` opt-out gates + `trackHistory` UI master switch); `enable.files` reserved/dead | | field | 34 | – | 39 | ~half dead — aspirational enhanced-type + governance config; naming-drift props server-live/client-snake | | flow | 29 | 1 | 7 | `runAs` experimental (unenforced identity switch); status/active gate nothing; FlowNodeAction enum out of sync | | action | 26 | – | 5 | `disabled` CEL ignored (renderers read non-spec `enabled`); type:'form'/shortcut/bulkEnabled dead | diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 207ffe4a82..6f52c9c57b 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -156,10 +156,8 @@ "note": "enforced by enforceApiAccess — operations outside the whitelist → 405." }, "trackHistory": { - "status": "dead", - "evidence": "no behavior-changing reader (database-loader.trackHistory is a separate ctor option)", - "authorWarn": true, - "authorHint": "Per-field history is live via `Field.trackHistory` (plugin-audit, ADR-0052) — set it on the fields you want tracked instead of this object flag." + "status": "live", + "evidence": "objectui app-shell RecordDetailView gates the record History tab on enable.trackHistory === true (historyEnabled memo); pairs with per-field trackHistory (plugin-audit renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts) which selects the field diffs. Audit CAPTURE into sys_audit_log is deliberately unconditional (compliance ledger) — the flag gates the UI surface, not the ledger (#2707)." }, "searchable": { "status": "dead", @@ -168,21 +166,17 @@ }, "files": { "status": "dead", - "evidence": "no behavior-changing reader", + "evidence": "no behavior-changing reader in framework or objectui — reserved for the generic Attachments related-list (Salesforce Notes & Attachments parity), tracked in #2727", "authorWarn": true, - "authorHint": "File attachments are modeled with a `Field.file`/`Field.image` (or the sys_attachment object), not this object flag — it enables nothing on its own." + "authorHint": "File attachments are modeled with a `Field.file`/`Field.image` (or the sys_attachment object), not this object flag — it enables nothing on its own yet (reserved for the future generic Attachments panel)." }, "feeds": { - "status": "dead", - "evidence": "no behavior-changing reader", - "authorWarn": true, - "authorHint": "Comments/collaboration live on the dedicated sys_comment object (plugin-audit), not this flag — it enables no feed at runtime." + "status": "live", + "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts (enforceFeedsCapability beforeInsert hook: explicit feeds:false on the target object rejects sys_comment creation, 403 FEEDS_DISABLED); objectui RecordDetailView hides the record feed on explicit false. Opt-out — spec default true (#2707)." }, "activities": { - "status": "dead", - "evidence": "no behavior-changing reader", - "authorWarn": true, - "authorHint": "Tasks/events live on the dedicated sys_activity object (plugin-audit), not this flag — it enables no activity suite at runtime." + "status": "live", + "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts (writeAudit: explicit activities:false skips the sys_activity timeline mirror; audit row unaffected); objectui RecordDetailView skips the timeline merge on explicit false. Opt-out — spec default true; the per-object lever for activity-row growth, ADR-0057 (#2707)." }, "trash": { "status": "dead", diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 19b7cf2ce3..b3734a6839 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -9,8 +9,10 @@ describe('ObjectCapabilities', () => { expect(result.searchable).toBe(true); expect(result.apiEnabled).toBe(true); expect(result.files).toBe(false); - expect(result.feeds).toBe(false); - expect(result.activities).toBe(false); + // feeds/activities are opt-OUT capabilities (#2707): default on, consumers + // gate on explicit `false` only — same posture as trash/mru/clone. + expect(result.feeds).toBe(true); + expect(result.activities).toBe(true); expect(result.trash).toBe(true); expect(result.mru).toBe(true); expect(result.clone).toBe(true); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b24455ef78..07eda22d17 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -29,45 +29,79 @@ export type ApiMethod = z.infer; /** * Capability Flags * Defines what system features are enabled for this object. - * - * Optimized based on industry standards (Salesforce, ServiceNow): - * - Added `activities` (Tasks/Events) - * - Added `mru` (Recent Items) - * - Added `feeds` (Social/Chatter) - * - Grouped API permissions - * + * + * Modeled on industry standards (Salesforce "Allow Activities"/"Track Field + * History"/"Enable Feed Tracking", Dataverse table options). Each flag has a + * defined enforcement contract (#2707); a flag with no runtime consumer is a + * bug, not a reservation — see `@objectstack/spec/liveness/object.json`. + * + * Opt-out flags (`feeds`, `activities`, `trash`, `mru`, `clone`, `searchable`, + * `apiEnabled`) default to `true`: absent block/flag = enabled, and consumers + * gate on explicit `false` only. Opt-in flags (`trackHistory`, `files`) + * default to `false`. + * * @example * { * trackHistory: true, * searchable: true, * apiEnabled: true, - * files: true + * activities: false * } */ export const ObjectCapabilities = z.object({ - /** Enable history tracking (Audit Trail) */ - trackHistory: z.boolean().default(false).describe('Enable field history tracking for audit compliance'), - + /** + * History tracking (Audit Trail) master switch — opt-in. + * + * Contract: `true` surfaces the record History tab (audit-trail UI) in the + * console. Pair with per-field `trackHistory: true` to select which field + * diffs render as human-readable timeline summaries (ADR-0052 §5b). Audit + * *capture* into `sys_audit_log` is a compliance ledger and stays on + * regardless of this flag; retention is governed by data lifecycle + * (ADR-0057), not by hiding the UI. + */ + trackHistory: z.boolean().default(false).describe('Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance'), + /** Enable global search indexing */ searchable: z.boolean().default(true).describe('Index records for global search'), - + /** Enable REST/GraphQL API access */ apiEnabled: z.boolean().default(true).describe('Expose object via automatic APIs'), - /** + /** * API Supported Operations * Granular control over API exposure. */ apiMethods: z.array(ApiMethod).optional().describe('Whitelist of allowed API operations'), - - /** Enable standard attachments/files engine */ - files: z.boolean().default(false).describe('Enable file attachments and document management'), - - /** Enable social collaboration (Comments, Mentions, Feeds) */ - feeds: z.boolean().default(false).describe('Enable social feed, comments, and mentions (Chatter-like)'), - - /** Enable standard Activity suite (Tasks, Calendars, Events) */ - activities: z.boolean().default(false).describe('Enable standard tasks and events tracking'), + + /** + * Standard attachments/files engine — opt-in, NOT YET CONSUMED at runtime. + * + * Reserved for the generic Attachments related-list (Salesforce + * "Notes & Attachments" parity). Until that ships, model attachments with + * `Field.file` / `Field.image` (or relate to `sys_attachment`) — setting + * this flag alone enables nothing, and the compile-time liveness lint + * warns on it. + */ + files: z.boolean().default(false).describe('RESERVED (no runtime effect yet) — use Field.file/Field.image for attachments; this flag will gate the future generic Attachments panel'), + + /** + * Social collaboration (Comments, Mentions, Feeds) — opt-out. + * + * Contract: default on. An explicit `false` hides the record feed UI and + * rejects new `sys_comment` rows targeting this object (403 + * FEEDS_DISABLED, enforced at the engine hook seam by plugin-audit). + */ + feeds: z.boolean().default(true).describe('Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object'), + + /** + * Activity timeline (sys_activity mirror of create/update/delete) — opt-out. + * + * Contract: default on. An explicit `false` stops plugin-audit from + * mirroring this object's CRUD into `sys_activity` (the record timeline) + * and hides the timeline merge in the console. The off-switch is also the + * per-object lever for activity-row growth (ADR-0057). + */ + activities: z.boolean().default(true).describe('Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline'), /** Enable Recycle Bin / Soft Delete */ trash: z.boolean().default(true).describe('Enable soft-delete with restore capability'),