Status: Draft Authors: Eliot Hedeman eliot.d.hedeman@gmail.com, Alex Kesling alex@empathic.dev Created: 2026-04-14 Extends: RFC: Toolpath - A Format for Artifact Transformation Provenance
This RFC defines a line-oriented JSONL format as a peer to Toolpath's canonical
JSON format, optimized for incremental persistence of Path documents. The
format expresses a Path as a stream of self-describing lines — each an
instruction that contributes to the final document — so that writers can append
one complete step at a time rather than buffering the entire path before
serializing.
Any canonical JSON Path can be written as JSONL and read back to an
identical document. The reverse direction is lossy — reading collapses
intermediate metadata updates — but the canonical form is stable across
conversion cycles. Signatures computed over canonical JSON remain valid
after any number of JSON → JSONL → JSON conversions.
One additive schema change is required: an optional graph_ref field on
PathIdentity so a streamed path can name the graph it belongs to up front.
The canonical Toolpath format serializes a Path as a single JSON document.
Producers must buffer the entire path before emitting a valid document. This
is a poor fit for live agent traces: a single writer (e.g., Claude Code)
records steps as it works, and a consumer tails the file to display
progress. With the canonical format the writer must either defer writing
until the session completes, or repeatedly rewrite a growing JSON blob —
neither of which supports a readable tailed file.
The streaming format provides an append-only, line-oriented file where
each line is a self-describing unit that incrementally constructs a Path
document.
- Append-only writes. Writers emit complete lines; no line ever needs to be rewritten or removed.
- Peer format with canonical JSON. JSON → JSONL → JSON is lossless; the canonical form is stable across conversion cycles.
- Complete expressive power. Every field reachable in canonical JSON is reachable through the line set.
- Signature preservation. Signatures computed over canonical JSON survive any number of JSON → JSONL → JSON conversion cycles.
- Strict, unambiguous parsing. Malformed input is a fatal error. No recovery heuristics.
- Streaming individual steps. Each step is atomic per line. Partial step construction (e.g., emitting a step identity now and its diff later) is out of scope.
- Multi-path streams. A JSONL file encodes exactly one inline
Path, which is wrapped at the file boundary as a single-pathGraph. Multi-path graphs compose JSONL files via existing$refsemantics. - Mid-file resync or corruption recovery. Readers always start from line 1.
- Transport semantics. The format is equally usable as a storage format, a tailed log, or a transport payload, but no transport is specified.
v1 covers a single inline Path streamed across many JSON lines. The format
is a single-path graph at the file boundary: when readers seal the stream,
they wrap the resulting Path as a single-path Graph (a Graph whose
paths array holds exactly one inline path). Multi-path graphs are not
streamable — they live as canonical .path.json files and reference
streaming siblings via $ref.
Toolpath documents use a two-part extension that encodes both the serialization strategy and the streaming variant:
| Extension | Format | Description |
|---|---|---|
.path.json |
Canonical JSON | A Graph document serialized as a single JSON blob. Multi-path graphs require this format. |
.path.jsonl |
Streaming JSONL | A single inline Path expressed as a sequence of self-describing JSON lines, sealed as a single-path Graph at the file boundary. |
Tools that accept .path.jsonl input read it into the same in-memory Graph
representation as a .path.json file.
Graph $ref entries MUST point to sealed .path.json files, not to
.path.jsonl streams. A $ref is a promise that the target is a complete,
valid document; a streaming file may be incomplete or mid-write.
Tools that consume .path.jsonl files should convert them to
.path.json before incorporating them into a multi-path graph.
| Property | Value |
|---|---|
| Extension | .path.jsonl |
| Encoding | UTF-8 |
| Line terminator | LF (\n) |
| Line format | One JSON object per line |
No blank lines, no comments, no trailing commas. Each line is a single externally tagged JSON object:
{"<Variant>": <body>}
Valid variants: PathOpen, Step, ActorDef, Signature, PathMeta,
Head, PathClose.
| Constraint | |
|---|---|
PathOpen |
Exactly once, as line 1. |
PathClose |
At most once, as the last line (if present). |
Head |
Zero or more; last occurrence wins. |
Step, ActorDef, Signature, PathMeta |
Zero or more, any order, between PathOpen and PathClose. |
Signature with target: "step:<id>" |
Must appear after the referenced Step line. |
Readers MUST treat the following as fatal errors:
- First line is not a valid
PathOpen. - Malformed JSON on any line.
Signaturetargeting a step that has not yet appeared.- Ambiguous head at EOF when no
Headline was emitted (see Reading JSONL).
Readers SHOULD warn on, but MUST skip, the following:
- Unknown variant at the top level of a line.
- Unknown
versionvalue inPathOpen.
Unknown lines are preserved in memory when possible (implementations
MAY store them as opaque JSON) so that a JSON → JSONL → JSON round-trip
through a newer-format file does not silently discard data. However,
readers are not required to interpret unknown lines, and unknown lines
do not affect the canonical Path produced by reading.
This approach favors forward compatibility: a file written by a newer
producer remains readable by an older consumer, which gets a correct
(if incomplete) view of the path. Version bumps in PathOpen signal
structural changes that older readers cannot safely ignore.
Writer durability is out of band. The format does not mandate an fsync
policy. Implementations typically sit somewhere on this spectrum:
| Policy | Tradeoff |
|---|---|
| No sync (OS-buffered) | Fastest. Unflushed lines lost on crash. Appropriate for ephemeral traces. |
fsync per line |
Strongest durability. Highest overhead. |
fsync per batch / on close |
Compromise. Suits agent traces that emit many steps in close succession. |
Each line body is a JSON object with the fields below. Fields marked optional may be omitted.
Exactly once, as the first line. Carries the initial path identity and any path-level metadata known at open time.
{"PathOpen": {
"version": "1",
"id": "pr-42",
"base": {"uri": "github:org/repo", "ref": "abc123"},
"graph_ref": "toolpath://archive/release-v2",
"meta": {
"title": "Add email validation",
"source": "github:myorg/myrepo/pull/42",
"intent": "...",
"refs": [{"rel": "fixes", "href": "issue://..."}]
}
}}| Field | Required | Description |
|---|---|---|
version |
yes | Format version string. v1 = "1". |
id |
yes | PathIdentity.id. |
base |
no | PathIdentity.base — same shape as canonical JSON. |
graph_ref |
no | $ref-style URL naming a graph this path belongs to. See Schema Change. |
meta |
no | Initial PathMeta excluding actors and signatures (those have dedicated line kinds). |
Zero or more. The body is the existing Step JSON shape verbatim — including
step.meta, which may carry step-level signatures, intent, refs, or actor
definitions.
{"Step": {
"step": {
"id": "step-003",
"parents": ["step-002"],
"actor": "agent:claude-code",
"timestamp": "2026-04-14T15:30:00Z"
},
"change": {
"src/auth/validator.rs": {"raw": "@@ -1,5 +1,25 @@\n..."}
},
"meta": {
"intent": "Add email validation"
}
}}Zero or more. Inserts or overwrites an entry in path.meta.actors.
{"ActorDef": {
"actor": "human:alex",
"definition": {
"name": "Alex Kesling",
"identities": [{"system": "github", "id": "akesling"}],
"keys": [{"type": "gpg", "fingerprint": "ABCD1234..."}]
}
}}Overwrite (rather than merge) is deliberate: an actor definition is the complete current record for that actor. A writer that wants to add a key re-emits the full definition.
Zero or more. Appends to the signature array at either path or step scope.
{"Signature": {
"target": "path",
"signature": {
"signer": "human:bob",
"key": "gpg:EFGH5678",
"scope": "reviewer",
"sig": "-----BEGIN PGP SIGNATURE-----\n...",
"timestamp": "2026-04-14T16:00:00Z"
}
}}target |
Effect |
|---|---|
"path" |
Append to path.meta.signatures. |
"step:<step-id>" |
Append to the named step's meta.signatures. Referenced Step line MUST already have appeared. |
Zero or more. Field-wise merge into path.meta. Last-write-wins per field.
Array fields (e.g. refs) replace; they do not append.
{"PathMeta": {
"patch": {
"title": "Revised title",
"intent": "...",
"refs": [{"rel": "tracks", "href": "..."}],
"extra": {"custom-field": "..."}
}
}}Append-like semantics for actors and signatures are handled by the
dedicated ActorDef and Signature line kinds; PathMeta does not carry
those fields.
Zero or more. Explicitly names the current head step. Last occurrence wins.
{"Head": {"step_id": "step-005"}}Optional terminator. Indicates the writer is done and no more lines will follow. Readers MAY use this to distinguish a completed file from one still being appended to. Readers MUST tolerate its absence.
{"PathClose": {}}How a reader produces a canonical {"Path": {...}} document from a
.path.jsonl file.
state:
path_id, base, graph_ref ← from PathOpen
path_meta ← PathOpen.meta or empty; actors={}, signatures=[]
steps = [] (in arrival order)
step_index = {} (step.id → &steps[i])
head = null (last Head line's step_id, if any)
for each line after PathOpen, in order:
Step:
append to steps; record in step_index
ActorDef { actor, definition }:
path_meta.actors[actor] = definition
Signature { target: "path", signature }:
path_meta.signatures.append(signature)
Signature { target: "step:<id>", signature }:
require step_index[id] exists (else fatal error)
step_index[id].meta.signatures.append(signature)
PathMeta { patch }:
for each field in patch:
path_meta[field] = patch[field] # arrays replace
Head { step_id }:
head = step_id
PathClose:
end-of-stream sentinel; no state change
on EOF:
if head is null:
candidates = { s in steps : no other s' in steps has s.id in s'.parents }
if |candidates| != 1: fatal error ("ambiguous or missing head")
head = the one candidate's id
emit: {"Path": {
"path": {
"id": path_id,
"base": base,
"graph_ref": graph_ref,
"head": head
},
"steps": steps,
"meta": path_meta
}}
The single-tip DAG rule applies only when head is unspecified. Paths with
intentional dead ends SHOULD emit an explicit Head line.
How a writer produces a .path.jsonl file from a canonical Path.
Used for converting stored documents and for round-trip testing.
emit PathOpen {
version: "1",
id: path.id,
base: path.base,
graph_ref: path.graph_ref,
meta: { # path.meta minus actors, signatures
title, source, intent, refs, extra
}
}
for actor_key in sorted(path.meta.actors.keys()):
emit ActorDef { actor: actor_key, definition: path.meta.actors[actor_key] }
for step in path.steps: # original array order
emit Step { ...step... }
for sig in step.meta.signatures: # original order
emit Signature { target: "step:" + step.id, signature: sig }
for sig in path.meta.signatures: # original order
emit Signature { target: "path", signature: sig }
emit Head { step_id: path.head }
emit PathClose {}
Reading JSONL is a lossy operation: PathMeta last-write-wins semantics
collapse intermediate metadata states, and line ordering is not preserved.
This means JSONL → JSON → JSONL does not reproduce the original line
sequence. However, the canonical JSON form is stable:
- JSON → JSONL → JSON:
read(write(P)) == Pfor any canonicalPathP. Writing emits every field, and reading reconstructs the original document. - JSONL → JSON → JSONL → JSON:
read(write(read(L))) == read(L)for any valid JSONL streamL. Reading collapses metadata and resolves head; converting back and reading again produces the same canonical document.
The format does not guarantee:
- JSONL round-trip:
write(read(L))may differ fromLin line count, line ordering, and metadata line content (intermediatePathMetaandActorDefoverwrites are collapsed when read). - Canonical JSONL: Two different JSONL files may read to the same JSON document. There is no unique JSONL representation.
Every canonical JSON field has a corresponding line kind:
| Canonical field | Line kind |
|---|---|
path.id, path.base, path.graph_ref |
PathOpen |
path.head |
Head (or inferred) |
path.meta.title / source / intent / refs / extra |
PathOpen.meta or PathMeta |
path.meta.actors entries |
ActorDef |
path.meta.signatures entries |
Signature with target: "path" |
steps[*] (entire step including inner meta) |
Step |
steps[*].meta.signatures entries |
Signature with target: "step:<id>" |
Canonicalization is unchanged. Signatures are computed over the canonical
JSON form per JCS (RFC 8785), as defined in the base RFC. Because
read(write(read(L))) == read(L), a signature computed on a canonical
JSON document remains valid after any number of JSON → JSONL → JSON
conversion cycles.
Late-arriving signatures — for example, a reviewer approval added after
the steps are recorded — arrive as Signature lines appended to the file.
The signed payload is still the canonical JSON form: the reviewer reads
the current JSONL file into canonical JSON, computes the signature per
the base RFC, and emits a Signature line.
One additive change to the canonical schema, in crates/toolpath/src/types.rs:
pub struct PathIdentity {
pub id: String,
pub base: Option<Base>,
pub head: String,
pub graph_ref: Option<String>, // NEW
}Backwards compatible: graph_ref is optional and omitted when empty.
Existing documents and signatures validate unchanged. This field lets a
path name the graph it belongs to, supporting cross-referencing from
streaming contexts where the containing graph is known up front.
The value of graph_ref uses the same $ref-style URL conventions as
Graph.paths[*].$ref:
https://...,s3://...,file:///...— external referencestoolpath://<archive-name>/<graph-id>— named archive references
A minimal streaming session recording a two-step path:
{"PathOpen":{"version":"1","id":"pr-42","base":{"uri":"github:org/repo","ref":"abc123"},"meta":{"title":"Add email validation"}}}
{"ActorDef":{"actor":"agent:claude-code","definition":{"name":"Claude Code","provider":"anthropic"}}}
{"Step":{"step":{"id":"step-001","actor":"agent:claude-code","timestamp":"2026-04-14T10:00:00Z"},"change":{"src/validator.rs":{"raw":"@@ -0,0 +1,20 @@\n+pub struct Validator..."}},"meta":{"intent":"Add email validation struct"}}}
{"Step":{"step":{"id":"step-002","parents":["step-001"],"actor":"tool:rustfmt","timestamp":"2026-04-14T10:00:05Z"},"change":{"src/validator.rs":{"raw":"@@ -3,3 +3,3 @@\n..."}},"meta":{"intent":"Auto-format"}}}
{"Head":{"step_id":"step-002"}}
{"Signature":{"target":"path","signature":{"signer":"human:alex","key":"gpg:ABCD1234","scope":"author","sig":"-----BEGIN PGP SIGNATURE-----\n..."}}}
{"PathClose":{}}
Reading this JSONL file produces a canonical {"Path": {...}} document with:
path.id = "pr-42",path.base = {...},path.head = "step-002"steps = [step-001, step-002]path.meta.title = "Add email validation"path.meta.actors = {"agent:claude-code": {...}}path.meta.signatures = [<author signature>]
Writing that canonical document back to JSONL produces an equivalent line sequence (modulo the normalized write ordering).
An earlier sketch considered a StepOpen / StepPatch / StepClose
lifecycle so that a single step could stream in pieces — for example, the
intent becomes known at t=0, the diff at t=20s. This was rejected for v1:
- Atomic steps preserve append-only semantics — no line is ever conditional on a later line's arrival.
- Live agent traces can emit a step as soon as the agent knows both the artifact and the change. The "progress before diff is ready" case is addressed by emitting a coarser step on arrival and a finer step once the diff stabilizes, or by emitting progress updates outside the toolpath file entirely.
The mechanism can be added in a future revision via new line kinds, guarded by a version bump.
An earlier draft treated unknown variants as fatal errors, reasoning that silently skipping a line means silently losing provenance. This was reversed because:
- Forward compatibility matters more in practice. A file written by a newer producer should be readable by an older consumer — the older reader gets a correct (if incomplete) view of the path rather than refusing to read the file at all.
- Provenance is not lost — the unknown lines are still in the file. A reader that understands them will interpret them correctly. An older reader that skips them produces the same result it would have produced before the new line kind existed.
- Version bumps in
PathOpenremain available for structural changes that older readers truly cannot handle (e.g., changes toStepsemantics or ordering constraints).
A single file could in principle carry an entire graph — GraphOpen,
then interleaved PathOpen / Step / PathClose blocks scoped by a
path field on every line. That design was rejected because:
- The primary use case (live agent trace) has one writer per path. Multiplexing multiple paths into one file adds coordination complexity that the use case doesn't need.
- Graphs already have a composition mechanism (
$ref). A graph that references streaming path files is simpler than a graph-scoped stream and composes with the existing model. - Per-line path scoping makes every line heavier and makes "tail this path" harder.
A streaming writer that knows its containing graph up front (a release
automation pipeline recording PRs) benefits from stamping graph_ref on
open. But round-trip requires the sealed JSON to carry the same
information, which means the canonical PathIdentity must have a place
to store it. Adding the field to PathIdentity is a small additive
change and keeps the JSONL format a pure serialization of the canonical
model.
Last-write-wins per field is simple and symmetric. Append semantics for
arrays creates asymmetry (refs appends but scalars replace), and the
fields most likely to "grow over time" — actors, signatures — already
have dedicated line kinds with the right semantics. Writers that want to
add a ref without disturbing existing refs emit a PathMeta line with the
complete new array.
- Canonical JSON readers are unaffected. The JSONL format is a peer, not a replacement.
- Existing canonical documents remain valid. The only schema change —
graph_refonPathIdentity— is additive and optional. - Existing signatures remain valid. Canonicalization is unchanged.
- Tooling that operates on canonical JSON (validate, render, query)
can accept
.path.jsonlinput by reading it into canonical form before operating.
A stronger "normalized write" guarantee would canonicalize each line
body per JCS, so that writing a given Path produces byte-identical
JSONL across implementations. This would allow signing a JSONL file
directly rather than signing the canonical JSON. Current answer: not in
v1. The canonical signing path (read → JCS → sign) is sufficient and
avoids introducing a second canonicalization rule.
A file missing PathClose and ending mid-line (the last line is not
newline-terminated) is ambiguous: still being written, or crashed
mid-write? Current answer: readers treat an unterminated final line as a
fatal error and treat EOF-without-PathClose as informational. Readers
tailing a live file use heuristics (file still open, recent mtime) to
distinguish the cases.
A writer might learn, after emitting a step, that the step's meta.intent
should change or that a new ref should be attached. v1 has no mechanism
for this — a step is immutable once emitted. A future StepPatch line
could cover the case, guarded by a version bump.
- JSON Lines (jsonlines.org): Line-oriented JSON for log files. Direct inspiration for the container format.
- NDJSON: Same idea, different name. Widely used in data pipelines.
- Server-Sent Events (SSE): Line-oriented event streams over HTTP.
The
event:/data:framing influenced the variant-per-line approach. - Git pack protocol: Variant-tagged messages over a stream. Similar structural pattern at a lower level of abstraction.
- OpenTelemetry OTLP: Structured append-only telemetry streams. Shares the "each record is self-describing" property.
- JCS (RFC 8785): JSON Canonicalization Scheme. Used unchanged for the signature path.