Point-in-time content API (compliance cluster)
Serve a published document as it was on a past date — a compliance/audit superpower for regulated content ("what did our published guidance say on 2026-03-01, provably"). First of the compliance cluster (#338; see also #352 governance dashboard, #356 tamper-evident + consent).
Using it
Add ?as_of=<date> to the headless delivery endpoint:
GET /api/content/post/my-post?as_of=2026-03-01
GET /api/content/post/my-post?as_of=2026-03-01T09:00:00Z&surface=webas_of accepts a full ISO 8601 datetime, or a bare date (treated as the end
of that day, UTC). It serves the same surfaces as live delivery (json
default, json_ld, web), plus response headers:
x-kiln-as-of— the moment requested.x-kiln-published-at— the effective publish time of the version served.
400 invalid_as_of for an unparseable date, and two distinct 404s:
not_published— nothing had been published by that moment.withdrawn— it had been published, but was unpublished or archived beforeas_ofand not republished by then. See Withdrawn content.not_public— it was published, but not to an anonymous reader: its audience atas_ofwas not public. See Who can read history.
A passphrase-locked document (#496) takes the same grant as live delivery
(x-kiln-unlock or ?unlock=), and a snapshot read with one is served
private, no-store — never public — for the same reason.
How it works
KilnCMS.Firing.PointInTime:
- Finds the last state transition — publish or unpublish/archive — at or
before
as_of(PaperTrail versions are tagged with their action name). An unpublish means the document was dark then, and the read stops there. - Replays the
:changes_onlyversion history up to that version to reconstruct the full published state (the same mergeRestoreVersionuses). - Re-fires that state through
KilnCMS.Firing.Enginein:previewmode — no DB write, no cache — producing the historical per-surface artifacts.
Because it reconstructs from immutable version history and re-fires with the same engine as live delivery, the historical artifact is faithful to what was actually published, and drafts/edits made after that publish never leak into it.
The collection view (#338 phase 2, shipped)
"What was published on this site on date X?" —
GET /api/content/:type?as_of=2026-03-01 # REST
{ contentAsOf(type: "post", asOf: $t) { … } } # GraphQL twinIndex entries (slug, title, published_at, href to the per-document
snapshot) reconstructed from version history: a document counts as published
iff its last publish/unpublish transition ≤ as_of was a publish, and its
title/slug are replayed to that publish (a later rename doesn't leak in).
Unlike the single-document read, the index respects unpublish — a list
that included since-removed content would misrepresent the site as it stood.
Bounded (limit, default 100, max 500) — the last-transition scan runs as one
DISTINCT ON SQL pass, so cost scales with matching documents, never with
total publish history — and results are server-cached for 5 minutes. Content
whose history predates version tracking can't be reconstructed and is omitted.
Who can read history
?as_of= is on the unauthenticated API and its answers are CDN-cacheable, so
it applies every rule live delivery applies to who may read a document. The
feature is about when; it widens nothing else.
- The snapshot resolves the document through the live delivery read first
(published now,
:publicaudience, passphrase lock unless a grant is presented), then checks that it was public atas_of— the lastaudienceon its version history at or before that moment. A document that was members-only then and is public now answers404 not_publicfor dates it was gated; one that was public then and is gated now is not found at all. Edits to a published document land on the live row without a republish, so this is the audience atas_of, not at the last publish before it. - The collection index (REST and GraphQL) lists only documents public to an
anonymous reader both now (public audience, no passphrase lock) and
then. The rule takes no caller input, which is what lets the index keep
one server-cache slot per
{org, type, as_of, limit}; a locked document is never listed, grant or not. - The passphrase lock has no history. Its hash is deliberately kept out of version rows (so a bcrypt hash never outlives a rotation there), so the current lock is the only one there is and it applies to every date: locking a document hides it from the historical index for dates it was open too.
- A document whose history records no audience at all fails closed.
Version tracking records every attribute on create, defaults included, so
this only affects history older than the
audiencecolumn itself.
Dynamic (D17) types
Dynamic types are served too, under their public type name. They need no
special handling on the single-document read — the storage resource comes from
the resolved record rather than the registry descriptor (a dynamic descriptor
carries resource: nil).
The collection index does need care: every dynamic type shares the
KilnCMS.CMS.Entry table, so an unscoped historical index for one type would
list every other type's documents. index/4 takes a :type_definition_id and
scopes on the source row, not the version changes map — a publish or
unpublish version carries no type_definition_id in its diff (only the create
version does), and the source row is the authority anyway since a document
can't change type.
Because the governance dashboard's "View as of then" links no longer have to be suppressed for dynamic entries, every publish row in a trail now links to its snapshot.
Withdrawn content
Publishing is not a one-way door: content gets taken down, corrected, and put back. If you ask what a document said on a date when it had been withdrawn, answering with the publish that preceded the withdrawal would assert the content was live at a moment it demonstrably wasn't — precisely the claim this endpoint exists to make truthfully, and the one a regulator would test.
So the single-document read resolves the last transition (publish or
unpublish/archive) rather than the last publish, and answers 404 withdrawn
inside a dark window. This matches what the collection index already did, so
the two views can't contradict each other: a document absent from the index for
a date can no longer serve content for that same date.
Republishing reopens the window — as_of after the republish serves the
republished state, as before.
Scope & later phases
- Single-document lookup is by the current record's id (resolved from the slug), so a since-deleted document's snapshot isn't reachable that way — use the collection index for discovery; id-addressable single-doc history is a later phase.
- Pairs with #356 (tamper-evident history + signed versions) and #352 (a governance dashboard that surfaces version diffs + point-in-time export).