> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Timeline 360

> After this ships, a support agent sees "bought on Tuesday → rule awarded 450 points → we emailed → they opened it → redeemed on Thursday" as one chain, on one screen.

## 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 a `correlation_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.sends` with status, `crm.notes` and `crm.activities`.
* Cursor pagination by `occurred_at`.
* Filtering by source and by date range.
* Chain rendering: entries sharing a `correlation_id` grouped 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)*

1. The timeline is **computed at read time** from its sources. There is no timeline table, no
   materialized view, and no sync job.
2. 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.
3. Entries are merge-sorted by `occurred_at` descending, with cursor pagination. Sources are queried
   in parallel and merged, never fetched fully and then sorted.
4. 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.
5. Sensitive fields are masked according to the reader's permissions, **exactly as in the owning
   module** (DEC-A4). The timeline never becomes the loophole.
6. Entries sharing a `correlation_id` are **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.
7. 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).
8. 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)*

| Endpoint                             | Class      | Permission          | Budget                                    |
| ------------------------------------ | ---------- | ------------------- | ----------------------------------------- |
| `GET /v1/crm/contacts/{id}/timeline` | Management | `crm.timeline.read` | p95 \<1 s first page, date range required |

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)*

1. First page p95 \<1 s for a contact with 5 000 events across all sources.
2. A request without a date range is rejected with a typed error.
3. Every source query carries its partition-key predicate — verified by capturing the queries.
4. A reader without a source's permission sees none of its entries and cannot infer them from counts
   or gaps.
5. A national ID appears masked for a reader without `core.contacts.read_national_id`.
6. Entries sharing a `correlation_id` render as one chain in causal order — the purchase → points →
   email → open → redemption case, end to end.
7. A source returning an error degrades gracefully and names the missing source.
8. Cursor pagination returns each entry exactly once across pages, with no duplicates at boundaries.
9. **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 in `backend/api`.

## Open questions

| # | Question                                                                          | Decides | By              |
| - | --------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Default date range when the caller supplies none — reject, or default to 90 days? | Daniel  | before approval |
| 2 | Per-source timeout before degrading — 300 ms?                                     | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
