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
39 changes: 39 additions & 0 deletions .changeset/enable-capability-gates-2707.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 4 additions & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 20 additions & 9 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand All @@ -18,21 +18,32 @@ 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)', () => {
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
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);
Expand Down Expand Up @@ -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);
});
Expand Down
91 changes: 91 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
43 changes: 42 additions & 1 deletion packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
20 changes: 20 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
Loading