Skip to main content

Content lifecycles

Publishing answers is this live?. Lifecycles answer the two questions that come after: when does this stop being live, and when does someone need to read it again.

They are one axis — health — computed from four columns, and they are deliberately not part of the state machine. A product safety guide whose annual review lapsed is still, factually, published; a legal notice past its stated shelf life is still being served. Making those a state would mean the publishing workflow had to model editorial trust, and the two move independently.

The model

Every content type — compiled (Page, Post, anything from mix kiln.gen.content) and dynamic (the Entry tier) — carries:

FieldTypeMeaning
unpublish_attimestampThe embargo end. When it passes, the record expires.
expiry_action:unpublish | :archive | :flagWhat expiry does. Default :unpublish.
review_after_daysinteger, 1–1095Freshness cadence. nil (the default) means none.
last_reviewed_attimestampWhen a human last attested the content is still correct.

and computes:

CalculationMeaning
effective_review_after_daysThe cadence in force — the record's own, else its type's default.
due_atWhen it next needs re-reading. NULL means never.
health:fresh | :due_soon | :due | :overdue | :expired

All three are expression calculations, computed in Postgres on every read. That is the design decision, not an implementation detail: a stored health column is wrong for every row between the moment a deadline passes and the moment a sweep gets to it — which is precisely the window an editor is asking the question in. Computed in SQL it is right on every read, and it filters and sorts, so ?filter[health]=overdue is one WHERE, not a table scan in Elixir.

Expiry: one timestamp, three outcomes

There is no separate expires_at. unpublish_at is the expiry timestamp, and expiry_action decides what happens when it passes:

  • :unpublish (default) — back to draft, artifacts deleted, unpublish_at cleared. Recoverable; nothing is lost.
  • :archive — straight to :archived, same teardown. For content that should leave the working set rather than reappear in the draft list every quarter.
  • :flag — nothing happens to the row. It stays published and unpublish_at stays in the past, which is exactly what makes health read :expired from then on. For content that must not silently vanish from a live site but does need a human told its stated shelf life has run out.

The first two run as AshOban triggers on the same minute cron as scheduled publishing, partitioned in SQL by expiry_action so neither can pick up the other's rows — or a :flag row, which belongs to neither. :flag needs no worker at all: the signal is the calculation.

Freshness: the cadence and the attestation

due_at is the last attestation plus the cadence — or, for content published under a cadence but never yet reviewed, the publish plus the cadence. Nothing needs backfilling for that to work; a record with a cadence and no last_reviewed_at starts its clock at published_at.

health then reads, for published content:

expired    unpublish_at has passed
overdue    due_at passed more than 7 days ago
due        due_at passed within the last 7 days
due_soon   due_at falls within the next 7 days
fresh      everything else — including all unpublished content

Unpublished content is always :fresh. Only live content can mislead a reader.

The seven days is one constant used symmetrically on both sides of the deadline, rather than two arbitrary numbers.

Marking reviewed

mark_reviewed is its own action — CMS.mark_page_reviewed!/2, CMS.mark_post_reviewed!/2, CMS.mark_entry_reviewed!/2, or ContentTypes.transition(type, "mark_reviewed", record, opts) for generic dispatch. It takes no input and stamps last_reviewed_at to now.

last_reviewed_at is not in default_accept, so no ordinary save, API client, or automation can write it. That is the whole point: the column means "a human said this is still right", and a field an autosave could set would mean "someone had this open". The action name lands on the PaperTrail version (version_action_name: :mark_reviewed), so who attested, and when is answerable from history rather than inferred.

Version restore never writes any of the four lifecycle columns. They are reported in the diff — a cadence change is worth seeing — but restoring an old version must not move a deadline nobody re-set, and for last_reviewed_at it would forge an attestation outright. See KilnCMS.CMS.VersionFields.

Per-type defaults

TypeDefinition.default_review_after_days lets an operator say "every buying guide is re-read yearly" once, on the type, instead of on each of four hundred entries. An entry that sets its own review_after_days overrides it; nil on both means the type has no cadence.

It is resolved in effective_review_after_days, so raising a type's default moves every inheriting entry's due_at on the next read — no backfill.

This applies to the dynamic entry tier only. Compiled types have no TypeDefinition row to inherit from, so for a Page or a Post the effective cadence is the per-record one. The alternative considered and rejected was a site-wide default: a fallback that reaches across every type at once is the shape that silently re-enables a cadence a team deliberately cleared.

The editorial calendar

/editor/calendar plots everything time-bound in one org and one window:

LaneComes from
Scheduled publisha draft/in-review record's scheduled_at
Proposed publisha draft/in-review record's proposed_publish_at (see below)
Scheduled unpublish / archive / expireda published record's unpublish_at, split by expiry_action
Went livepublished_at
Review duethe due_at calculation
Task duean open Task's due_on
Release go-live / shippeda ContentRelease's scheduled_at and published_at

