diff --git a/examples/app-showcase/src/objects/task.object.ts b/examples/app-showcase/src/objects/task.object.ts
index b486b97617..6ead30125f 100644
--- a/examples/app-showcase/src/objects/task.object.ts
+++ b/examples/app-showcase/src/objects/task.object.ts
@@ -40,6 +40,9 @@ export const Task = ObjectSchema.create({
status: Field.select({
label: 'Status',
required: true,
+ // ADR-0052 §5b — declarative activity. Status changes auto-appear on the
+ // record timeline as "Status: To Do → In Progress" with zero hook code.
+ trackHistory: true,
options: [
{ label: 'Backlog', value: 'backlog', default: true, color: '#94A3B8' },
{ label: 'To Do', value: 'todo', color: '#3B82F6' },
@@ -50,6 +53,7 @@ export const Task = ObjectSchema.create({
}),
priority: Field.select({
label: 'Priority',
+ trackHistory: true,
options: [
{ label: 'Low', value: 'low', color: '#94A3B8' },
{ label: 'Medium', value: 'medium', default: true, color: '#3B82F6' },
@@ -81,6 +85,13 @@ export const Task = ObjectSchema.create({
sync_error: Field.textarea({ label: 'Sync Error' }),
},
+ // ADR-0052 §5b.2 — declarative semantic milestone. When status enters `done`,
+ // the platform emits "✅ Task completed:
" on the timeline (taking
+ // precedence over the raw "Status: In Review → Done" diff) — zero hook code.
+ activityMilestones: [
+ { field: 'status', value: 'done', summary: '✅ Task completed: {title}', type: 'completed' },
+ ],
+
validations: [
{
type: 'state_machine' as const,
diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts
index b4780e8fdb..7196d31de4 100644
--- a/packages/plugins/plugin-audit/src/audit-writers.test.ts
+++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts
@@ -25,7 +25,10 @@ interface CapturedRow {
* `engine.getSchema(name)` returns after `applySystemFields` has (or has
* not) injected `organization_id`.
*/
-function makeEngine(schemas: Record>) {
+function makeEngine(
+ schemas: Record>,
+ objectDefs: Record = {},
+) {
const hooks = new Map any>>();
const created: CapturedRow[] = [];
@@ -49,7 +52,7 @@ function makeEngine(schemas: Record>) {
const fieldMap = Array.isArray(fields)
? Object.fromEntries(fields.map((f) => [f, { type: 'text' }]))
: fields;
- return { name, fields: fieldMap };
+ return { name, fields: fieldMap, ...(objectDefs[name] || {}) };
},
registerHook(event: string, fn: (ctx: any) => any) {
const list = hooks.get(event) ?? [];
@@ -177,3 +180,68 @@ describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)',
expect(activity?.row.summary).toBe('Updated crm_opportunity "Acme Renewal"');
});
});
+
+describe('audit writers — declarative milestones (ADR-0052 §5b.2)', () => {
+ const FIELDS = {
+ id: { type: 'text' },
+ name: { type: 'text', label: 'Name' },
+ stage: {
+ type: 'select',
+ label: 'Stage',
+ trackHistory: true,
+ options: [
+ { value: 'negotiation', label: 'Negotiation' },
+ { value: 'closed_won', label: 'Closed Won' },
+ ],
+ },
+ };
+ const SCHEMA = {
+ sys_audit_log: SINGLE_TENANT.sys_audit_log,
+ sys_activity: SINGLE_TENANT.sys_activity,
+ crm_opportunity: FIELDS,
+ };
+ // Object-level milestone: when stage enters closed_won → "Deal won: {name}".
+ const OBJECT_DEFS = {
+ crm_opportunity: {
+ activityMilestones: [
+ { field: 'stage', value: 'closed_won', summary: 'Deal won: {name}', type: 'completed' },
+ ],
+ },
+ };
+
+ it('emits the interpolated milestone summary (precedence over field-change) on transition', async () => {
+ const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
+ installAuditWriters(engine as any, 'test.audit');
+
+ await fire('afterUpdate', {
+ object: 'crm_opportunity',
+ input: { id: 'opp-1', stage: 'closed_won' },
+ result: { id: 'opp-1', name: 'Acme Renewal', stage: 'closed_won' },
+ __previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
+ session: {},
+ });
+
+ const activity = created.find((c) => c.object === 'sys_activity');
+ // Milestone summary wins over the "Stage: Negotiation → Closed Won" diff.
+ expect(activity?.row.summary).toBe('Deal won: Acme Renewal');
+ expect(activity?.row.type).toBe('completed');
+ });
+
+ it('does not fire the milestone when the field does not transition into the value', async () => {
+ const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
+ installAuditWriters(engine as any, 'test.audit');
+
+ await fire('afterUpdate', {
+ object: 'crm_opportunity',
+ input: { id: 'opp-1', stage: 'negotiation' },
+ result: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
+ __previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'proposal' },
+ session: {},
+ });
+
+ const activity = created.find((c) => c.object === 'sys_activity');
+ // Falls back to the field-change render (trackHistory), not the milestone.
+ // `proposal` has no option entry here → raw value; `negotiation` → its label.
+ expect(activity?.row.summary).toBe('Stage: proposal → Negotiation');
+ });
+});
diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts
index 61c0f42515..d072403b0f 100644
--- a/packages/plugins/plugin-audit/src/audit-writers.ts
+++ b/packages/plugins/plugin-audit/src/audit-writers.ts
@@ -172,6 +172,39 @@ function renderTrackedChangeSummary(
return parts.length > 0 ? parts.join('; ') : null;
}
+/**
+ * ADR-0052 §5b.2 — declarative semantic milestones. If a watched field
+ * transitioned INTO a configured `value` on this update (before ≠ value, after =
+ * value), return the interpolated summary (and optional activity type). `{token}`
+ * in the template is replaced by the record's field value, resolving select
+ * option labels when possible. Returns null when no milestone fired (needs the
+ * `before` snapshot to detect a transition). Takes precedence over the raw
+ * field-change summary.
+ */
+function matchMilestone(
+ objectDef: any,
+ fields: Record | null,
+ before: Record | null,
+ after: Record | null,
+): { summary: string; type?: string } | null {
+ const milestones =
+ objectDef && Array.isArray(objectDef.activityMilestones) ? objectDef.activityMilestones : null;
+ if (!milestones || !after || !before) return null;
+ for (const m of milestones) {
+ if (!m || typeof m.field !== 'string' || typeof m.summary !== 'string') continue;
+ if (after[m.field] === m.value && before[m.field] !== m.value) {
+ const summary = m.summary.replace(/\{(\w+)\}/g, (_match: string, key: string) => {
+ const v = after[key];
+ if (v === null || v === undefined || v === '') return '';
+ const field = fields ? fields[key] : undefined;
+ return field ? displayFieldValue(field, v) : String(v);
+ });
+ return { summary, type: typeof m.type === 'string' ? m.type : undefined };
+ }
+ }
+ return null;
+}
+
/**
* Install audit + activity writers on the given engine. Idempotent per
* `packageId` — calling twice with the same id replaces the previous
@@ -240,6 +273,21 @@ export function installAuditWriters(
return defs;
};
+ // Cached full object definition (for ADR-0052 §5b.2 activityMilestones —
+ // object-level metadata, not just fields).
+ const objectDefCache = new Map();
+ const getObjectDef = (objectName: string): any => {
+ if (objectDefCache.has(objectName)) return objectDefCache.get(objectName);
+ let def: any = null;
+ try {
+ def = typeof (engine as any).getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
+ } catch {
+ /* ignore — best-effort */
+ }
+ objectDefCache.set(objectName, def);
+ return def;
+ };
+
/**
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
* so we can compute the diff in the afterXxx hook. We attach the snapshot
@@ -361,20 +409,28 @@ export function installAuditWriters(
const label = recordLabel(after ?? before, recordId ?? '');
let summary: string;
+ let activityType: string = activityTypeFor(action);
if (action === 'create') {
summary = `Created ${ctx.object} "${label}"`;
} else if (action === 'delete') {
summary = `Deleted ${ctx.object} "${label}"`;
} else {
- // ADR-0052 §5b: if any changed field declares `trackHistory: true`, render
- // the diff legibly ("Stage: Proposal → Closed Won"); else generic text.
- summary =
- renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
- `Updated ${ctx.object} "${label}"`;
+ // ADR-0052 §5b — declarative activity, precedence: a configured semantic
+ // milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
+ // Proposal → Closed Won", §5b.1); else the generic fallback.
+ const milestone = matchMilestone(getObjectDef(ctx.object), getFieldDefs(ctx.object), before, after);
+ if (milestone) {
+ summary = milestone.summary;
+ if (milestone.type) activityType = milestone.type;
+ } else {
+ summary =
+ renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
+ `Updated ${ctx.object} "${label}"`;
+ }
}
const activityRow: Record = {
- type: activityTypeFor(action),
+ type: activityType,
// Explicit ISO timestamp — `defaultValue: 'NOW()'` on the column
// isn't resolved by every driver and would otherwise leak the
// literal string "NOW()" into the row.
diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json
index cea730a9ff..c07a23b1ad 100644
--- a/packages/spec/liveness/object.json
+++ b/packages/spec/liveness/object.json
@@ -18,6 +18,7 @@
"external": { "status": "live", "evidence": "packages/runtime/src/external-validation-plugin.ts", "note": "remote table + write gate." },
"indexes": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1181", "note": "DDL." },
"validations": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts:154", "note": "incl. state_machine." },
+ "activityMilestones": { "status": "live", "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", "note": "ADR-0052 §5b.2 — audit-writer matchMilestone() emits a templated activity row when a watched field transitions into the configured value." },
"actions": { "status": "live", "evidence": "packages/runtime/src/app-plugin.ts:929", "note": "served on /meta/objects/:name." },
"managedBy": { "status": "live", "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." },
"userActions": { "status": "live", "note": "objectui ObjectGrid per-object CRUD." },
diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts
index a189efcd01..a257134805 100644
--- a/packages/spec/src/data/object.zod.ts
+++ b/packages/spec/src/data/object.zod.ts
@@ -68,7 +68,7 @@ export const ObjectCapabilities = z.object({
/** Enable standard Activity suite (Tasks, Calendars, Events) */
activities: z.boolean().default(false).describe('Enable standard tasks and events tracking'),
-
+
/** Enable Recycle Bin / Soft Delete */
trash: z.boolean().default(true).describe('Enable soft-delete with restore capability'),
@@ -523,6 +523,23 @@ const ObjectSchemaBase = z.object({
*/
validations: z.array(ValidationRuleSchema).optional().describe('Object-level validation rules'),
+ /**
+ * Declarative semantic activity milestones (ADR-0052 §5b.2). When a watched
+ * field transitions INTO `value`, the platform emits a templated activity-row
+ * on the record timeline — no `*.hook.ts` / `*.flow.ts`. Complements field-level
+ * `trackHistory` (which renders raw "Field: old → new"): use milestones for
+ * business-meaningful events ("Deal won", "Task completed"). `summary` supports
+ * `{field}` tokens interpolated from the record; the milestone summary takes
+ * precedence over the field-change summary for the same update. Consumed by
+ * `@objectstack/plugin-audit` audit-writers (enforce-or-remove, ADR-0049).
+ */
+ activityMilestones: z.array(z.object({
+ field: z.string().describe('Field to watch (typically a status/stage select).'),
+ value: z.string().describe('The value the field must transition INTO to fire the milestone.'),
+ summary: z.string().describe('Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}".'),
+ type: z.string().optional().describe('Activity type for the emitted row (default "completed").'),
+ })).optional().describe('Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2).'),
+
// ADR-0020: record state machines are not a separate `stateMachines` map —
// each lifecycle is a `state_machine` rule in `validations` above (one rule
// per state field). Parallel lifecycles = multiple rules. The write path