lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
# Lifeloop Continuation Store

A thread-scoped key/blob store that Lifeloop maintains across client process
restarts. Clients write opaque state before a harness or client restart;
Lifeloop holds it; clients read it back after restart. Lifeloop never
interprets the stored blob.

The continuation store is a sibling primitive to the lifecycle event contract
(`LIFECYCLE-001`), not an extension of it. Events flow through the dispatch
path; cross-restart state flows through this store.

## Goals

- Provide cross-restart persistence for client-owned state without coupling
  Lifeloop to any client's data model.
- Generalize cross-restart client state storage into a primitive that any
  Lifeloop client can use. The greenfield CCD renewal flow uses this for its
  `renewal-state` blob.
- Keep ownership boundaries explicit: Lifeloop owns persistence; clients own
  interpretation, validation, schema, lifecycle, and redaction of stored
  blobs.
- Remain language-neutral. The wire surface is JSON over CLI/stdio; clients in
  any language with a process-spawn primitive can participate.

## Non-Goals

- Not a general-purpose KV store. Scoped to thread identity plus key
  namespace.
- Not durable beyond thread lifecycle. Threads have explicit closeout.
- Not a secret manager. Clients are responsible for blob redaction and
  sensitive-data handling.
- No replication, distribution, or cross-machine synchronization.
- Not a message queue. There are no notifications on writes; clients read on
  lifecycle events.
- No compare-and-swap semantics in v0.1. Last-writer-wins.

## Wire Surface

The store is exposed as a CLI subcommand group under `lifeloop continuation`.
Each subcommand reads or writes JSON on stdout for machine consumption; blob
bytes are passed through stdin or stdout to keep them opaque to Lifeloop.

| Command | Input | Output (stdout) | Exit |
|---|---|---|---|
| `lifeloop continuation put --thread <id> --key <name> [--client-id <id>] [--ttl-s <N>]` | blob bytes on stdin | `{"status":"ok","written_at_epoch_s":<u64>}` | `0` on success; non-zero with `{"error":"storage_failure","reason":"..."}` on I/O failure |
| `lifeloop continuation get --thread <id> --key <name> [--require-client-id <id>]` | (none) | blob bytes on stdout; metadata to stderr as `{"client_id":"...","written_at_epoch_s":<u64>,"ttl_s":<u64-or-null>}` | `0` on hit; non-zero with `{"error":"not_found"}` on miss or expiry; non-zero with `{"error":"client_id_mismatch"}` when `--require-client-id` does not match; non-zero with `{"error":"storage_failure","reason":"..."}` on I/O failure |
| `lifeloop continuation drop --thread <id> --key <name> [--require-client-id <id>]` | (none) | `{"status":"ok"}` | `0` (idempotent — exits `0` even if absent); non-zero with `{"error":"client_id_mismatch"}` on `--require-client-id` mismatch; non-zero with `{"error":"storage_failure","reason":"..."}` on I/O failure |
| `lifeloop continuation list --thread <id>` | (none) | `{"thread":"...","keys":[{"key":"...","client_id":"...","written_at_epoch_s":<u64>,"ttl_s":<u64-or-null>}]}` | `0` on success (best-effort — see note below); non-zero with `{"error":"storage_failure","reason":"..."}` on directory-level I/O failure |
| `lifeloop continuation drop-thread --thread <id>` | (none) | `{"status":"ok","dropped_count":<u32>}` | `0` on success; non-zero with `{"error":"storage_failure","reason":"..."}` on I/O failure |

The Exit column above lists the I/O-failure envelope only. ALL commands
can additionally exit non-zero with `{"error":"invalid_identifier",...}`
when `--thread`, `--key`, `--client-id`, or `--require-client-id` is
malformed; `put` can additionally exit with
`{"error":"blob_too_large","max_bytes":<N>}`. See **Failure Modes**
below for the exhaustive envelope reference.

