How handlers receive input data and emit output data via typed streams.
Writes are async and flow-controlled
Every data write is flow-controlled (one credit per CHUNK, see Flow control), so the handler API is asynchronous:
OutputStream::write,emit_list_item, andemit_cborareasync fn; they await when the receiver’s window is exhausted. Call them with.await.blocking_write/blocking_emit_list_itemserve non-async contexts (FFI threads,spawn_blockingclosures).StreamSender(the detached emitter) keeps its sync signature and acquires credit blockingly.log()/progress()stay sync and are never flow-controlled; progress must flow even when the data window is exhausted (L14).start_unbounded(is_sequence, meta)opens a stream with no length promise (L16); its STREAM_END omits chunk_count.InputStream::is_unbounded()reports the flag on the consuming side, and thecollect_*helpers refuse unbounded streams with a hard error, so consume them withrecv().finish(progress, message)declares the request’s terminal status, carried in the END frame’s terminal metadata (L5). Do NOT emit a trailing 100% progress LOG, because the END metadata is the final progress event.
InputStream
An InputStream represents a single named input stream arriving from the host. Each stream carries a media_urn identifying the data type (e.g., "media:document;ext=pdf", "media:enc=utf-8") and yields decoded CBOR values extracted from CHUNK frames.
Handlers never see raw frames, sequence numbers, checksums, or stream IDs. The runtime’s demux layer decodes CHUNK payloads, verifies checksums, and delivers clean CBOR values through the stream.
pub struct InputStream {
media_urn: String,
rx: mpsc::UnboundedReceiver<Result<ciborium::Value, StreamError>>,
}
Source: capdag-rs/src/bifaci/cartridge_runtime.rs.
Receiving data
Three consumption patterns:
Streaming: process values one at a time:
let mut stream = /* from InputPackage */;
while let Some(item) = stream.recv().await {
let value = item?;
// process each CBOR value
}
Accumulate bytes: collect everything into a byte buffer:
let bytes: Vec<u8> = stream.collect_bytes().await?;
collect_bytes() extracts the inner bytes from each Value::Bytes or Value::Text chunk and concatenates them. Non-byte CBOR types are CBOR-encoded before appending. Only use this when you know the stream is finite; infinite streams block forever.
Single value: expect exactly one chunk:
let value: ciborium::Value = stream.collect_value().await?;
Returns the first value and ignores the rest. Returns StreamError::Closed if the stream is empty.
InputPackage
An InputPackage bundles all input streams for a single request. Handlers get it by calling req.take_input() (which can only be called once; a second call returns an error to prevent double-consumption).
pub struct InputPackage {
rx: mpsc::UnboundedReceiver<Result<InputStream, StreamError>>,
_demux_handle: Option<JoinHandle<()>>,
}
The package yields InputStream objects as STREAM_START frames arrive from the wire. It returns None after the END frame, signaling all arguments have been delivered.
Three ways to consume an InputPackage:
Collect each stream separately (most common):
let input = req.take_input()?;
let streams: Vec<(String, Vec<u8>)> = input.collect_streams().await?;
// streams = [("media:document;ext=pdf", <bytes>), ("media:enc=utf-8", <bytes>)]
Each stream’s bytes are accumulated independently. Use the stream lookup helpers (below) to find specific arguments by media URN.
Concatenate all streams:
let all_bytes: Vec<u8> = input.collect_all_bytes().await?;
Useful when a cap has only one input stream and you just want the bytes.
Iterate streams one at a time:
while let Some(stream_result) = input.recv().await {
let stream = stream_result?;
println!("Got stream: {}", stream.media_urn());
let bytes = stream.collect_bytes().await?;
}
Source: cartridge_runtime.rs (InputPackage).
Stream lookup helpers
After collecting streams with collect_streams(), use these free functions to find specific arguments:
| Function | Returns | On miss |
|---|---|---|
find_stream(streams, media_urn) |
Option<&[u8]> |
None |
find_stream_str(streams, media_urn) |
Option<String> |
None |
require_stream(streams, media_urn) |
Result<&[u8]> |
StreamError |
require_stream_str(streams, media_urn) |
Result<String> |
StreamError |
Matching uses MediaUrn::is_equivalent(): both URNs must have the exact same tag set (order-independent). This is an exact match, not subsumption or pattern matching. Both sides know the argument media URNs from the cap definition, so equivalence is the right test.
let streams = input.collect_streams().await?;
let pdf_data = require_stream(&streams, "media:document;ext=pdf")?;
let prompt = find_stream_str(&streams, "media:enc=utf-8");
The media_urn parameter must be the full media URN from the cap’s argument definition.
Source: cartridge_runtime.rs (find_stream, require_stream).
Argument roles: the main input
Every cap argument has a role, and the contract that assigns it is the canonical
arg-role model every front-end (the capdag CLI, the planner’s step-requirement
payloads, UIs) presents:
- Main input. An arg is the cap’s main input iff it declares a
stdinsource (CapArg::is_main_input); validation RULE3 and RULE15 guarantee that a cap whosein=is notmedia:voidhas exactly one, and that its stdin URN is tagged-URN-equivalent toin=. The main input is the value piped in on stdin, like a Unix command’s stdin. Its declared URN may differ from the stdin URN (e.g. afile-patharg whose piped content is apdf-stream); the stdin URN, not the arg’s own, isin=. - Identity. An arg is identified by its
media_urn, unique within the cap. Bindings, run-time values and diagnostics all name an arg by it. - Name. Every arg has a
name, unique within the cap (RULE17): the label a machine selects it by, and the spelling the CLI accepts in--arg <name>=. It identifies nothing, and nothing about the URN can be read from it. - Accepted type. What a value bound to the arg must conform to
(
CapArg::accepted_urn()): thestdinsource URN for the main input;acceptswhen the definition gives one; the arg’s ownmedia_urnotherwise. - Stream identity. The runtime demuxes each arg’s input stream by
CapArg::stream_urn(): the arg’sstdinsource URN when it declares one, otherwise itsmedia_urn. The engine relabels a forwarded stream to that URN after auditing what it carries, so an op looks its args up by the URNs its own definition declares, whatever produced the value. - Required options. Non-main args with
required: trueand nodefault_value; an invocation must supply each of them. - Options. The remaining non-main args, carrying their
default_value.
Every arg, main or not, may be supplied by another cap’s output, by a constant, or by a run-time value (Machine notation). “Option” describes how an arg is offered on a command line, not where its value may come from.
Consumers of the model: the planner marks the main input on every step argument
(ArgumentInfo.is_main_input), and the CLI structures per-cap help and
missing-argument errors around this three-way split (see
CapDAG CLI reference §”Cap interface presentation”).
Reference media and transport resolution
Some inputs arrive by reference: the value on the wire is not the data, it is a small pointer the runtime resolves into the data. The contract is:
- The arg is declared with a reference media URN (the slot URN); its
stdinsource carries the content media URN; the cap URN’sin=equals the stdin URN.CapArg::is_main_inputalready matches on the stdin source, so a reference-fed main input is structurally identical to a directly-fed one. RULE3/RULE15 apply unchanged. - The runtime’s transport-resolution layer, not the op, recognizes
the reference URN on an incoming stream, consumes the reference value,
resolves it, and delivers the resolved content to the op as an ordinary
InputStreamlabeled with the content URN. The op is transport-blind: it cannot tell a resolved reference from directly piped content, and must never try. - On platforms where the cartridge cannot reach the referenced resource (sandboxing, device permissions), the host resolves instead and streams the content over the wire as a normal input stream. Same cap, same plan, different resolver: a deployment detail, never a protocol one.
Two reference families exist:
File paths (bounded references)
Reference URN family: media:file-path (any URN it accepts). The reference
value is a path string or, for is_sequence args, a newline-separated
list of paths/globs. Resolution reads the file(s) and delivers the bytes as
a bounded stream (scalar) or one item per file (sequence). A missing file,
a failed glob, or a multi-file resolution into a scalar arg is a hard
error. See FilePathContext in cartridge_runtime.rs.
Live feeds (unbounded references)
Reference URN family: any URN carrying the live marker tag:
media:audio;live;microphone, media:image;live;webcam,
media:live;synthetic (the built-in deterministic test feed). The
reference value is a selector record (JSON):
| Field | Meaning |
|---|---|
device |
Provider-defined device selector (id or well-known name). Optional; a provider with exactly one device may default. |
params |
Provider-defined capture parameters (sample rate, resolution, …). Optional. |
stop |
Stop conditions: { "duration_ms": N } and/or { "max_items": N }. Optional; absent means “until stopped”. |
on_overrun |
"drop-oldest" (default) or "fail". See Flow control §Overrun. |
Resolution opens the device via the built-in capture dispatch
(capdag::capture::open, a CLOSED, compile-time set of backends matched
by reference-URN family in plain code, exactly as file paths are read by
plain code; capture is transport, not a capability, and there is no
registry and nothing to register) and delivers an unbounded sequence
stream of items labeled with the arg’s stdin content URN. Each item carries per-item metadata
(pts_us, capture_ts_us, seq, and gap markers; see
Streaming §Live feeds). An unregistered
reference URN, an unparseable selector, or a device the provider cannot
open is a hard error, never a silent empty stream. Selector parsing is
strict at every nesting level: an unknown field (in the selector or
inside stop) is rejected (a misspelled stop condition silently ignored
would run an unbounded feed the caller meant to bound). An empty value is
the all-defaults selector; its canonical spelling is {} (clients
serialize selectors with defaults omitted and keys sorted, so equal
selections are byte-equal). Family membership is the encapsulated
is_live_feed media-URN predicate, present in all five implementations.
The backends: media:live;synthetic, a deterministic clock source
(selector params: items, interval_ms, item_bytes, ring, plus
overrun injection) used by the shared test range and available as a
fixture feed in every build; and the hardware backends (microphone via
alsa/avfoundation, webcam via v4l2/avfoundation) behind CapDAG’s capture
cargo feature, enabled by the capture-capable builds (the CLI, the
engine, audiocartridge, videocartridge). A reference outside the known
families, or a hardware family in a build without the feature, is a
hard, named error.
Which runtime resolves is a deployment fact, not a protocol one. The cartridge runtime resolves when the consumer is an op (the linear-machine case: the first consuming cap’s process opens the device). The HOST resolves when the cartridge cannot reach the device (sandboxing), and, the same rule from the other side, when the host itself is the consumer: a live source feeding a ForEach region directly is iterated by the ENGINE, so the engine opens the feed via the same dispatch and dispatches one body per delivered item while the capture runs. The engine registers the tap so a run stop closes it (close-tap → the feed ends → in-flight bodies drain → a stopped run is a valid run, 15.2 §Runs stop).
Backpressure and delivery. A live feed’s delivery channel into the op is bounded. The chain is: wire credit stalls the op’s output → the op stops consuming input → the bounded feed channel fills → the provider’s feeder stops draining the capture ring → the ring fills → the capture edge applies the overrun policy. Loss can occur ONLY at the capture edge, only under the declared policy, and always counted (§Overrun). This is the live analog of L10’s deadlock-freedom: every stage has a full-state behavior and none of them is “buffer forever”.
Ending. A feed ends when a stop condition fires, when the request is
stopped (a CloseStream frame: the runtime closes the request’s live feeds
and lets the pipeline drain; see Execution
§Runs stop), or when the request is cancelled. The feed’s end is an ordinary
input-stream end: the op observes it exactly as it observes a file stream
ending. A cap that reads a feed declares it: streaming = true on its
main input (15.2 §Streaming contracts), which is what lets the executor
wire it live to an unbounded producer at all.
Response media labeling: the effect contract
The media URN a response STREAM_START carries is derived by the runtime,
not chosen by the op. derive_response_media(cap_urn) (Rust; the same
function exists in every runtime port) computes it from the cap’s declared
effect over its declared main input; this is the label every engine-fed input
stream carries:
| Effect | Response label |
|---|---|
declared (default) |
the declared out= |
none |
the declared in= (the type passes through) |
patch |
the declared in= with the declared delta applied |
This is CapUrn::apply_to_runtime_input_media over the declared input,
the same inference the planner used to refine downstream types at plan
time, so a runtime-labeled response satisfies the engine’s effect audit
by construction. A cap whose label cannot be derived (unparseable URN,
inapplicable effect) fails the request at dispatch; there is no fallback
label.
The engine audits every emission at receipt (ResponseAudit, backed by the
single predicate CapUrn::is_conformant_runtime_output): before a
response STREAM_START is forwarded to the next step, persisted, or
collected, its media URN must satisfy the producing cap’s declared effect
contract. The condition is per-effect:
effect=none/effect=patchcompute an exact output type: the emission must be tag-equivalent to the inference. A more specific emission is still a violation: the effect promises the type is fully determined by the input.effect=declaredpromises only the declaredout=: the emission mustconform_toit. More specific is legal; more generic breaks every downstream refinement and fails.
A violation is a hard failure attributed internal
(ExecutionError::EffectContractViolation), naming the cap URN, the
declared effect, the runtime input, and expected vs actual, raised at
the frame boundary, before the pipelined forward relabels the stream
to the downstream declared arg URN, so the relabel is a verified
assertion rather than a mask over a lying cap.
Outputs
A cap declares one or more outputs (none when out= is media:void). Each is
identified by its media_urn, has a name (RULE19), and is optional or
required. One is the main output: the one whose URN is the cap URN’s out=.
let fit = req.output(); // the main output
fit.start(false, None)?;
fit.write(b"the main output").await?;
let residuals = req.output_for("media:fmt=json;list;record;residual")?;
residuals.start(true, None)?;
residuals.emit_list_item(&row, None).await?;
req.output() is the main output’s stream. req.output_for(urn) is the stream
of the output that URN identifies (tag-equivalence, as with input lookup); a
URN the cap does not declare is RuntimeError::UnknownOutput. Each is an
OutputStream (below) with its own STREAM_START, credit and STREAM_END.
The main output’s stream names no output: it is the cap’s answer. The stream
of every other output names the output it is (frame key 21, that output’s
media_urn) beside the media URN of what it carries. What an output carries,
and how the engine audits it:
| Output | Carries | Audit |
|---|---|---|
| main | per the cap URN’s effect (next section) | as described there |
| other, default | its own media_urn |
the emission must conform_to it |
other, carries = "input" |
the main input’s runtime type | tag-equivalent to it |
When the handler returns Ok(()):
- a required output the handler never wrote to is opened and closed by the
runtime as an empty value (an empty sequence when it is
is_sequence); - an optional output the handler never wrote to opens no stream: it did not fire, and its consumers see it as absent (Machine notation).
The engine refuses, as ExecutionError::OutputContractViolation, a main-output
stream from a cap that declares no output, a stream that names an output the cap
does not declare or names its main output, a second stream for one output, an
output other than the main one carrying a type that is not what it declares, and
a request that ends without a stream for a required output.
OutputStream
OutputStream is the handler’s interface for emitting response data, progress, and log messages. It manages STREAM_START/CHUNK/STREAM_END framing automatically: handlers write data, and the stream handles the rest.
A stream holds its own stream_id, the media URN its STREAM_START carries, the output it
is when it is a response stream (an argument stream of a peer call names none), its chunk
counters, its flow-control window and its write-coalescing buffer.
Source: cartridge_runtime.rs (OutputStream).
Opening a stream
A handler opens a stream before writing to it, and says which kind it is:
req.output().start(false, None)?; // one value, written in pieces
req.output().start(true, None)?; // a sequence, one item at a time
start(is_sequence, meta) sends STREAM_START. is_sequence is the stream’s cardinality
mode and must be the one the output declares; the engine audits it. meta is the stream’s
metadata, forwarded to consumers. start_unbounded(is_sequence, meta) opens a stream that
makes no length promise (a feed). Writing to a stream that was not started, starting one
twice, and writing in the other mode than it was started in are each an error.
Emitting data
Raw bytes: auto-chunked at max_chunk boundaries:
req.output().write(b"result data here").await?;
Small writes COALESCE: they accumulate in a per-stream buffer and ship as one CHUNK when the batch reaches 4096 bytes or the oldest buffered byte is 20 ms old at the next write (COALESCE_MAX_BYTES / COALESCE_MAX_AGE). Chunk boundaries on a scalar stream are non-semantic, so this is invisible to receivers, and a per-token emitter produces batches instead of one frame per token. A batch that is due (and any write larger than the threshold) splits at max_chunk boundaries (default 256 KB); each piece is wrapped as Value::Bytes and sent as a CHUNK frame with checksum, one credit per frame. flush() (and blocking_flush) force the buffered tail out for explicit mid-stream latency barriers; close() always flushes it.
Single CBOR value:
req.output().emit_cbor(&ciborium::Value::Text("hello".to_string())).await?;
Handles type-specific chunking: Bytes values coalesce exactly like write (pure byte-stream continuation on a scalar stream); Text is split at max_chunk boundaries (respecting UTF-8 character boundaries). Array elements are sent one per chunk. Map entries are sent as [key, value] pairs. Scalar types are sent as a single chunk. Every non-Bytes emission is an ORDERING BARRIER: it flushes the coalescing buffer first, so nothing overtakes buffered bytes. Sequence items (emit_list_item) are never coalesced; their chunk runs delimit items.
List item (for streaming list output):
for item in items {
req.output().emit_list_item(&item, None).await?;
}
The second argument is the item’s own metadata, carried on its chunk.
Each call CBOR-encodes the value and sends the raw bytes as chunk payloads. The receiver concatenates chunk payloads to produce an RFC 8742 CBOR sequence: one self-delimiting CBOR value per emit_list_item call. This differs from emit_cbor, which re-wraps each piece as a separate CBOR value.
Progress and logging:
req.output().progress(0.5, "Processing page 3 of 6");
req.output().log("info", "Found 12 images");
These emit LOG frames that travel alongside data frames. See Progress and logging.
Close:
req.output().close().await?;
Flushes any coalesced tail bytes, then sends STREAM_END with the total (coalesced) chunk count; flushing before the count is promised is what makes coalescing lossless. close is async because the tail flush may wait on credit; blocking_close() is the FFI-thread counterpart. Idempotent: calling it twice is safe.
What the runtime does for the handler
sequenceDiagram
participant H as Handler
participant OS as OutputStream
participant W as Wire
H->>OS: start(is_sequence, meta)
OS->>W: STREAM_START
H->>OS: write(data) or emit_cbor(value)
OS->>W: CHUNK (data)
H->>OS: write(more)
OS->>W: CHUNK (more)
Note over H: handler returns Ok(())
Note over OS: runtime calls close()
OS->>W: STREAM_END
Note over OS: runtime sends
OS->>W: END
- STREAM_START: sent by
start(), which the handler calls. On the stream of an output other than the main one it names that output (frame key 21). - STREAM_END: sent by
close(). The runtime closes every stream the handler opened once the handler returnsOk(()), so a handler need not. - An output the handler never opened: see Outputs. A required one is opened and closed by the runtime as an empty value; an optional one sends nothing.
- END: sent by the runtime after the handler returns.
- On error: the runtime sends an ERR frame instead of END, and closes nothing: what the handler had written stays as it was, and the request has failed.
A cartridge run from its own command line
A cartridge invoked directly (<cartridge> <alias> ...) has one standard output, and it
carries the cap’s main output: the stream that names no output. Another output that
fires is not printed: the cartridge says so on standard error, naming the output. A machine
is what routes a cap’s other outputs.
CBOR encoding conventions
Arguments and results flow as CBOR values in CHUNK payloads. The encoding conventions:
- Text arguments (prompts, file paths, configuration strings): sent as
Value::Bytescontaining UTF-8 encoded text, not JSON-quoted. A prompt string"hello world"becomes the raw byteshello world, not"hello world". - Binary data (images, PDFs, model weights): sent as
Value::Bytescontaining raw binary data. - JSON structures (model specs, configuration objects): sent as
Value::Bytescontaining the JSON string in UTF-8. - Numeric arguments: sent as
Value::Bytescontaining the string representation in UTF-8.
This matches the json_value_to_bytes convention used in the planner module (see Planner): strings become raw UTF-8 bytes (no JSON quoting), everything else becomes JSON-encoded bytes.
Swift equivalent
The Swift OutputStream and InputStream in capdag-objc/Sources/Bifaci/CartridgeRuntime.swift follow the same semantics with platform-appropriate types:
Datainstead ofVec<u8>,CBORvalues instead ofciborium::Value.- Methods throw instead of returning
Result. start(isSequence:meta:),write(_:),emitCbor(_:),emitListItem(_:meta:),close(), as in Rust;blockingClose()for a thread that cannot await.log(level:attributionClass:message:argUrn:)andprogress(_:message:)for diagnostics;finish(progress:message:)declares the request’s terminal status.runWithKeepalive()uses a backgroundTaskinstead oftokio::task::spawn_blocking.CborRequest.output()is the main output;output(for:)finds another declared output by its media URN, asRequest::output_fordoes.
The stream lifecycle is identical: STREAM_START when start is called, STREAM_END on close, END on handler completion; when the handler returns, a required output it never wrote to is sent empty and an optional one is not sent.