Nothing is stored. KilnCMS.CMS.Calendar derives every event from a column that already exists, so the calendar cannot disagree with the records it draws — and a materialized calendar table would need a write path on every one of those columns and be wrong between the write and the sweep.

Three views share one at anchor, so switching keeps your place:

  • Month — the planning grid. Chips are capped per day cell with a "+N more" overflow, so one busy Thursday cannot resize the row.
  • Week — seven tall columns with times, for when two things land on the same afternoon.
  • List — chronological, the accessible baseline, and the mobile view: the grids are hidden md:block, because seven columns on a phone is a horizontal scroll.

Filters (type, lane, health) are URL-persisted, so a filtered calendar is a link you can send:

/editor/calendar?view=list&at=2026-09-01&health=overdue

An unknown value in any filter shows everything rather than nothing — a hand-edited or stale URL should not render an empty calendar that looks like a calendar with nothing in it.

The page is live. Any write that moves something plotted here broadcasts on the org's calendar topic (KilnCMS.CMS.Changes.BroadcastCalendar) and every open calendar re-queries — whether the write came from another editor's session, the API, or a scheduler. :autosave deliberately does not broadcast: one per debounce would wake every open grid in the org every few seconds while one person types.

Every query runs as the signed-in editor under their org's tenant, so the calendar can only show what that person could already read one record at a time.

Rescheduling from the grid

On the month and week grids, a chip in a reschedulable lane can be dragged to another day, or moved with the arrow keys while focused — ←/→ by a day, ↑/↓ by a week, matching the grid's own geometry. Both paths do the same thing; the outcome is announced in an aria-live region, because a chip moving is not something a screen reader can otherwise report.

The day moves and the time of day does not: a 09:00 publish dragged to Thursday is a 09:00 Thursday publish.

LaneDrag moves
Scheduled publishscheduled_at
Scheduled unpublish / archive / expiredunpublish_at
Release go-livethe release's scheduled_at
Proposed publish— confirmed by an admin, not dragged (see below)
Went live, release shipped— history; nothing to reschedule
Task due— the chip carries the task's content id; reschedule from the task list
Review due— see below

Review-due chips do not drag, deliberately. due_at is derived from last_reviewed_at and the cadence, so dragging it could only write one of those two: moving last_reviewed_at forges an attestation, and changing review_after_days alters the cadence permanently in order to move a single deadline. Neither is what "give me another week" means. Those chips carry a Mark reviewed button instead — which resets the clock honestly — and it appears only when the health is :due, :overdue or :expired, because a review falling due next month is not asking for anything.

Two refusals, both returning an inline error and snapping the chip back:

  • Into the past. Uniform across lanes: a backwards drag is nearly always a slip, and every lane's past date means something abrupt — a publish that fires within the minute, an embargo end that takes a live page down now.
  • Not in the window. The move is looked up against the events the editor's calendar actually loaded. That is the authorization boundary as much as an ergonomic one: the window came from a policy-scoped, org-scoped read, so a socket cannot move a record it could not already see.

The write itself is then subject to the record's own policy, so an editor scoped by editable_types to other types cannot move what they cannot edit, and the calendar route is editor-gated, so a viewer never reaches it at all.

Turning staleness into work

Health tells you something has gone stale. The remediation loop is what puts it in somebody's queue.

The sweep

KilnCMS.CMS.HealthSweep runs daily (KILN_HEALTH_SWEEP_CRON, 07:30 by default — before the task digest, so a task it raises is in that morning's email rather than tomorrow's). It finds published content whose health is :overdue or :expired, emits [:kiln_cms, :lifecycle, :health_sweep] telemetry with the counts, and dispatches one automation event per record:

page.health_overdue
recipe.health_expired

A sweep exists at all because every other editorial trigger hangs off a write — something was published, returned to draft, assigned. Freshness lapses because time passed: nobody did anything, which is exactly the problem, so there is no write to hang it off. Something has to come looking.

The sweep changes nothing. It is a read plus an event. It does not touch the content, does not create tasks, and does not write health (which is a calculation and has nothing to store). What happens next is a rule the team configured, or nothing at all — which is the point: "overdue" means different things to a newsroom and to a reference library, and one hard-coded reaction would be wrong for one of them.

That also makes it safe to leave enabled everywhere. A site with no review cadences matches nothing.

:due and :due_soon deliberately do not fire. A trigger on "falls due next week" would send the same reminder every day for a week before the deadline, which is how a team learns to filter the reminders out.

The rule

:health_overdue and :health_expired are ordinary Automation triggers, so they are configured in the automation builder like any other, scoped by content type, and can be disabled. The :create_task reaction turns one into an editorial Task:

