History & Point-in-Time
Read a record's audit history, and reconstruct what it looked like at an instant in the past.
Overview
Two read-only endpoints expose the audit trail (action_logs) as a first-class part of a record's API surface:
| Endpoint | Answers |
|---|---|
GET /api/v1/{resource}/{id}/history |
What has happened to this record, and who did it? |
GET /api/v1/{resource}/{id}/as_at?at=… |
What did this record look like at a given instant? |
Both are available for four resources: rentals, invoices, accounts, and catalogue_items.
Neither endpoint is a projection engine. as_at reconstructs state by folding audit diffs backwards over the current row — it is the supported public approximation of a full event-sourced projection, and it tells you, on every response, exactly how faithful the answer was. When a real projection engine lands, these endpoints remain the stable public contract.
Authorisation
Both endpoints require two grants:
- the resource's own read permission and ability (
rentals.view/rentals:read, and so on), and - the audit-trail grants
action-log.view/action-log:read.
A token scoped only to the resource cannot use these endpoints as a side door into the audit trail.
History
GET /api/v1/rentals/42/history?page=1&per_page=20
{
"history": [
{
"id": 9001,
"action": "rental.updated",
"actor_type": "user",
"actor_id": "7",
"actor_label": "Jo Bloggs",
"user_id": 7,
"user_name": "Jo Bloggs",
"old_values": { "status": "quotation" },
"new_values": { "status": "order" },
"via": "web",
"created_at": "2026-01-15T14:30:00+00:00"
}
],
"meta": { "total": 12, "per_page": 20, "page": 1 }
}
- Entries are newest first, offset-paginated like every other collection.
viais the surface the change came through (web,api,mcp,cli), lifted from the entry's metadata.- Soft-deleted records still answer. History outlives the record it describes. A
404means the id never existed, not that the record was deleted.
Point-in-time (as_at)
GET /api/v1/rentals/42/as_at?at=2026-01-01T00:00:00Z
at is required, must be a valid ISO 8601 timestamp, and must not be in the future (422 otherwise). If the record did not yet exist at that instant, the response is a 404 naming the earliest instant it can be evidenced at.
The not-created response uses the standard API error envelope:
HTTP/1.1 404 Not Found
{
"message": "No rental state exists for identifier 42 at 2026-01-01T00:00:00Z; it was first recorded at 2026-02-01T00:00:00Z.",
"code": "aggregate_not_created"
}
The response is the resource in its normal shape — the same DTO GET /api/v1/rentals/42 returns — plus a meta.reconstruction block:
{
"rental": { "id": 42, "status": "quotation", "…": "…" },
"meta": {
"reconstruction": {
"as_at": "2026-01-01T00:00:00+00:00",
"complete": true,
"uncovered_fields": [],
"unaudited_fields": ["internal_notes"],
"events_folded": 6,
"relations": "Only the record's own columns are reconstructed. …"
}
}
}
How the fold works
Starting from the record as it stands today, every audit entry newer than at is undone, newest first. For each key in an entry's new_values that also appears in its old_values, the attribute is reset to the old value. Order matters: walking newest→oldest is what stops an intermediate value from surviving when the same field moved twice.
Read meta.reconstruction before trusting the answer
| Field | Meaning |
|---|---|
complete |
true only when every folded diff was fully reversible. |
uncovered_fields |
Attributes a folded diff changed but could not reverse — the audit entry recorded a new value with no matching old value. These fields still hold their current value. |
unaudited_fields |
Fillable attributes that appear in no audit diff for this record at all. Presented as-is; the audit trail cannot prove they never moved. |
events_folded |
How many audit entries were applied in reverse. |
relations |
A fixed caveat, repeated on every response: only the record's own columns are reconstructed. |
Known limits
- Relationships are current, not historical. Included or lazily loaded relations reflect today's state. Only columns move.
- Type fidelity is the audit trail's fidelity. Values come back as the writer serialised them into JSONB — money stays an integer in minor units, datetimes come back as stored strings, JSON columns as decoded structures. They are re-hydrated through the model's casts, so scalar and JSON columns round-trip faithfully; anything the writer serialised lossily stays lossy.
- Nothing is persisted. The reconstructed record is built in memory and never saved.
MCP
The same two surfaces are exposed as first-class MCP tools, both read-only:
| Tool | Arguments |
|---|---|
get-entity-history |
resource, id, page, per_page |
get-entity-as-at |
resource, id, at |
resource is one of rentals, invoices, accounts, catalogue_items.