lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
# 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.

The Markdown file is the human-readable narrative. Wire vocabularies are
re-encoded as Rust constants in `tests/spec_alignment.rs` for mechanical
drift detection. Do not paraphrase the spec when transcribing into code —
the constants must literally quote the spec's table values.

## Drift detection

`tests/spec_alignment.rs` is the spec ↔ code drift detector. Each spec
table (lifecycle events, integration modes, support states, adapter
roles, receipt statuses, failure classes, retry classes, placement
classes, requirement levels, negotiation outcomes, the failure → retry
default-mapping table, and the receipt / payload / manifest field
tables) has a paired test that fails when the Rust types diverge.

A few alignment tests pin values that the spec **narrative** mandates
without giving them their own table: `placement_outcomes`,
`frame_context`, and the `lifeloop.v0.2` schema-version label. Those
tests are flagged in the file as self-referential — they catch
implementation drift but cannot catch spec drift until the spec adds
authoritative tables. Tracked as follow-ups to #17.

The test is **not** a wire-shape pin. That role belongs to
`tests/wire_contract.rs`, which freezes the *current* implementation's
JSON shape and catches accidental drift on already-shipped types. The
two together cover both directions of risk — accidental change on the
wire (`wire_contract.rs`) and accidental staleness against the spec
(`spec_alignment.rs`).

When the spec moves: update the constants in `spec_alignment.rs`, watch
the failures it produces, and migrate the Rust types to match. Each
breaking step ships with a tombstone in `docs/tombstones/` per
[`docs/playbooks/schema-bump.md`](../playbooks/schema-bump.md).

When the implementation moves: rerun
`cargo test --test spec_alignment`. New `#[ignore]` annotations require a
companion tombstone or a named owning issue (#N) in the ignore reason —
never silently weaken an assertion to make CI green.

## 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/spec_alignment.rs` carries:

- `lifecycle_receipt_required_nullable_fields_present_when_none` (outbound
  half: keys still appear as `null`),
- `lifecycle_receipt_required_nullable_const_matches_spec` (the runtime
  intercept knows the same fields the spec calls `required_nullable`),

and `tests/wire_contract.rs` exercises the inbound half
(`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 all four tests in the same
commit. A new `required` or `optional` field needs only the
`*_fields_match_spec` assertion to be updated.