Lifecycle
A lifecycle describes the stages a record goes through and when it drops out of normal view. The common case is soft delete: deleting a todo hides it everywhere at once but keeps it for 30 days, so it can be investigated or restored, and then removes it for good.
type TodoTitle = Text { max_length: 200}entity Todo { id: Uuid title: TodoTitle deleted_at: Instant? lifecycle: { visible: deleted_at == none // which records normal reads can see transitions: { delete: { // a named, allowed stage change when: deleted_at == none // only allowed when this is true set: { deleted_at: clock.now // what the change sets } } } purge: { // permanent deletion of hidden records after: 30d // how long a hidden record is kept since: deleted_at // counted from when it was hidden } }}
A lifecycle has three parts:
visible:says which records normal reads can see. Every read, list and count leaves out records that aren't visible, and reading one byidreportsnot_found. Nothing else has to remember to filter them.transitions:are named, allowed stage changes, and the only way to change the fields a lifecycle owns. Each has a name, an optionalwhen:that must be true for it to run, and a fixedset:saying what it changes. The generated operationTodo.delete(todo_id)runs thedeletetransition.purge:deletes hidden records for good once they have been hidden forafter:, counted from thesince:field. It runs in small, audited batches. Code can't hard-delete a record that has a lifecycle.
visible: and when: are conditions on the record's own fields. They may
use comparisons (==, !=, <, <=, >, >=), and, or, none
checks and clock.now, so scheduled publishing works. A starting value
that Jadpo sets goes in initial:; here every new story starts as a
draft:
type HeadlineText = Text { max_length: 120}type ReviewStage = Enum { draft in_review approved}entity Story { id: Uuid headline: HeadlineText stage: ReviewStage published_at: Instant? lifecycle: { visible: published_at != none and published_at <= clock.now initial: { stage: ReviewStage.draft // every new story starts as a draft } transitions: { submit: { when: stage == ReviewStage.draft set: { stage: ReviewStage.in_review } } } }}
The conditions can't mention other records or who is asking. That is Policy, and keeping lifecycle conditions this simple lets them become database indexes.
The details:
- Bare fields. A lifecycle's conditions name the record's fields bare,
as above (
deleted_at, notTodo.deleted_at); a qualified one isdata.lifecycle_condition, with the fix.inandnotaren't among the operators; the opposite comparison says the same. - What a lifecycle owns. Every field a transition sets, and every
field
initial:gives, changes only through the transitions. Acreate,updateor patch that gives one isdata.lifecycle_owned_field(Reads and writes). A new record gets itsinitial:value, ornone; so a required field a transition sets needs aninitial:value (data.lifecycle_initial_required). Values inset:andinitial:are fixed: literals, enum values,noneandclock.now. - Delete.
Todo.deleteruns the lifecycle'sdeletetransition, which changes the record and keeps it. An entity with a lifecycle but nodeletetransition can't be deleted by code at all. Neither can a record whose removal would cascade to one with a lifecycle (data.lifecycle_hard_delete). Only purge removes such records. - Transitions see visible records only. A transition is an update, so
it reaches a record only while
visible:holds. A hidden record is the entity's not-found failure, and restoring one needs a decision of its own. Awhen:that asks only whatvisible:asks can't refuse a record that is there. Any otherwhen:can, and its callers map that refusal inconflict:(Conflicts). - How long purge waits.
after:takes a duration written in place (30d) or aDurationsetting (config.soft_delete_retention, Configuration and time), whose value is the one the app started with. A record is purged oncevisible:doesn't hold and itssince:field is older than the run's time lessafter:. - How purge deletes. Each batch deletes at most 500 records, oldest
first, in its own transaction. References that
cascadeand thosecleareddo what they do for any delete. A record that arestrictedreference holds stays until nothing holds it, and never stops the rest going. That includes a hold through any chain of cascades, even one that comes back to its own entity (a folder inside a folder), followed as deep as the data goes. - Who runs purge. Purge is Jadpo's own operation, outside every action and policy. The runtime's purge entry point runs it, and the built-in hourly purge schedule calls it (The purge schedule). Each run reports how many records each batch deleted, which the schedule logs.
Markdown for agents: /docs/lifecycle.md.Built from Jadpo main at a707efb.