# 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.

```jadpo
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 by
  `id` reports `not_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 optional `when:`
  that must be true for it to run, and a fixed `set:` saying what it
  changes. The generated operation `Todo.delete(todo_id)` runs the
  `delete` transition.
- **`purge:`** deletes hidden records for good once they have been hidden
  for `after:`, counted from the `since:` 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:

```jadpo
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](policy.md), 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`, not `Todo.deleted_at`); a qualified one is
  `data.lifecycle_condition`, with the fix. `in` and `not` aren'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. A
  `create`, `update` or patch that gives one is
  `data.lifecycle_owned_field`
  ([Reads and writes](reads-and-writes.md)). A new record gets its
  `initial:` value, or `none`; so a required field a transition sets needs
  an `initial:` value (`data.lifecycle_initial_required`). Values in
  `set:` and `initial:` are fixed: literals, enum values, `none` and
  `clock.now`.
- **Delete.** `Todo.delete` runs the lifecycle's `delete` transition,
  which changes the record and keeps it. An entity with a lifecycle but no
  `delete` transition 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. A `when:` that asks only what `visible:` asks can't refuse a record
  that is there. Any other `when:` can, and its callers map that refusal
  in `conflict:` ([Conflicts](reads-and-writes.md#conflicts)).
- **How long purge waits.** `after:` takes a duration written in place
  (`30d`) or a `Duration` setting (`config.soft_delete_retention`,
  [Configuration and time](configuration-and-time.md)), whose value is
  the one the app started with. A record is purged once `visible:`
  doesn't hold and its `since:` field is older than the run's time less
  `after:`.
- **How purge deletes.** Each batch deletes at most 500 records, oldest
  first, in its own transaction. References that `cascade` and those
  `cleared` do what they do for any delete. A record that a `restricted`
  reference 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](schedules.md#the-purge-schedule)). Each
  run reports how many records each batch deleted, which the schedule
  logs.
