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
31 changes: 25 additions & 6 deletions docs/adr/0052-audit-is-not-the-activity-feed.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,12 +178,31 @@ timeline. But weighing it against the implementation reality reversed that lean:
| threads/mentions/reactions | ✅ fields already declared (`parent_id`, `reply_count`, `mentions`, `reactions`) | ✅ (but unreachable) |

Picking the durable, default, UI-wired system reaches "one backend" **now**, at
near-zero risk. `service-feed`'s only real edge — one unified *typed* stream —
is obtained on the chosen family by treating **`sys_activity` as the unified
typed timeline** (its `type` enum already carries the event kinds; extend it to
`email | call | event | note` as needed). The two remaining UI niceties
(reactions, threaded replies) are a render of fields `sys_comment` **already**
has — an objectui enhancement, not a backend change.
near-zero risk. `service-feed`'s only real edge — one unified *typed* stream — is
obtained on the chosen family by treating **`sys_activity` as the unified
timeline base** — the **ActivityPointer** model (cf. Dataverse `ActivityPointer`
→ `Email`/`PhoneCall`/`Appointment` subtypes; Salesforce ActivityTimeline →
`EmailMessage`/`Task`/`Event`):

- **`type` stays domain-NEUTRAL** — the platform-produced verbs (`created`,
`updated`, `commented`, `completed`, …). It is **not** extended with one
vertical's vocabulary (`email`/`call`/`meeting`); every domain has its own
(`interview`, `site_visit`, `inspection`, …) and a closed enum would be an
endless treadmill. Domain kind rides in `metadata.kind`.
- **Rich communication entities are their own tables** — an email belongs in
`sys_email` (already exists), a call/meeting in a task/activity object — never
crammed into a generic activity blob (they have structured headers, threading,
attachments, mutable delivery status that must be queryable).
- **`sys_activity` carries a structured pointer to that source entity** via
`source_object` / `source_id` (added in this PR) — distinct from
`object_name`/`record_id` (the *regarding* record). The timeline drills from a
one-line summary to the full email/call record, and apps can query "all
activities sourced from `sys_email`". This is the queryable equivalent of an id
buried in `metadata`.

The two remaining UI niceties (reactions, threaded replies) are a render of
fields `sys_comment` **already** has — an objectui enhancement, not a backend
change.

Rejected alternative — invest in `service-feed`: building a DB adapter + mounting
the REST route + repointing ChatterPanel + migrating `sys_comment` rows is weeks
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/plugin-audit/src/objects/sys-activity.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,6 +128,36 @@ export const SysActivity = ObjectSchema.create({
group: 'Target',
}),

// ── Source pointer (ADR-0052 §5 — ActivityPointer model) ─────────
// `object_name`/`record_id` say WHICH record this activity belongs to (the
// "regarding" record, e.g. the contact). `source_object`/`source_id` point
// to the RICH ENTITY this activity was derived from — the email row in
// `sys_email`, the call/meeting in a task object, the `sys_comment` — so the
// timeline can drill from a one-line summary to the full record. This is the
// queryable, structured equivalent of cramming an id into `metadata`
// (cf. Dataverse ActivityPointer → Email/PhoneCall/Appointment subtypes,
// Salesforce ActivityTimeline → EmailMessage/Task/Event). Optional: most
// CRUD activities have no distinct source (the record IS the source).
source_object: Field.text({
label: 'Source Object',
required: false,
readonly: true,
searchable: true,
maxLength: 255,
description: 'Object name of the rich source entity this activity was derived from (e.g. "sys_email"). Null when the activity is about the target record itself.',
group: 'Target',
}),

source_id: Field.text({
label: 'Source ID',
required: false,
readonly: true,
searchable: true,
maxLength: 255,
description: 'Record id of the rich source entity (paired with source_object) — lets the timeline drill to the full email/call/meeting record.',
group: 'Target',
}),

url: Field.url({
label: 'URL',
required: false,
Expand Down
Loading