Skip to main content
Backside is bitemporal. Every write to a tracked entity lands a row in a *_history table in the same transaction, and every read can be pinned to a past moment — without the caller needing to know anything about partitions, GIST indexes, or RLS policies. There are two independent time axes:
  • System time (as_of) — when did we learn the fact? This is what moved when a write happened.
  • Valid time (valid_at) — when was the fact true in the world? Useful when a correction backfills an event: the write (system time) happens now, but the fact was true yesterday (valid time).
The common case is valid_at = as_of — a single axis. Set both when you specifically want to decouple them.

What’s tracked

Four wisdom entities carry full bitemporal history: contacts, deals, notes, tasks. Each exposes the same read surface. Tasks are unitemporal — only valid time, no system time. Their as_of flows into the valid-time axis; valid_at is format-validated but ignored for data narrowing. Task responses carry only valid_from / valid_to in the _temporal block.

Single-moment reads

Add ?as_of= (RFC 3339) to any wisdom-entity GET or list endpoint.
GET /api/v1/contacts/{id}?as_of=2025-07-15T00:00:00Z
GET /api/v1/deals?as_of=2025-07-15T00:00:00Z&stage_id=...
Every historical response carries a _temporal block:
{
  "id": "...",
  "display_name": "Ellen MacGregor",
  "_temporal": {
    "as_of": "2025-07-15T00:00:00Z",
    "valid_at": "2025-07-15T00:00:00Z",
    "sys_from": "2025-07-10T14:32:00Z",
    "sys_to": "2025-08-22T09:15:00Z",
    "captured_by": "<member-uuid>",
    "capture_reason": "user_edit"
  }
}
SDK consumers can ignore _temporal or surface it for audit UX. List-endpoint caveats: filters that join non-history tables (full-text search q, tag / label names, stage-type, priority-key sort) return 400 when combined with as_of. The historical list otherwise mirrors the current-state one.

Full version history

GET /api/v1/contacts/{id}/history?from=&to=&cursor=&limit=
Returns every version of the entity, newest-first. Both window bounds are optional. Results paginate with an opaque cursor.
{
  "versions": [ { "...": "...", "_temporal": { "..." } } ],
  "next_cursor": "eyJzeXNfZnJvbSI6IjIwMjUtMDctMTBUMTQ6MzI6MDBaIiwiY29ycmVsYXRpb25faWQiOiIuLi4ifQ"
}
Use the route /deals/{id}/version-history for deals (the plain /history route there is legacy stage-change history and returns a different shape). Contacts, notes, and tasks use /history.
Cursors are opaque. Don’t parse them; always round-trip the next_cursor value unchanged.

Diff between two moments

GET /api/v1/contacts/{id}/diff?from=2025-06-01T00:00:00Z&to=2025-09-01T00:00:00Z
Returns the field-level delta:
{
  "from": "2025-06-01T00:00:00Z",
  "to": "2025-09-01T00:00:00Z",
  "changes": {
    "display_name": { "from": "E. MacGregor", "to": "Ellen MacGregor" },
    "relationship_class": { "from": "other", "to": "customer" }
  },
  "change_count": 2,
  "intermediate_versions": 4
}
intermediate_versions counts every history row in the window — use it to decide whether to follow up with /timeline for the play-by-play. Edge cases:
  • Entity existed at from but not at to"deleted_in_window": true.
  • Entity didn’t exist at from but did at to"created_in_window": true.
  • Entity didn’t exist at either → 404.

Timeline

GET /api/v1/contacts/{id}/timeline?from=&to=&fields=display_name,relationship_class
Walks adjacent history versions in the window and emits one event per changed field per version:
{
  "from": "2025-06-01T00:00:00Z",
  "to": "2025-09-01T00:00:00Z",
  "events": [
    {
      "at": "2025-07-10T14:32:00Z",
      "field": "display_name",
      "from": "E. MacGregor",
      "to": "Ellen MacGregor",
      "actor": "<member-uuid>",
      "correlation_id": "<request-uuid>"
    }
  ]
}
The fields parameter is a comma-separated filter — omit it to get every field.

Retention

Each tenant has a retention window (Free 30d, Pro 90d, Scale 365d, Enterprise forever, overridable on tenant_configs). Reads beyond the retention cutoff return 410 Gone rather than empty data so callers can tell the difference between “no such entity at that moment” and “that moment is no longer available.”
{
  "error": {
    "code": "gone",
    "message": "as_of 2023-01-01T00:00:00+00:00 is before this tenant's retention cutoff for contacts (90 days). Older history has been pruned."
  }
}

MCP

The context_for_person composite MCP tool accepts as_of and valid_at and returns the contact block as of that moment. Related-entity fan-out (interactions / tasks / notes / relationships) stays current-state at v0.1. Full composite temporal tooling (trajectory, cohort_drift, relationship_signals, etc.) is in a follow-up release.

What’s not covered (v0.1 gaps)

  • Related-entity fan-out on as-of reads. Historical channels, tags, labels, deal_contacts, and backlinks read the current-state tables; only the main row is historical.
  • Historical full-text search. search_vector is generated on the main table only, so q with as_of returns 400.
  • Historical pipeline / stage / project names. These joins stay current-state. If a pipeline was renamed, as-of deal responses show the current name.
Follow-ups are tracked in docs/tasks/temporal-rls-and-pool.md §3.3.