# Lifeloop Client Callback Protocol
A normative description of how Lifeloop delivers lifecycle events to clients
and how clients invoke Lifeloop commands. Defines transport modes, wire
framing, deadlines, validation rules, and failure semantics for the
inter-process boundary.
This spec is the IPC/RPC counterpart to the content specs:
- `LIFECYCLE-001` defines the **content** that crosses the boundary —
`CallbackRequest`, `CallbackResponse`, `DispatchEnvelope`, lifecycle event
vocabulary, manifest, receipts.
- `CONTINUATION-001` defines a sibling primitive (cross-restart blob
persistence). Clients invoke it through the reverse-direction pattern this
spec describes.
- `CALLBACK-001` (this spec) defines **how** those types travel between
Lifeloop and client processes.
## Goals
- Make the transport contract independent enough that a non-Rust client
(Go, Python, Node) can implement it with only a JSON parser and a
subprocess/stdio primitive.
- Cover both directions of inter-process traffic: Lifeloop → client (event
dispatch) and client → Lifeloop (command invocation).
- Specify the failure modes precisely enough that clients can write
conformance tests without reading Lifeloop's Rust source.
- Preserve the "library contract first" discipline from `AGENTS.md`: the
transport spec must not leak Rust-specific assumptions into the wire.
- Document the proposed evolution path to a long-lived stream mode without
committing to it as part of v0.1.
## Non-Goals
- Not a definition of the event vocabulary or wire types (see
`LIFECYCLE-001`).
- Not a transport for arbitrary inter-process communication; scoped to the
lifecycle callback contract.
- Not a guarantee of cross-machine or cross-network transport. The spec
assumes same-host IPC.
- Not a security boundary on its own. Authentication, authorization, and
process isolation are the harness's responsibility.
- Not a binary protocol. JSON over stdio is the wire format; gRPC,
protobuf, Cap'n Proto, and similar are out of scope.
## Transport Modes
Lifeloop's `CallbackInvoker` seam (`src/router/seams.rs`) admits multiple
delivery implementations behind a common interface. The wire-level
behavior of each mode is normatively specified below.
### A. In-process callbacks
Defined by `src/router/callbacks.rs`. The client implements
`CallbackInvoker` as a Rust trait directly; Lifeloop calls into client code
through dynamic dispatch in the same process. No serialization, no
subprocess spawn, no deadline beyond Rust's normal call stack.
This mode is the simplest and is used by Lifeloop's own integration tests
and by Rust-native clients embedded in the same binary as Lifeloop. It is
**not** part of the cross-language wire contract — Go and Python clients
cannot use this mode.
Conformance for in-process clients is the Rust trait. This spec does not
add normative requirements beyond that.
### B. Subprocess callbacks (v1 — current normative mode)
Defined by `src/router/subprocess.rs`. Lifeloop spawns a fresh child
process for each lifecycle event. The full round trip is:
1. Lifeloop validates the routing plan and rejects `receipt.emitted` events
before spawn (see §"Receipt.Emitted Notification Guard").
2. Lifeloop spawns the configured client binary with the configured args
and a clean stdio triple (stdin, stdout, stderr piped).
3. Lifeloop writes one `DispatchEnvelope` JSON document to the child's
stdin, then closes stdin.
4. The child writes one `CallbackResponse` JSON document to stdout, then
exits.
5. Lifeloop reads stdout to EOF, parses, and validates the response.
6. Lifeloop reaps the child. A child still running at the deadline is
killed and the invocation fails with the `Timeout` failure class.
The `DispatchEnvelope` is the wire shape defined in
`src/callback_contract.rs`. It carries one `CallbackRequest` plus zero or
more `PayloadEnvelope` bodies referenced by the request's `payload_refs`.
The routed `CallbackRequest` preserves its optional harness identity fields
and opaque `metadata` map through the subprocess boundary.
#### Wire framing
The current normative wire framing for subprocess callbacks is:
- **stdin (Lifeloop → client):** exactly one `DispatchEnvelope` JSON
document, followed by EOF (Lifeloop closes stdin).
- **stdout (client → Lifeloop):** exactly one `CallbackResponse` JSON
document. Lifeloop reads to EOF, so a trailing newline is optional but
recommended for line-buffered output.
- **stderr (client → Lifeloop, optional):** structured diagnostics. Lifeloop
captures stderr for inclusion in failure receipts when the call fails;
successful calls do not require stderr to be empty but should not abuse
it.
JSON shape must conform to the Serde definitions in
`src/callback_contract.rs`. Unknown fields are rejected on both sides
(`#[serde(deny_unknown_fields)]`); clients must use exactly the fields
defined in `LIFECYCLE-001`.
Newline framing is **not** part of v1: Lifeloop reads the entire stdout
buffer until EOF and parses as one JSON document. Multi-document framing
is reserved for stream mode (see §C).
#### Configuration
A subprocess invoker is configured with:
| `program` (required) | Absolute path to the client binary. |
| `args` (optional, ordered) | Arguments passed on every spawn. Lifeloop does not inject any args of its own. |
| `timeout` (required) | Total round-trip deadline (spawn → write → read → exit). |
Lifeloop does **not** provide a default timeout. The integrator must pick
a value that matches its lifecycle SLOs. Timeouts that are too short
manifest as spurious failure receipts; timeouts that are too long delay
harness-side hooks.
#### Exit codes
The client should exit `0` on success. Non-zero exit is treated as a
transport failure even if a valid `CallbackResponse` was written to stdout
first, because the response cannot be trusted when the client is also
signaling failure.
#### Failure classes
`SubprocessInvokerError` variants map deterministically onto the shared
`FailureClass` vocabulary defined by `LIFECYCLE-001` — the subprocess
transport introduces no new failure classes. The mapping is:
| `ReceiptEmittedRejected(RouteError)` | `InvalidRequest` | Notification event rejected before spawn. |
| `Spawn(std::io::Error)` | `TransportError` | Binary not found, permission denied, OS-level fork failure. |
| `WriteRequest(std::io::Error)` | `TransportError` | Failed to write the request JSON to the child's stdin. |
| `ReadResponse(std::io::Error)` | `TransportError` | Failed to read the child's stdout. |
| `NonZeroExit { code, stderr }` | `TransportError` | Child exited non-zero. |
| `SerializeRequest(serde_json::Error)` | `InternalError` | Cannot serialize the dispatch envelope — bug on the Lifeloop side, not the transport. |
| `ParseResponse(serde_json::Error)` | `InvalidRequest` | Child wrote bytes that did not parse as JSON (including the empty-stdout case). The response is malformed at the wire layer. |
| `InvalidResponse(ValidationError)` | `InvalidRequest` | JSON parsed cleanly but failed `CallbackResponse::validate` (unknown receipt status, missing required field, etc.). |
| `Timeout` | `Timeout` | Round trip exceeded the configured timeout; the child was killed. |
`LIFECYCLE-001` does not define an `InvalidResponse` failure class.
Wire-layer parse/validate failures on the response side map to
`InvalidRequest` per the existing failure-mapping function in
`src/router/subprocess.rs::failure_class_for_subprocess_error`. The
spec is descriptive of that function; updates to either must move in
lockstep.
### C. Stream callbacks (v2 — proposed evolution, not implemented)
A future evolution may upgrade the Lifeloop → client direction to a
long-lived stream over stdio, using JSON-RPC 2.0 framing:
- Lifeloop spawns the client once with a `--stream` flag (or equivalent
invocation signal).
- Lifeloop sends events as JSON-RPC notifications:
`{"jsonrpc":"2.0","method":"event/<lifecycle-event-kind>","params":{...}}`.
- For events expecting a reply, Lifeloop sends a JSON-RPC request with an
`id`; the client replies with a matching `{"jsonrpc":"2.0","id":...,"result":...}`
or `{"jsonrpc":"2.0","id":...,"error":{...}}`.
- The client can send JSON-RPC requests in the reverse direction over the
same pipe, removing the need for per-command subprocess invocation of
Lifeloop CLI (see §"Client-Initiated Commands").
The `params` / `result` shapes are the same `CallbackRequest` /
`CallbackResponse` Serde types from v1; only the framing changes.
Stream mode is **proposed, not implemented in v0.1**. Conformance for
v0.1 is only required against in-process and subprocess modes. This
section is normative for what v2 must look like when it ships; clients
implementing v2 ahead of Lifeloop must match this shape.
## Client-Initiated Commands (Reverse Direction)
For traffic in the client → Lifeloop direction in v0.1, clients invoke
the Lifeloop CLI as a subprocess. This is the symmetric pattern: just as
Lifeloop spawns clients, clients spawn `lifeloop` for commands.
The CLI surface that exposes client-callable primitives includes (but is
not limited to):
| `lifeloop continuation {put,get,drop,list,drop-thread}` | `CONTINUATION-001` | Cross-restart blob persistence |
The wire format for client-initiated commands is:
- **stdin (client → Lifeloop):** blob bytes when the command takes a blob
(e.g. `continuation put`); empty otherwise.
- **stdout (Lifeloop → client):** JSON document describing the result, or
blob bytes for read-style commands like `continuation get`. The
per-command spec defines which.
- **stderr (Lifeloop → client):** structured metadata or error payloads
the per-command spec defines.
- **exit code:** `0` on success; non-zero with a structured JSON error on
failure.
Stream mode (v2) replaces the subprocess invocation with JSON-RPC requests
in the reverse direction over the same pipe. The v0.1 CLI surface and the
v2 JSON-RPC surface MUST describe the same operations under the same
names; clients can detect the mode and route accordingly.
## Receipt.Emitted Notification Guard
`receipt.emitted` is a notification event in `LIFECYCLE-001`. It carries
evidence of a prior dispatch outcome but does **not** itself produce a
downstream callback. Implementations MUST reject `receipt.emitted` events
before any client invocation:
- In-process and subprocess transports reject in
`super::validate_receipt_eligible` (`src/router/failure_mapping.rs`).
- Stream mode MUST emit no `event/receipt.emitted` notifications to
clients in v2; the event is observable only via the receipt ledger.
Conformance tests must include the rejection-before-spawn behavior.
## Validation Rules That Cross the Boundary
Validation defined in `src/callback_contract.rs` is part of the wire
contract, not an implementation detail:
- `CallbackRequest::validate()` enforces:
- `schema_version` matches `SCHEMA_VERSION` (defined in `LIFECYCLE-001`);
- all required string fields are non-empty;
- `frame.*` events MUST carry a `frame_context`;
- `receipt.emitted` MUST NOT carry an `idempotency_key`.
- `CallbackResponse::validate()` enforces analogous rules on the response
side.
- `DispatchEnvelope::validate()` checks the envelope (the request plus
payload bodies).
Clients MAY enforce additional client-side validation (CCD-specific
thread binding, payload-budget rules, etc.) before acting on a request;
they MUST surface any client-side rejection as a `CallbackResponse` with
the appropriate failure class rather than by silently dropping the event.
## Conformance
External spec-governance checks should cover:
- `transport_modes` table covering in-process, subprocess (v1), and
stream (v2);
- `subprocess_wire_framing` table (stdin one-doc, stdout one-doc, EOF
semantics);
- `subprocess_failure_mapping` table mirroring §"Failure classes" above;
- `client_initiated_commands_registry` table listing the CLI groups that
participate (currently `continuation`, future entries added by sibling
specs);
- `receipt_emitted_guard` constant asserting the rejection-before-spawn
rule.
Wire-shape tests in `tests/wire_contract.rs` must include:
- subprocess round-trip: spawn fixture client, write envelope, read
response;
- subprocess `receipt.emitted` rejection: invoking with a
`receipt.emitted` plan returns `InvalidRequest` and never spawns;
- subprocess deadline: a fixture client that sleeps past the deadline is
killed and the invocation surfaces `Timeout`;
- subprocess non-zero exit: a fixture client that exits non-zero produces
a `TransportError` even if stdout had a valid response;
- subprocess malformed JSON: produces `InvalidResponse`;
- subprocess unknown-field rejection: a response with an unknown field is
rejected (Serde `deny_unknown_fields`).
A reference fixture client written in a non-Rust language (initially Go,
matching the planned client surface) SHOULD live under
`tests/fixtures/client-lang-neutral/` and be exercised in CI as proof
that the wire format is genuinely language-agnostic. The fixture's job
is to echo back a structured `CallbackResponse` for any input —
implementation simplicity is the point.
## Language Neutrality
The wire format is JSON over stdio plus CLI invocation. The minimum
client implementation surface is:
- a JSON parser and serializer matching the
`#[serde(deny_unknown_fields)]` discipline (unknown fields rejected
on both sides);
- a subprocess primitive that can spawn `lifeloop` and read its stdout;
- a way to read its own stdin to EOF and write its own stdout.
Any language with `os.exec`, `subprocess`, `Process.spawn`, or
equivalent meets this bar.
Lifeloop MUST NOT ship a client SDK that exposes operations not also
available over the CLI/JSON contract. Convenience bindings are allowed
but MUST be derivable from the wire contract; they cannot extend it.
## Relationship to Existing Implementation
This spec normalizes the contract already implemented in:
- `src/router/seams.rs` — the `CallbackInvoker` trait (transport
abstraction);
- `src/router/callbacks.rs` — in-process delivery;
- `src/router/subprocess.rs` — subprocess delivery (the v1 normative mode);
- `src/router/failure_mapping.rs` — the failure-class mapping table;
- `src/router/receipts.rs` — receipt synthesis on the Lifeloop side;
- `src/callback_contract.rs` — wire types and validation.
The spec adds normative weight to behavior that previously existed only
in module-level doc comments. Future changes to any of those files MUST
be reflected here and in the wire/conformance tests that prove the
lifecycle behavior.
## Implementation Status
In-process callbacks: implemented (`src/router/callbacks.rs`).
Subprocess callbacks (v1): implemented (`src/router/subprocess.rs`). The
v1 wire contract described above is the current behavior; the spec is
descriptive, not prescriptive of new work for v0.1.
Stream callbacks (v2): proposed only. No implementation. The shape in
§C is the normative target for whoever implements it.
Client-initiated commands: the `lifeloop continuation` group is gated
on `CONTINUATION-001` implementation. The reverse-direction pattern
itself is documented here for the first time; existing clients
implicitly use it whenever they invoke `lifeloop` as a subprocess.
Conformance tests: the drift-detection and wire-shape tables listed in
§"Conformance" are not yet populated. Adding them is part of promoting
this spec from `draft` to `accepted`.