# Specs

Normative target schemas for the Lifeloop wire contract.

## Files

- [`lifecycle-contract`](lifecycle-contract/body.md) — the lifecycle event
  vocabulary, adapter manifest, capability negotiation, opaque payload
  envelope, receipt schema, failure and retry classes, and the conformance
  expectations Lifeloop adapters and clients must meet.
- [`client-callback-protocol`](client-callback-protocol/body.md) — the IPC/RPC
  contract for delivering lifecycle events to clients and for
  client-initiated commands back to Lifeloop. Covers transport modes
  (in-process, subprocess, proposed stream), wire framing, deadlines,
  failure-class mapping, and the language-neutrality guarantee.
- [`continuation-store`](continuation-store/body.md) — the thread-scoped
  key/blob persistence primitive used by clients for state that must
  survive process death (renewal tokens, etc.). Lifeloop owns persistence;
  clients own interpretation.

The Markdown file is the human-readable narrative. Lifeloop's in-repo
tests prove lifecycle wire behavior, conformance fixtures, and serde
shape. Cross-spec alignment and vocabulary governance belong in the
external Pituitary/Cortex toolchain rather than in hand-copied Rust
constants inside this product crate.

## Contract Tests

`tests/wire_contract.rs` pins the current JSON wire shapes and catches
accidental drift on already-shipped lifecycle types. Conformance tests
and fixtures under `tests/conformance/` exercise the externally visible
adapter/client contract. When a lifecycle spec change affects shipped
wire data, update the implementation, wire tests, conformance fixtures,
and any required tombstone in the same change.

## Field-presence taxonomy

Every field in a wire type is exactly one of three things. Pick the right
one when adding a new field; the wrong choice silently corrupts the
contract.

| Presence | Wire shape | Inbound rule | Serde shape |
|---|---|---|---|
| `required` | Key MUST be present, value MUST NOT be `null`. | Missing key or `null` value rejected. | `field: T` (no `Option`, no `default`). |
| `required_nullable` | Key MUST be present. Value MAY be `null`. | Missing key rejected. `null` accepted. | `field: Option<T>`, **no** `skip_serializing_if`, plus a parent-level deserialize check that asserts the key was present (see `LifecycleReceipt::REQUIRED_NULLABLE_FIELDS` and the custom `Deserialize` impl in `src/lib.rs`). |
| `optional` | Key MAY be omitted entirely. | Missing key defaults to `None`/empty. | `field: Option<T>` with `#[serde(skip_serializing_if = "Option::is_none")]`, or `Vec<T>`/`Map<...>` with `#[serde(default, skip_serializing_if = "...is_empty")]`. |

The trap is that `serde`'s default behavior treats `Option<T>` as
`required_nullable` *on the outbound side* (it serializes as `null` when
`None`) but as `optional` *on the inbound side* (a missing key
deserializes as `None`). The two halves do not match without help. For
`required_nullable` fields, the outbound half is fine — the inbound half
needs the parent-level intercept documented above.

If a contract reviewer says "this is required and nullable" and the code
just has `field: Option<T>` with no `skip_serializing_if` and no inbound
intercept, the type is silently `optional` on the wire even though both
sides claim required-nullable. `tests/wire_contract.rs` exercises the
outbound and inbound halves
(`lifecycle_receipt_rejects_each_missing_required_nullable_field` and
`lifecycle_receipt_accepts_explicit_null_for_required_nullable_fields`).
A new `required_nullable` field must extend the required-nullable wire tests
in the same commit. A new `required` or `optional` field must extend the
affected wire-shape test.