**`list` is best-effort.** A single malformed/oversized/unreadable entry
in the thread directory is SKIPPED (with a diagnostic JSON object
written to stderr) rather than aborting the listing — one corrupt blob
must not blind operators to the rest of the thread. Directory-level
I/O failures (e.g., the thread directory itself is unreadable) still
exit non-zero with `storage_failure`.

Blob bytes are opaque to Lifeloop. Lifeloop does not validate UTF-8, JSON,
length (beyond a configurable max), or any structure of the blob.

### Optional flags

| Flag | Applies to | Purpose |
|---|---|---|
| `--client-id <id>` | `put` | Records which client wrote the entry. Used by `list` output and `--require-client-id` filters. Optional; defaults to `unknown`. |
| `--ttl-s <N>` | `put` | After `N` seconds, reads return `not_found` and the entry is eligible for garbage collection. Optional; defaults to no TTL. |
| `--require-client-id <id>` | `get`, `drop` | If set, Lifeloop refuses the operation unless the entry's recorded `client_id` matches. Defensive against cross-client interference. Optional. |

## Storage Layout

Filesystem-backed under `$XDG_STATE_HOME/lifeloop/continuation/` (or the
platform equivalent for non-XDG systems):

```
$XDG_STATE_HOME/lifeloop/continuation/<thread-id>/<key>   # combined meta+blob file
```

Each entry is stored as **a single combined file** with the following
framing format:

```
[ 4 bytes: u32 big-endian META_LEN ][ META_LEN bytes: meta JSON ][ blob bytes... ]
```