%{
  trigger_event: :health_overdue,
  content_type: "page",
  action: :create_task,
  config: %{
    "assignee_id" => "…",        # fallback when the author can't hold it
    "due_in_days" => 7,          # clamped to 1..365, default 7
    "note" => "Review due — {{title}}"
  }
}

Assignment goes to the content's author when they are still an editor — "the person who wrote it should re-read it" — and falls back to the rule's assignee_id otherwise, which is what makes the rule usable on imported content with no author, or content whose author has since left. If neither can hold a task, nothing is created and the sweep logs it; a scheduled job does not die over a misconfigured rule.

Idempotent per {content, kind}. The sweep re-fires every day a record stays overdue — that is what makes it a reminder rather than a one-shot notification that can be missed — so :create_task first asks whether an open :lifecycle_review task already exists on that content. Without that, a guide nobody has got to in a fortnight carries fourteen identical tasks, and the queue meant to surface the problem is the problem. Complete the task without actually re-reading the piece and the next sweep raises a fresh one, because the content is still overdue.

Lifecycle tasks are tagged kind: :lifecycle_review (manual ones are :manual), and they opt out of #501's auto-complete-on-publish: republishing the same stale document is exactly what the review exists to question.

On the governance dashboard

/editor/governance carries a Content health section: counts across :due_soon / :due / :overdue / :expired, the ten most urgent records, and a CSV export of all of them (GET /editor/governance/health.csv, admin-only).

It distinguishes two things a count alone cannot. A site where nothing carries a review cadence says so — because a panel rendering 0 overdue for a site that has never set one is stating something false in a reassuring voice, and "we checked, nothing is late" is the opposite fact from "we have never asked". Only once something is tracked does the panel report a clean bill.

The counts are recomputed per page load rather than stored, for the same reason health is a calculation: a stored count is wrong from the moment a deadline passes. It is cheap because the query filters on health in Postgres, so a site with forty thousand fresh pages reads none of them — the result set is the problem list, not the library.

Under the panel, Work queues link to the full lists in the content list, where they can be narrowed by type and saved as views:

  • Review due (/editor?health=due): due and overdue. Shown once something carries a cadence.
  • Never reviewed (/editor?reviewed=never): mark_reviewed has never run on the record. The panel's counts cannot answer this: a record without a cadence is always :fresh, and a cadenced record that was never reviewed counts from published_at, so it reads as an ordinary re-review.
  • No kind consent (/editor?consent_missing=<kind>), one per configured consent kind: records with no consent of that kind on this site. See editorial consent.

In the editor

The content editor's header carries a health pill next to the workflow state badge — the two are orthogonal, and an editor needs both at a glance: this document is Published and eight months past its review. A Mark reviewed button appears beside it only when the health is :due, :overdue or :expired.

It is a button of its own rather than something that rides on Save, for the same reason last_reviewed_at is not in default_accept: an editor who saves a typo fix has not re-read the piece.

Reading it

Health is a normal field on the existing reads — there is no lifecycle endpoint.

GET /api/json/pages?filter[health]=overdue&sort=due_at
{ pages(filter: {health: {eq: OVERDUE}}) { title health dueAt } }
CMS.Page
|> Ash.Query.filter(health == :overdue)
|> Ash.Query.sort(due_at: :asc)
|> Ash.read!(actor: editor)

Health inherits content's own read policy, so an anonymous consumer only ever computes it over published, unrestricted content — unpublished health never leaks.

Authorization

review_after_days, expiry_action and unpublish_at are ordinary accepted attributes on :update, so they are editor+admin writable exactly like scheduled_at. mark_reviewed is an update action and follows the same policy.

Proposed publish dates

Setting scheduled_at takes the same permission as publishing (an admin, or an editor on a site that lets editors publish). An editor without it can still ask for a date: proposed_publish_at (#1812). The calendar's "+" on a day sets it for them, and they can change it in the editor's schedule area. It publishes nothing: no scheduler reads it. It is drawn on the calendar in its own dashed "Proposed" lane, which does not drag, and shown on the content list's row, so a reviewer sees it next to the submission. Someone who may publish turns it into the schedule with Confirm date on that row (an ordinary write of scheduled_at, authorized like any other), or with Use this date in the editor followed by Save. Setting any scheduled_at, or publishing, clears the proposal (Changes.ClearProposedPublishAt).

proposed_publish_at is public? false, like the body's blocks. No read returns it, on the JSON:API, GraphQL or delivery surfaces. It is an accepted input on the content write actions, so an API client without publish rights can propose a date the same way (see the JSON:API guide). A version restore does not restore it. The expiry workers run under the AshOban interaction bypass, as scheduled publishing does.

  • docs/content-releases.md — coordinated multi-item go-live.
  • docs/governance-dashboard.md — the audit chain over what shipped when.
  • docs/automation.md — reacting to editorial events.