Skip to content

Commit e2fa074

Browse files
os-zhuangclaude
andauthored
feat(data): enable.feeds/activities become real opt-out gates; trackHistory contract defined (#2707) (#2733)
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 <noreply@anthropic.com>
1 parent 2f3581f commit e2fa074

12 files changed

Lines changed: 303 additions & 55 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/rest': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
feat(data): make object `enable.feeds`/`enable.activities` real opt-out gates; define the `enable.trackHistory` contract (#2707)
9+
10+
`ObjectSchema.enable.{files,trackHistory,activities,feeds}` were parsed but
11+
(mostly) unconsumed — an author setting them got nothing, silently. Per the
12+
enforce-or-remove doctrine, each flag now has a defined enforcement contract:
13+
14+
- `enable.activities` — opt-OUT writer gate. Spec default flips
15+
`false → true`; plugin-audit keeps mirroring CRUD into the `sys_activity`
16+
timeline unless the object declares an explicit `activities: false`
17+
(behavior-preserving for every existing stack; the off-switch is the
18+
per-object lever for activity-row growth, ADR-0057). The compliance
19+
`sys_audit_log` row is NOT gated.
20+
- `enable.feeds` — opt-OUT with server-side enforcement. Spec default flips
21+
`false → true`; an explicit `feeds: false` now rejects `sys_comment`
22+
creation targeting that object at the engine hook seam
23+
(403 `FEEDS_DISABLED`, fail-closed like `CLONE_DISABLED`).
24+
- `enable.trackHistory` — was misclassified `dead` in the liveness ledger:
25+
the console has gated the record History tab on it since 2026-05.
26+
Reclassified live with the two-grain contract documented (object flag =
27+
History-tab master switch; per-field `trackHistory` = diff selector; audit
28+
*capture* stays unconditional as a compliance ledger).
29+
- `enable.files` — stays dead + authorWarn (reserved for the future generic
30+
Attachments panel; use `Field.file`/`Field.image` meanwhile). Its
31+
`describe()` now says so instead of advertising a capability that
32+
doesn't exist.
33+
34+
The default flips can't be avoided: with `default(false)`, compiled output
35+
materializes `false` for every object with an `enable` block, making
36+
"author explicitly opted out" indistinguishable from "schema default" — so
37+
opt-out semantics require the default to be `true` (same posture as
38+
`trash`/`mru`/`clone`). Liveness ledger + reference docs regenerated;
39+
compile-time authorWarn now fires only for `enable.files`.

content/docs/references/data/object.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,13 @@ const result = ApiMethod.parse(data);
7777

7878
| Property | Type | Required | Description |
7979
| :--- | :--- | :--- | :--- |
80-
| **trackHistory** | `boolean` || Enable field history tracking for audit compliance |
80+
| **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 |
8181
| **searchable** | `boolean` || Index records for global search |
8282
| **apiEnabled** | `boolean` || Expose object via automatic APIs |
8383
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Whitelist of allowed API operations |
84-
| **files** | `boolean` || Enable file attachments and document management |
85-
| **feeds** | `boolean` || Enable social feed, comments, and mentions (Chatter-like) |
86-
| **activities** | `boolean` || Enable standard tasks and events tracking |
84+
| **files** | `boolean` || RESERVED (no runtime effect yet) — use Field.file/Field.image for attachments; this flag will gate the future generic Attachments panel |
85+
| **feeds** | `boolean` || Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object |
86+
| **activities** | `boolean` || Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
8787
| **trash** | `boolean` || Enable soft-delete with restore capability |
8888
| **mru** | `boolean` || Track Most Recently Used (MRU) list for users |
8989
| **clone** | `boolean` || Allow record deep cloning |

packages/cli/src/commands/compile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export default class Compile extends Command {
250250

251251
// 3d-bis. Liveness author-warning lint — close the spec-liveness loop on
252252
// the author side: an authored property the ledger marks dead-and-
253-
// misleading (e.g. `object.enable.feeds`, `field.columnName`) or
253+
// misleading (e.g. `object.enable.files`, `field.columnName`) or
254254
// experimental is set hopefully but does nothing / isn't enforced at
255255
// runtime. Advisory only; ledger-driven (entries opt in via
256256
// `authorWarn`), so it's high-signal and NEVER fails the build.

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
/**
1010
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
1111
* files the gate enforces), so they double as a contract test: if an
12-
* `authorWarn` annotation is removed from `enable.feeds` / `columnName` / etc.,
12+
* `authorWarn` annotation is removed from `enable.files` / `columnName` / etc.,
1313
* the matching assertion fails.
1414
*/
1515

@@ -18,21 +18,32 @@ const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
1818
const paths = (findings: { message: string }[]) => findings.map((f) => f.message);
1919

2020
describe('lintLivenessProperties', () => {
21-
it('warns on an authored dead capability flag (enable.feeds: true)', () => {
22-
const findings = lintLivenessProperties(objStack({ enable: { feeds: true } }));
21+
it('warns on an authored dead capability flag (enable.files: true)', () => {
22+
const findings = lintLivenessProperties(objStack({ enable: { files: true } }));
2323
expect(findings.length).toBeGreaterThan(0);
24-
const feeds = findings.find((f) => f.message.includes('enable.feeds'));
25-
expect(feeds).toBeDefined();
26-
expect(feeds!.rule).toBe(LIVENESS_DEAD_PROPERTY);
27-
expect(feeds!.where).toBe("object 'widget'");
28-
expect(feeds!.hint.length).toBeGreaterThan(0);
24+
const files = findings.find((f) => f.message.includes('enable.files'));
25+
expect(files).toBeDefined();
26+
expect(files!.rule).toBe(LIVENESS_DEAD_PROPERTY);
27+
expect(files!.where).toBe("object 'widget'");
28+
expect(files!.hint.length).toBeGreaterThan(0);
2929
});
3030

3131
it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => {
3232
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
3333
expect(paths(findings).some((m) => m.includes('enable.trash'))).toBe(false);
3434
});
3535

36+
// #2707: feeds/activities/trackHistory went LIVE (opt-out writer/UI gates +
37+
// History-tab master switch) — authoring them must no longer warn.
38+
it('does NOT warn on the now-live capability flags (feeds/activities/trackHistory)', () => {
39+
const findings = lintLivenessProperties(
40+
objStack({ enable: { feeds: true, activities: true, trackHistory: true } }),
41+
);
42+
expect(paths(findings).some((m) => m.includes('enable.feeds'))).toBe(false);
43+
expect(paths(findings).some((m) => m.includes('enable.activities'))).toBe(false);
44+
expect(paths(findings).some((m) => m.includes('enable.trackHistory'))).toBe(false);
45+
});
46+
3647
it('does NOT warn when a dead boolean flag is explicitly false (enable.files: false)', () => {
3748
const findings = lintLivenessProperties(objStack({ enable: { files: false } }));
3849
expect(paths(findings).some((m) => m.includes('enable.files'))).toBe(false);
@@ -72,7 +83,7 @@ describe('lintLivenessProperties', () => {
7283

7384
it('handles objects as a keyed record (not just arrays)', () => {
7485
const findings = lintLivenessProperties({
75-
objects: { widget: { name: 'widget', enable: { feeds: true } } },
86+
objects: { widget: { name: 'widget', enable: { files: true } } },
7687
});
7788
expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY);
7889
});

packages/plugins/plugin-audit/src/audit-writers.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,94 @@ describe('audit writers — declarative milestones (ADR-0052 §5b.2)', () => {
294294
expect(activity?.row.summary).toBe('Stage: proposal → Negotiation');
295295
});
296296
});
297+
298+
describe('audit writers — enable.activities opt-out gate (#2707)', () => {
299+
const SCHEMA = {
300+
sys_audit_log: SINGLE_TENANT.sys_audit_log,
301+
sys_activity: SINGLE_TENANT.sys_activity,
302+
crm_lead: ['id', 'name'],
303+
};
304+
305+
it('mirrors CRUD into sys_activity by default (absent enable block)', async () => {
306+
const { engine, fire, created } = makeEngine(SCHEMA);
307+
installAuditWriters(engine as any, 'test.audit');
308+
309+
await fire('afterInsert', {
310+
object: 'crm_lead',
311+
input: { id: 'lead-1' },
312+
result: { id: 'lead-1', name: 'Acme' },
313+
session: {},
314+
});
315+
316+
expect(created.some((c) => c.object === 'sys_audit_log')).toBe(true);
317+
expect(created.some((c) => c.object === 'sys_activity')).toBe(true);
318+
});
319+
320+
it('skips ONLY the sys_activity mirror on explicit activities:false — audit row still written', async () => {
321+
const { engine, fire, created } = makeEngine(SCHEMA, {
322+
crm_lead: { enable: { activities: false } },
323+
});
324+
installAuditWriters(engine as any, 'test.audit');
325+
326+
await fire('afterInsert', {
327+
object: 'crm_lead',
328+
input: { id: 'lead-1' },
329+
result: { id: 'lead-1', name: 'Acme' },
330+
session: {},
331+
});
332+
333+
// Compliance ledger is NOT gated by the capability flag…
334+
expect(created.some((c) => c.object === 'sys_audit_log')).toBe(true);
335+
// …but the timeline mirror is.
336+
expect(created.some((c) => c.object === 'sys_activity')).toBe(false);
337+
});
338+
});
339+
340+
describe('audit writers — enable.feeds server-side enforcement (#2707)', () => {
341+
const SCHEMA = {
342+
sys_audit_log: SINGLE_TENANT.sys_audit_log,
343+
sys_activity: SINGLE_TENANT.sys_activity,
344+
crm_lead: ['id', 'name'],
345+
};
346+
347+
const commentInsert = (threadId?: unknown) => ({
348+
object: 'sys_comment',
349+
input: { data: { thread_id: threadId, body: 'hello' } },
350+
session: {},
351+
});
352+
353+
it('rejects sys_comment creation targeting an object with explicit feeds:false (403 FEEDS_DISABLED)', async () => {
354+
const { engine, fire } = makeEngine(SCHEMA, {
355+
crm_lead: { enable: { feeds: false } },
356+
});
357+
installAuditWriters(engine as any, 'test.audit');
358+
359+
await expect(fire('beforeInsert', commentInsert('crm_lead:rec-1'))).rejects.toMatchObject({
360+
code: 'FEEDS_DISABLED',
361+
status: 403,
362+
object: 'crm_lead',
363+
});
364+
});
365+
366+
it('allows comments when feeds is absent (opt-out default) or explicitly true', async () => {
367+
const absent = makeEngine(SCHEMA);
368+
installAuditWriters(absent.engine as any, 'test.audit');
369+
await expect(absent.fire('beforeInsert', commentInsert('crm_lead:rec-1'))).resolves.toBeUndefined();
370+
371+
const explicit = makeEngine(SCHEMA, { crm_lead: { enable: { feeds: true } } });
372+
installAuditWriters(explicit.engine as any, 'test.audit');
373+
await expect(explicit.fire('beforeInsert', commentInsert('crm_lead:rec-1'))).resolves.toBeUndefined();
374+
});
375+
376+
it('lets unconventional/missing thread_id through (capability gate, not access control)', async () => {
377+
const { engine, fire } = makeEngine(SCHEMA, {
378+
crm_lead: { enable: { feeds: false } },
379+
});
380+
installAuditWriters(engine as any, 'test.audit');
381+
382+
await expect(fire('beforeInsert', commentInsert(undefined))).resolves.toBeUndefined();
383+
await expect(fire('beforeInsert', commentInsert('free-form-thread'))).resolves.toBeUndefined();
384+
// Unknown target object → no def → allowed.
385+
await expect(fire('beforeInsert', commentInsert('ghost_object:rec-9'))).resolves.toBeUndefined();
386+
});
387+
});

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,10 +488,17 @@ export function installAuditWriters(
488488
activityRow.organization_id = tenantId ?? null;
489489
}
490490

491+
// `enable.activities` is an opt-OUT capability (#2707): absent block/flag
492+
// = mirror on (spec default `true`), explicit `false` = this object's CRUD
493+
// is not mirrored into the sys_activity timeline. This is the per-object
494+
// lever for activity-row growth (ADR-0057). The compliance audit row is
495+
// NOT gated — sys_audit_log capture stays unconditional.
496+
const activitiesEnabled = getObjectDef(ctx.object)?.enable?.activities !== false;
497+
491498
try {
492499
const sys = api.sudo();
493500
await sys.object('sys_audit_log').create(auditRow);
494-
await sys.object('sys_activity').create(activityRow);
501+
if (activitiesEnabled) await sys.object('sys_activity').create(activityRow);
495502
// M10.8 / ADR-0030: notify the assignee. Best-effort; never throws into
496503
// the user-facing CRUD path. Goes through the messaging single ingress
497504
// (`emit`) — the inbox channel materializes the bell row — rather than
@@ -521,6 +528,40 @@ export function installAuditWriters(
521528
engine.registerHook('afterUpdate', writeAudit, { packageId });
522529
engine.registerHook('afterDelete', writeAudit, { packageId });
523530

531+
/**
532+
* `enable.feeds` server-side enforcement (#2707). Comments are created
533+
* through the generic data path (`dataSource.create('sys_comment', …)`),
534+
* so the engine hook seam is the one gate every caller crosses — REST,
535+
* SDK, and feed-service alike. Opt-OUT semantics matching `enable.clone`
536+
* (cloneData in metadata-protocol): absent block/flag = allowed, only an
537+
* explicit `feeds: false` on the *target* object rejects. The thrown
538+
* error carries `.status`, which the REST layer's `sendError` forwards
539+
* verbatim → 403 FEEDS_DISABLED, fail-closed like CLONE_DISABLED.
540+
*
541+
* The target object is resolved from `thread_id` (conventionally
542+
* `{object}:{record_id}` — see sys-comment.object.ts). A missing or
543+
* unconventional thread_id is allowed through: this is capability
544+
* gating, not access control, and free-form threads have no object to
545+
* gate on.
546+
*/
547+
const enforceFeedsCapability = async (ctx: HookContext) => {
548+
const data: any = (ctx.input as any)?.data;
549+
const threadId = data?.thread_id;
550+
if (typeof threadId !== 'string') return;
551+
const sep = threadId.indexOf(':');
552+
if (sep <= 0) return;
553+
const targetObject = threadId.slice(0, sep);
554+
const def = getObjectDef(targetObject);
555+
if (def?.enable?.feeds === false) {
556+
const err: any = new Error(`Comments are disabled for object '${targetObject}' (enable.feeds: false)`);
557+
err.code = 'FEEDS_DISABLED';
558+
err.status = 403;
559+
err.object = targetObject;
560+
throw err;
561+
}
562+
};
563+
engine.registerHook('beforeInsert', enforceFeedsCapability, { object: 'sys_comment', packageId });
564+
524565
/**
525566
* M10.8: Dedicated hook on `sys_comment` afterInsert that parses the
526567
* `mentions` JSON field and writes one sys_notification per mentioned

packages/rest/src/rest-server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ export function mapDataError(error: any, object?: string): { status: number; bod
104104
},
105105
};
106106
}
107+
// Capability gate (#2707): the target object opted out of comments
108+
// (`enable.feeds: false`) — plugin-audit's engine hook rejects the
109+
// sys_comment insert fail-closed. 403 like CLONE_DISABLED; surfaced by
110+
// `code` because the generic data routes map through here (they never
111+
// reach sendError's `.status` passthrough). `error.object` names the
112+
// gated TARGET object (not sys_comment), so prefer it.
113+
if (error?.code === 'FEEDS_DISABLED') {
114+
return {
115+
status: 403,
116+
body: {
117+
error: error?.message ?? 'Comments are disabled for this object',
118+
code: 'FEEDS_DISABLED',
119+
...(error?.object || object ? { object: error?.object ?? object } : {}),
120+
},
121+
};
122+
}
107123
// Short-circuit: explicit security denial → 403. Match by `code` /
108124
// `name` to avoid pulling a runtime dependency on plugin-security.
109125
if (

packages/rest/src/rest.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,6 +1927,26 @@ describe('mapDataError — schema/constraint envelopes', () => {
19271927
const sqliteError = (message: string, code = 'SQLITE_ERROR') =>
19281928
Object.assign(new Error(message), { code });
19291929

1930+
// #2707: plugin-audit's engine hook rejects sys_comment creation when the
1931+
// TARGET object declares `enable.feeds: false`. The generic data routes map
1932+
// errors through mapDataError (not sendError's `.status` passthrough), so
1933+
// the code needs its own branch — without it the 403 degraded to the
1934+
// catch-all 400 (caught live in the runtime smoke test).
1935+
it('maps FEEDS_DISABLED → 403 with the gated target object', () => {
1936+
const r = mapDataError(
1937+
Object.assign(new Error("Comments are disabled for object 'gate_probe' (enable.feeds: false)"), {
1938+
code: 'FEEDS_DISABLED',
1939+
status: 403,
1940+
object: 'gate_probe',
1941+
}),
1942+
'sys_comment',
1943+
);
1944+
expect(r.status).toBe(403);
1945+
expect(r.body.code).toBe('FEEDS_DISABLED');
1946+
// Prefers the gated target object over the route's object (sys_comment).
1947+
expect(r.body.object).toBe('gate_probe');
1948+
});
1949+
19301950
it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => {
19311951
const r = mapDataError(
19321952
sqliteError(

packages/spec/liveness/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Signal over noise is the whole point, so warnings are **opt-in per entry**:
105105
Two rules keep it false-positive-free, **both of which the marker author must respect**:
106106

107107
1. **Only mark genuinely *misleading* dead props** — ones that imply a capability/behavior
108-
that doesn't exist (`enable.feeds`, `field.columnName`, `versioning`). Benign display/doc
108+
that doesn't exist (`enable.files`, `field.columnName`, `versioning`). Benign display/doc
109109
metadata that's "dead" (no runtime reader) — `description`, `tags`, `icon` — must NOT be
110110
marked; an author isn't misled by them.
111111
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
163163

164164
| Type | live | exp | dead | Notes |
165165
|---|---|---|---|---|
166-
| object | 31 || 17 | `enable`/ObjectCapabilities + versioning/partitioning/cdc tier dead; `apiEnabled` unenforced |
166+
| 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 |
167167
| field | 34 || 39 | ~half dead — aspirational enhanced-type + governance config; naming-drift props server-live/client-snake |
168168
| flow | 29 | 1 | 7 | `runAs` experimental (unenforced identity switch); status/active gate nothing; FlowNodeAction enum out of sync |
169169
| action | 26 || 5 | `disabled` CEL ignored (renderers read non-spec `enabled`); type:'form'/shortcut/bulkEnabled dead |

0 commit comments

Comments
 (0)