Summary
sys_activity rows are the only thing the record:activity timeline renders, and a packaged app cannot ship a single usable one. Seeds load the rows happily — every gate reports success — but the rows attach to no record, so the timeline stays empty. The ActivityPointer model (ADR-0052 §5) is documented as the queryable drill from a one-line summary to the rich entity, and today no app can demonstrate it out of the box.
Found while implementing objectstack-ai/hotcrm#1258 (seed demo interactions so the lead Activity tab shows something). Measured against 17.1.0.
What was measured
Three type: 'completed' rows were seeded for a demo lead, then read back from the running server.
The write half works.build is silent, the boot banner counts the rows (Seeds: … 345 rows, up from 342), and every column lands — readonly included, since seeds write under { isSystem: true } and sys_activity declares every field readonly. mode: 'upsert' is accepted despite managedBy: 'append-only'; cel temporals resolve.
The addressing half does not.sys_activity.record_id is Field.text(), not a lookup, and the seed loader resolves natural keys only for lookup / master_detail fields — everything else is stored verbatim. The target record's id is a nanoid that does not exist until first boot, so there is no value an author can write.
Using the filter the shipped console bundle actually issues — sys_activity, { $filter: { object_name, record_id }, $orderby: { timestamp: 'desc' } }:
filter { object_name: 'crm_lead', record_id: 'tgIjpNhjlfmWU8YF' } → 0 rows (the real lead id)
filter { object_name: 'crm_lead', record_id: 'Lisa Thompson' } → 3 rows (the literal natural key)
Why this is a platform gap and not an app workaround
The app-side routes are all worse than the gap:
- A hook or flow that writes the pointer row at bootstrap would be a second producer of interaction rows alongside the
log_call / log_meeting / send_email action bodies — every action-created interaction would get two rows. That is the two-producer conflict hotcrm#702 already ruled against on crm_forecast. - A post-boot script driving the shipped actions over HTTP works, but then
demo:reset && dev alone still shows an empty tab — the demo is no longer self-contained, which is the whole point of seed data. - Widening the consumer (having the timeline also match on
record_label) is a tolerant-consumer fix for a producer-side gap, and would make the feed match unrelated records that happen to share a label.
The general rule is already settled and correct — a seed cannot name an id that does not exist yet — and reference resolution is its one sanctioned exemption. The gap is that the ActivityPointer pair is excluded from that exemption purely because it is typed as text.
Possible shapes (not a recommendation — needs a decision)
- Make the pointer pair resolvable in seeds. Teach the loader that
object_name + record_id (and source_object + source_id) form a polymorphic reference, so record_id accepts the target's natural key and resolves it against the object named in the sibling column. Declared = enforced; no consumer change. - A polymorphic reference field type that
sys_activity adopts, making the pair a first-class relationship the loader already knows how to resolve — larger, but it also gives the pair referential integrity, which it has none of today. - Refuse the write instead of accepting it. If seeding an ActivityPointer is not intended to be supported, the loader should reject a seed whose unresolvable text column is a declared pointer target, rather than storing a literal that silently renders nothing. This is the cheap half and is worth doing regardless of 1 or 2 — the current behaviour is a success receipt for configuration that does nothing.
Adjacent facts worth carrying into the decision
sys_activity is lifecycle.class: 'telemetry' with retention: { maxAge: '14d' } and day-shard rotation (sys_activity__r<YYYYMMDD> behind a view). Even with addressing solved, seeded demo history older than a fortnight is reaped by design — so "seedable timeline" may need a retention answer too, or demo rows need to stay inside the window.sys_activity declares no owner_id / organization_id, so ownership scoping is not an obstacle here — the seeded rows read back fine over REST as the dev admin.
Downstream
hotcrm shipped a guard rather than a workaround — objectstack-ai/hotcrm#1260 holds seeds inside the app's own object graph, with this measurement recorded in the test comment. If this card lands, that guard should be deleted deliberately.
Summary
sys_activityrows are the only thing therecord:activitytimeline renders, and a packaged app cannot ship a single usable one. Seeds load the rows happily — every gate reports success — but the rows attach to no record, so the timeline stays empty. The ActivityPointer model (ADR-0052 §5) is documented as the queryable drill from a one-line summary to the rich entity, and today no app can demonstrate it out of the box.Found while implementing objectstack-ai/hotcrm#1258 (seed demo interactions so the lead Activity tab shows something). Measured against 17.1.0.
What was measured
Three
type: 'completed'rows were seeded for a demo lead, then read back from the running server.The write half works.
buildis silent, the boot banner counts the rows (Seeds: … 345 rows, up from 342), and every column lands — readonly included, since seeds write under{ isSystem: true }andsys_activitydeclares every fieldreadonly.mode: 'upsert'is accepted despitemanagedBy: 'append-only';celtemporals resolve.The addressing half does not.
sys_activity.record_idisField.text(), not a lookup, and the seed loader resolves natural keys only forlookup/master_detailfields — everything else is stored verbatim. The target record's id is a nanoid that does not exist until first boot, so there is no value an author can write.Using the filter the shipped console bundle actually issues —
sys_activity, { $filter: { object_name, record_id }, $orderby: { timestamp: 'desc' } }:Why this is a platform gap and not an app workaround
The app-side routes are all worse than the gap:
log_call/log_meeting/send_emailaction bodies — every action-created interaction would get two rows. That is the two-producer conflict hotcrm#702 already ruled against oncrm_forecast.demo:reset && devalone still shows an empty tab — the demo is no longer self-contained, which is the whole point of seed data.record_label) is a tolerant-consumer fix for a producer-side gap, and would make the feed match unrelated records that happen to share a label.The general rule is already settled and correct — a seed cannot name an id that does not exist yet — and reference resolution is its one sanctioned exemption. The gap is that the ActivityPointer pair is excluded from that exemption purely because it is typed as text.
Possible shapes (not a recommendation — needs a decision)
object_name+record_id(andsource_object+source_id) form a polymorphic reference, sorecord_idaccepts the target's natural key and resolves it against the object named in the sibling column. Declared = enforced; no consumer change.sys_activityadopts, making the pair a first-class relationship the loader already knows how to resolve — larger, but it also gives the pair referential integrity, which it has none of today.Adjacent facts worth carrying into the decision
sys_activityislifecycle.class: 'telemetry'withretention: { maxAge: '14d' }and day-shard rotation (sys_activity__r<YYYYMMDD>behind a view). Even with addressing solved, seeded demo history older than a fortnight is reaped by design — so "seedable timeline" may need a retention answer too, or demo rows need to stay inside the window.sys_activitydeclares noowner_id/organization_id, so ownership scoping is not an obstacle here — the seeded rows read back fine over REST as the dev admin.Downstream
hotcrm shipped a guard rather than a workaround — objectstack-ai/hotcrm#1260 holds seeds inside the app's own object graph, with this measurement recorded in the test comment. If this card lands, that guard should be deleted deliberately.