Skip to content

Commit 7505274

Browse files
os-zhuangclaude
andauthored
feat(audit): declarative activity milestones (ADR-0052 §5b.2) (#1952)
* demo(showcase): mark task status/priority trackHistory (ADR-0052 §5b) Demonstrates declarative activity: status/priority changes auto-appear on the record timeline ("Status: In Progress → In Review") with zero hook code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(audit): declarative activity milestones (ADR-0052 §5b.2) Object-level `activityMilestones` — when a watched field transitions INTO a configured value, the platform emits a templated, semantic activity row ("✅ Task completed: {title}", "Deal won: {name}") on the record timeline, with zero hook/flow code. Complements field-level `trackHistory` (§5b.1) and takes precedence over the raw "Field: old → new" summary for the same update. Same declarative posture as Salesforce Flow / ServiceNow for milestone events. - spec: `activityMilestones` on ObjectSchema (top-level) + liveness classification - plugin-audit: matchMilestone() + getObjectDef(); summary precedence milestone → field-change → generic; milestone may set the activity `type` - tests: 2 new (interpolated milestone + precedence; no-fire when no transition) - showcase: showcase_task fires "✅ Task completed: {title}" on status→done Verified in the showcase browser: status→done renders the milestone row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82d2202 commit 7505274

5 files changed

Lines changed: 162 additions & 9 deletions

File tree

examples/app-showcase/src/objects/task.object.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export const Task = ObjectSchema.create({
4040
status: Field.select({
4141
label: 'Status',
4242
required: true,
43+
// ADR-0052 §5b — declarative activity. Status changes auto-appear on the
44+
// record timeline as "Status: To Do → In Progress" with zero hook code.
45+
trackHistory: true,
4346
options: [
4447
{ label: 'Backlog', value: 'backlog', default: true, color: '#94A3B8' },
4548
{ label: 'To Do', value: 'todo', color: '#3B82F6' },
@@ -50,6 +53,7 @@ export const Task = ObjectSchema.create({
5053
}),
5154
priority: Field.select({
5255
label: 'Priority',
56+
trackHistory: true,
5357
options: [
5458
{ label: 'Low', value: 'low', color: '#94A3B8' },
5559
{ label: 'Medium', value: 'medium', default: true, color: '#3B82F6' },
@@ -81,6 +85,13 @@ export const Task = ObjectSchema.create({
8185
sync_error: Field.textarea({ label: 'Sync Error' }),
8286
},
8387

88+
// ADR-0052 §5b.2 — declarative semantic milestone. When status enters `done`,
89+
// the platform emits "✅ Task completed: <title>" on the timeline (taking
90+
// precedence over the raw "Status: In Review → Done" diff) — zero hook code.
91+
activityMilestones: [
92+
{ field: 'status', value: 'done', summary: '✅ Task completed: {title}', type: 'completed' },
93+
],
94+
8495
validations: [
8596
{
8697
type: 'state_machine' as const,

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

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ interface CapturedRow {
2525
* `engine.getSchema(name)` returns after `applySystemFields` has (or has
2626
* not) injected `organization_id`.
2727
*/
28-
function makeEngine(schemas: Record<string, string[] | Record<string, any>>) {
28+
function makeEngine(
29+
schemas: Record<string, string[] | Record<string, any>>,
30+
objectDefs: Record<string, any> = {},
31+
) {
2932
const hooks = new Map<string, Array<(ctx: any) => any>>();
3033
const created: CapturedRow[] = [];
3134

@@ -49,7 +52,7 @@ function makeEngine(schemas: Record<string, string[] | Record<string, any>>) {
4952
const fieldMap = Array.isArray(fields)
5053
? Object.fromEntries(fields.map((f) => [f, { type: 'text' }]))
5154
: fields;
52-
return { name, fields: fieldMap };
55+
return { name, fields: fieldMap, ...(objectDefs[name] || {}) };
5356
},
5457
registerHook(event: string, fn: (ctx: any) => any) {
5558
const list = hooks.get(event) ?? [];
@@ -177,3 +180,68 @@ describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)',
177180
expect(activity?.row.summary).toBe('Updated crm_opportunity "Acme Renewal"');
178181
});
179182
});
183+
184+
describe('audit writers — declarative milestones (ADR-0052 §5b.2)', () => {
185+
const FIELDS = {
186+
id: { type: 'text' },
187+
name: { type: 'text', label: 'Name' },
188+
stage: {
189+
type: 'select',
190+
label: 'Stage',
191+
trackHistory: true,
192+
options: [
193+
{ value: 'negotiation', label: 'Negotiation' },
194+
{ value: 'closed_won', label: 'Closed Won' },
195+
],
196+
},
197+
};
198+
const SCHEMA = {
199+
sys_audit_log: SINGLE_TENANT.sys_audit_log,
200+
sys_activity: SINGLE_TENANT.sys_activity,
201+
crm_opportunity: FIELDS,
202+
};
203+
// Object-level milestone: when stage enters closed_won → "Deal won: {name}".
204+
const OBJECT_DEFS = {
205+
crm_opportunity: {
206+
activityMilestones: [
207+
{ field: 'stage', value: 'closed_won', summary: 'Deal won: {name}', type: 'completed' },
208+
],
209+
},
210+
};
211+
212+
it('emits the interpolated milestone summary (precedence over field-change) on transition', async () => {
213+
const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
214+
installAuditWriters(engine as any, 'test.audit');
215+
216+
await fire('afterUpdate', {
217+
object: 'crm_opportunity',
218+
input: { id: 'opp-1', stage: 'closed_won' },
219+
result: { id: 'opp-1', name: 'Acme Renewal', stage: 'closed_won' },
220+
__previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
221+
session: {},
222+
});
223+
224+
const activity = created.find((c) => c.object === 'sys_activity');
225+
// Milestone summary wins over the "Stage: Negotiation → Closed Won" diff.
226+
expect(activity?.row.summary).toBe('Deal won: Acme Renewal');
227+
expect(activity?.row.type).toBe('completed');
228+
});
229+
230+
it('does not fire the milestone when the field does not transition into the value', async () => {
231+
const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
232+
installAuditWriters(engine as any, 'test.audit');
233+
234+
await fire('afterUpdate', {
235+
object: 'crm_opportunity',
236+
input: { id: 'opp-1', stage: 'negotiation' },
237+
result: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
238+
__previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'proposal' },
239+
session: {},
240+
});
241+
242+
const activity = created.find((c) => c.object === 'sys_activity');
243+
// Falls back to the field-change render (trackHistory), not the milestone.
244+
// `proposal` has no option entry here → raw value; `negotiation` → its label.
245+
expect(activity?.row.summary).toBe('Stage: proposal → Negotiation');
246+
});
247+
});

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

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,39 @@ function renderTrackedChangeSummary(
172172
return parts.length > 0 ? parts.join('; ') : null;
173173
}
174174

175+
/**
176+
* ADR-0052 §5b.2 — declarative semantic milestones. If a watched field
177+
* transitioned INTO a configured `value` on this update (before ≠ value, after =
178+
* value), return the interpolated summary (and optional activity type). `{token}`
179+
* in the template is replaced by the record's field value, resolving select
180+
* option labels when possible. Returns null when no milestone fired (needs the
181+
* `before` snapshot to detect a transition). Takes precedence over the raw
182+
* field-change summary.
183+
*/
184+
function matchMilestone(
185+
objectDef: any,
186+
fields: Record<string, any> | null,
187+
before: Record<string, any> | null,
188+
after: Record<string, any> | null,
189+
): { summary: string; type?: string } | null {
190+
const milestones =
191+
objectDef && Array.isArray(objectDef.activityMilestones) ? objectDef.activityMilestones : null;
192+
if (!milestones || !after || !before) return null;
193+
for (const m of milestones) {
194+
if (!m || typeof m.field !== 'string' || typeof m.summary !== 'string') continue;
195+
if (after[m.field] === m.value && before[m.field] !== m.value) {
196+
const summary = m.summary.replace(/\{(\w+)\}/g, (_match: string, key: string) => {
197+
const v = after[key];
198+
if (v === null || v === undefined || v === '') return '';
199+
const field = fields ? fields[key] : undefined;
200+
return field ? displayFieldValue(field, v) : String(v);
201+
});
202+
return { summary, type: typeof m.type === 'string' ? m.type : undefined };
203+
}
204+
}
205+
return null;
206+
}
207+
175208
/**
176209
* Install audit + activity writers on the given engine. Idempotent per
177210
* `packageId` — calling twice with the same id replaces the previous
@@ -240,6 +273,21 @@ export function installAuditWriters(
240273
return defs;
241274
};
242275

276+
// Cached full object definition (for ADR-0052 §5b.2 activityMilestones —
277+
// object-level metadata, not just fields).
278+
const objectDefCache = new Map<string, any>();
279+
const getObjectDef = (objectName: string): any => {
280+
if (objectDefCache.has(objectName)) return objectDefCache.get(objectName);
281+
let def: any = null;
282+
try {
283+
def = typeof (engine as any).getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
284+
} catch {
285+
/* ignore — best-effort */
286+
}
287+
objectDefCache.set(objectName, def);
288+
return def;
289+
};
290+
243291
/**
244292
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
245293
* so we can compute the diff in the afterXxx hook. We attach the snapshot
@@ -361,20 +409,28 @@ export function installAuditWriters(
361409

362410
const label = recordLabel(after ?? before, recordId ?? '');
363411
let summary: string;
412+
let activityType: string = activityTypeFor(action);
364413
if (action === 'create') {
365414
summary = `Created ${ctx.object} "${label}"`;
366415
} else if (action === 'delete') {
367416
summary = `Deleted ${ctx.object} "${label}"`;
368417
} else {
369-
// ADR-0052 §5b: if any changed field declares `trackHistory: true`, render
370-
// the diff legibly ("Stage: Proposal → Closed Won"); else generic text.
371-
summary =
372-
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
373-
`Updated ${ctx.object} "${label}"`;
418+
// ADR-0052 §5b — declarative activity, precedence: a configured semantic
419+
// milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
420+
// Proposal → Closed Won", §5b.1); else the generic fallback.
421+
const milestone = matchMilestone(getObjectDef(ctx.object), getFieldDefs(ctx.object), before, after);
422+
if (milestone) {
423+
summary = milestone.summary;
424+
if (milestone.type) activityType = milestone.type;
425+
} else {
426+
summary =
427+
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
428+
`Updated ${ctx.object} "${label}"`;
429+
}
374430
}
375431

376432
const activityRow: Record<string, any> = {
377-
type: activityTypeFor(action),
433+
type: activityType,
378434
// Explicit ISO timestamp — `defaultValue: 'NOW()'` on the column
379435
// isn't resolved by every driver and would otherwise leak the
380436
// literal string "NOW()" into the row.

packages/spec/liveness/object.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"external": { "status": "live", "evidence": "packages/runtime/src/external-validation-plugin.ts", "note": "remote table + write gate." },
1919
"indexes": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1181", "note": "DDL." },
2020
"validations": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts:154", "note": "incl. state_machine." },
21+
"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." },
2122
"actions": { "status": "live", "evidence": "packages/runtime/src/app-plugin.ts:929", "note": "served on /meta/objects/:name." },
2223
"managedBy": { "status": "live", "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." },
2324
"userActions": { "status": "live", "note": "objectui ObjectGrid per-object CRUD." },

packages/spec/src/data/object.zod.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const ObjectCapabilities = z.object({
6868

6969
/** Enable standard Activity suite (Tasks, Calendars, Events) */
7070
activities: z.boolean().default(false).describe('Enable standard tasks and events tracking'),
71-
71+
7272
/** Enable Recycle Bin / Soft Delete */
7373
trash: z.boolean().default(true).describe('Enable soft-delete with restore capability'),
7474

@@ -523,6 +523,23 @@ const ObjectSchemaBase = z.object({
523523
*/
524524
validations: z.array(ValidationRuleSchema).optional().describe('Object-level validation rules'),
525525

526+
/**
527+
* Declarative semantic activity milestones (ADR-0052 §5b.2). When a watched
528+
* field transitions INTO `value`, the platform emits a templated activity-row
529+
* on the record timeline — no `*.hook.ts` / `*.flow.ts`. Complements field-level
530+
* `trackHistory` (which renders raw "Field: old → new"): use milestones for
531+
* business-meaningful events ("Deal won", "Task completed"). `summary` supports
532+
* `{field}` tokens interpolated from the record; the milestone summary takes
533+
* precedence over the field-change summary for the same update. Consumed by
534+
* `@objectstack/plugin-audit` audit-writers (enforce-or-remove, ADR-0049).
535+
*/
536+
activityMilestones: z.array(z.object({
537+
field: z.string().describe('Field to watch (typically a status/stage select).'),
538+
value: z.string().describe('The value the field must transition INTO to fire the milestone.'),
539+
summary: z.string().describe('Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}".'),
540+
type: z.string().optional().describe('Activity type for the emitted row (default "completed").'),
541+
})).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).'),
542+
526543
// ADR-0020: record state machines are not a separate `stateMachines` map —
527544
// each lifecycle is a `state_machine` rule in `validations` above (one rule
528545
// per state field). Parallel lifecycles = multiple rules. The write path

0 commit comments

Comments
 (0)