This single-file layout exists because the pair-atomicity guarantee
("an interrupted `put` leaves either the prior state intact or no state
at all, never a partial pair") is unachievable with a two-file layout:
POSIX `rename(2)` is atomic per-path, so two renames cannot be made
atomic as a pair without external coordination (lock files, sentinel
markers, or directory-rename schemes that don't compose with the
spec's per-key path shape). Combining the pair into one file makes
the atomic operation a single `rename(2)`, which the POSIX standard
guarantees is atomic.

The CLI wire surface is unchanged: `get` reads the file, splits the
framing, and emits **blob bytes to stdout, meta JSON to stderr** —
preserving the spec's two-output presentation. Clients never observe
the combined-file framing.

Metadata schema (the JSON wrapped by the framing prefix):

```json
{
  "schema_version": "lifeloop.continuation.v0.1",
  "client_id": "ccd",
  "written_at_epoch_s": 1716385200,
  "ttl_s": 600
}
```

Where `ttl_s` is `null` when no TTL was set.

Writes are atomic: Lifeloop composes the framing in memory, writes
the combined bytes to a tempfile in the same parent directory, then
calls `rename(2)` to swap it into the final position. An interrupted
`put` leaves either the prior file (if any) intact or no file at all
— never a half-written framing.

`<thread-id>` and `<key>` are filesystem-path-safe identifiers. Lifeloop
rejects names containing path separators, leading dots, or non-portable
characters with `{"error":"invalid_identifier"}`. Blobs exceeding the
configured maximum size (default 16 MiB) are rejected with
`{"error":"blob_too_large","max_bytes":<N>}`.

## Ownership Boundary

Lifeloop owns:

- storage location, atomicity, concurrency safety;
- TTL expiry semantics and garbage collection;
- the CLI surface, JSON output shape, exit codes;
- identifier validation for `<thread-id>` and `<key>`;
- receipts and evidence of put/get/drop, when emitted under lifecycle events.

Clients own:

- blob contents and schema;
- key naming within their `client_id` namespace;
- validation of blob contents on read;
- security and redaction of blob contents;
- lifecycle decisions about when to put, get, and drop.

Lifeloop must NOT:

- parse, interpret, or validate blob contents;
- apply client-specific schema rules;
- decide *when* a write or read is appropriate based on event semantics
  (lifecycle events trigger client logic; client logic calls the store).

## Failure Modes

| Condition | Behavior |
|---|---|
| Disk full, permission denied, corrupt on-disk meta, or any filesystem I/O failure on **any** subcommand | Non-zero exit, `{"error":"storage_failure","reason":"..."}`. The `reason` field is a free-form operator diagnostic; machine consumers branch on `.error == "storage_failure"` rather than parsing it. |
| `get` on absent key | Non-zero exit, `{"error":"not_found"}` |
| `get` on TTL-expired entry | Non-zero exit, `{"error":"not_found"}`; entry eligible for garbage collection |
| Concurrent `put` to same `(thread, key)` | Last-writer-wins; no compare-and-swap in v0.1 |
| `drop` on absent key | Exit `0` (idempotent) |
| `--require-client-id` mismatch | Non-zero exit, `{"error":"client_id_mismatch"}` |
| Invalid `<thread-id>` or `<key>` | Non-zero exit, `{"error":"invalid_identifier"}` |
| Blob exceeds configured max size on `put` | Non-zero exit, `{"error":"blob_too_large","max_bytes":<N>}` |

## Garbage Collection

TTL expiry is enforced as a read-time shadow only:

- On `get`, expired entries return `not_found`.
- On `list`, expired entries are filtered out.

There is no deletion queue and no background sweep; expired entries remain
on disk until an explicit `drop`/`drop-thread` removes them. Clients must
not rely on any deletion of expired entries — the only guarantee is that
*reads* (`get`/`list`) see expired entries as absent.

## Relationship to Existing Renewal Status

`RenewalAutomationStatus` in `src/renewal_status.rs` belongs to the legacy
host-hook renewal bridge. The continuation store is the greenfield
cross-restart primitive:

| Concept | Future home |
|---|---|
| The opaque continuation token bytes | Continuation store under client-chosen key (`renewal-state` for CCD), written by the client |
| `pending_token_present` flag | Implied by `continuation get` exit code |
| State machine (`Idle`/`Pending`/`Fulfilled`/`Failed`/`Expired`) | Client-side library; Lifeloop has no opinion |
| `client_id`, `adapter_id`, timestamps | Continuation store metadata, or client-owned blob fields |
| `failure_class`, `retry_class` | Emitted as receipts via the lifecycle contract, not as persisted state |

This spec only mandates that the continuation store provides opaque
cross-restart persistence. CCD's renewal state machine owns the `renewal-state`
blob schema and lifecycle.

## Conformance

External spec-governance checks should cover:

- `continuation_subcommands` table mirroring the wire surface above;
- `continuation_metadata_fields` table for the `meta.json` schema;
- `continuation_error_codes` table for the JSON error names;
- `continuation_flags` table for the optional flags and their applicability.

Wire-shape tests in `tests/wire_contract.rs` must include:

- round-trip: `put` then `get` returns identical bytes;
- idempotency: `drop` on absent key exits `0`;
- TTL: read after expiry returns `not_found`;
- atomicity: an interrupted `put` leaves either prior state or no state,
  never a partial blob/metadata pair;
- concurrency: two concurrent `put`s produce one valid final state
  (last-writer-wins);
- identifier validation: rejects `<thread-id>` and `<key>` containing path
  separators, leading dots, or non-portable characters;
- client-id filter: `--require-client-id` rejects mismatched reads and
  drops without exposing blob bytes.

## Language Neutrality

The wire surface is intentionally CLI-based with JSON I/O so any client
language can participate. A Go client invoking `lifeloop continuation put`
via `exec.Command` and piping blob bytes through stdin observes the same
contract as a Rust client. Clients should not assume the existence of a
Rust-native API to this primitive; library bindings, if added, must remain
behind the CLI/JSON contract for cross-language parity.

## Implementation Status

Implemented. The store lives in `src/cli/continuation.rs` and is wired
under `lifeloop continuation` (`put` / `get` / `drop` / `list` /
`drop-thread`) at `src/main.rs`. The CCD lifeloop client is the first
consumer: its `renewal_intents` table and `renewal-state` blob protocol
(normative in CCD-LIFELOOP-CLIENT-001) round-trip through this primitive
for cross-restart renewal continuation. TTL expiry is read-time-shadow
only (see §"Garbage Collection") — no background sweep exists.