Context
This is the feature that justifies the module, and it is the one thing in the suite that competitors structurally cannot copy. A loyalty tool sees points. An email platform sees sends. A CRM sees notes. Only a system where all three share acorrelation_id (DEC-E7) can show the causal chain rather
than three lists sorted by date.
It is a projection, not a table. Materializing it would mean a fifth copy of data that already
exists, kept in sync by a job that will drift. It merges live from the sources at read time.
The permission trap is real and worth stating: a joined view is exactly where a permission gets
forgotten. An agent without core.contacts.read_national_id must see a masked one here too, and an
agent without loyalty.ledger.read must not learn balances through the timeline.
Scope (normative)
- A merge-sorted read over:
core.tracked_events,loyalty.ledger_transactions,messaging.sendswith status,crm.notesandcrm.activities. - Cursor pagination by
occurred_at. - Filtering by source and by date range.
- Chain rendering: entries sharing a
correlation_idgrouped as one causal sequence. - Permission filtering per source.
Non-scope (normative)
- Materializing the timeline. It is computed at read time, always.
- Cross-contact reporting. This is one contact’s history.
- Editing anything from the timeline. It is a read surface; actions happen in their own module.
- Real-time streaming — F1b if asked for.
Behaviour (normative)
- The timeline is computed at read time from its sources. There is no timeline table, no materialized view, and no sync job.
- Each source is queried with its partition-key predicate. The date range is therefore required, not defaulted, and the API says so — a timeline without one is a full scan of the largest tables in the platform.
- Entries are merge-sorted by
occurred_atdescending, with cursor pagination. Sources are queried in parallel and merged, never fetched fully and then sorted. - Every source is permission-filtered independently. A reader without a source’s permission does not see its entries, and does not learn they exist from a gap or a count.
- Sensitive fields are masked according to the reader’s permissions, exactly as in the owning module (DEC-A4). The timeline never becomes the loophole.
- Entries sharing a
correlation_idare grouped as one chain with their causal order preserved. That grouping is the feature; a flat list sorted by date is what every other tool already offers. - Cross-module reads go through each module’s public read path, never its tables. The boundary holds even for a read (ADR-022’s discipline, applied here).
- An unavailable source degrades gracefully: the timeline renders the rest and says which source is missing. A messaging outage should not blank a support agent’s screen.
Data (normative)
No tables. This feature owns a projection and no storage — which is the whole point.API (normative)
The single permission gates access to the timeline; each source is filtered by its own permission
on top. Holding
crm.timeline.read alone shows notes and activities and nothing else.
Events (normative)
None. A read surface emits nothing.Acceptance criteria (normative)
- First page p95 <1 s for a contact with 5 000 events across all sources.
- A request without a date range is rejected with a typed error.
- Every source query carries its partition-key predicate — verified by capturing the queries.
- A reader without a source’s permission sees none of its entries and cannot infer them from counts or gaps.
- A national ID appears masked for a reader without
core.contacts.read_national_id. - Entries sharing a
correlation_idrender as one chain in causal order — the purchase → points → email → open → redemption case, end to end. - A source returning an error degrades gracefully and names the missing source.
- Cursor pagination returns each entry exactly once across pages, with no duplicates at boundaries.
- Negative: no cross-module table is read directly, enforced by dependency-cruiser.
Execution
Single slice, synchronous command. Sources queried in parallel with a per-source timeout; merge and permission filtering inbackend/api.