This is the complete frozen-evidence graph and debug.nav contract.
A failed run leaves an immutable capture. A second PTC run can navigate that capture: the boundary failure, the programs the run generated, their results, the prelude source those programs reached, and that source's frozen dependencies.
The substrate is structural. It exposes evidence and typed links; it never selects a suspect or proposes a repair. Reaching a conclusion — or deciding the evidence is insufficient — stays with the caller, whether that caller is a deterministic walk or a model.
What the evidence path looks like
failed immutable run
-> runs / open / read / follow
-> generated source and result
-> referenced prelude source
-> dependency prelude source
-> supported diagnosis, or insufficient evidenceEach arrow is a typed relationship carried on the evidence item itself, so a walker never has to guess a filter or reconstruct an identity by hand.
Capture the evidence a debugger can read
Two artifacts matter, and they carry different authority.
A trace is bounded operational evidence: run and evaluation lifecycle, capability names and outcomes, counts, and a sanitized failure taxonomy. It contains no prompts, model responses, capability payloads, or generated source.
A failed capability attempt records its closed envelope kind and reason
on the capability-stopped event. An unrecognized envelope atom is retained
only as a one-way fingerprint.
A workflow's own fail value never reaches the public trace as prose. An
unrecognized fail kind is retained only as a one-way fingerprint on
run-stopped; that taxonomy is separate from the capability-stopped class.
A private inspection artifact is an explicit 0600 host development
authority. It adds the frozen component sources, the exact generated programs,
capability arguments and results, model exchanges, prints, and detailed
failures. Raised capability callbacks can include their bounded exception
message and formatted stacktrace here, but never in the correlated trace.
Those strings may contain secrets or local paths and cannot be reliably
redacted. Read the artifact only through an authorized private sink, and never
publish it alongside a normal trace.
A project document captures both:
"artifacts": {"root": "target/.ptc", "trace": true, "inspection": true, "result": true, "envelope": true}The equivalent low-level form is --trace-dir plus --inspect. See
Running and debugging for every switch and
Project configuration for the artifact layout.
Inspection requires a trace, because every private record is validated against the run it claims to describe.
Publish one conversation with ptc transcript
For one certified conversation, avoid a REPL. Create a sibling directory
first; do not write into /tmp or into the project that holds the traces:
mkdir -p out
ptc transcript RUN_ID \
--traces .ptc/traces \
--inspection .ptc/inspection \
--private-unattended \
--private-output out/conversation.private.json
--private-output names a new owner-only file. Its parent must already exist
and be reached without a symbolic link — on macOS /tmp is a symlink, so
/tmp/out.json is refused. The parent must also be physically separate from
--traces and --inspection: no directory may equal, contain, or be
contained by either of the others. A file in the current directory fails when
that directory contains --traces. A sibling directory, as above, satisfies
both rules. A rejection names the two conflicting switches and their physical
relationship, and discloses no path.
Give the debugger bounded navigation authority
The debugger is an ordinary application. Its host document installs the failed run's two directories as snapshot providers:
{
"install": {
"failed-run-traces": {
"source": "ptc_private_trace_snapshot",
"installation_revision": "failed-run-traces-v1",
"directory": "target/.ptc/traces"
},
"debug.nav": {
"source": "ptc_inspection_snapshot",
"installation_revision": "failed-run-inspection-v1",
"directory": "target/.ptc/inspection"
}
}
}Use ptc_private_trace_snapshot when the captured run wrote a
.private.jsonl trace, and ptc_trace_snapshot for an ordinary one. Either
way the inspection selection fixes the debugger's data class to
private_inspection, so no normal vendor connector can run beside it.
The manifest selects both into one mission and installs the debug.nav
prelude:
"missions": {
"evidence": {
"components": [
{"id": "evidence.walk", "path": "evidence.walk.clj", "dependencies": ["debug.nav"]},
{"library": "debug.nav"}
],
"providers": ["debug.nav", "failed-run-traces"]
}
},
"providers": {
"mission": [
{"name": "debug.nav"},
{"name": "failed-run-traces", "config": {"expose": false}}
]
}{"expose": false} keeps the trace selection as the inspection source's
dependency without creating a second navigation namespace.
A snapshot provider names its operations <alias>.runs, <alias>.open, and
<alias>.read, and the shipped prelude binds that conventional alias, so the
inspection selection must be called debug.nav. The alternative shipped
surface, analysis, binds the stable analysis-runs/analysis-open/
analysis-read names a REPL profile grants; install one or the other, never
both.
Navigate with debug.nav
debug.nav is a thin policy layer over the snapshot provider. It adds no host
authority and no diagnosis policy:
| Call | Purpose |
|---|---|
(debug.nav/runs options) | list captured runs, filtered by status, name, tags, or id |
(debug.nav/open run-id) | discover a run's collections, filters, identifiers, and completeness fields |
(debug.nav/read run-id options) | read one bounded native page from a named collection |
(debug.nav/follow run-id relationship options) | follow one typed relationship |
follow takes a relationship exactly as an evidence item published it and
reads its declared target collection and filters. It returns the original
relationship beside the unchanged page envelope, so cursors, omitted_count,
snapshot_hash, truncation, and the relationship's own state all survive the
hop. Caller options may contain only limit and cursor; a relationship that
is unavailable or carries null filters is refused rather than guessed at.
Start from the failure:
(let [run (first (get (debug.nav/runs {"status" "error" "limit" 1}) "items"))
run-id (get run "run_id")
error (first (get (debug.nav/read run-id {"collection" "execution_errors"}) "items"))]
{"terminal_reason" (get run "terminal_reason")
"relationships" (map #(get % "rel") (get error "relationships"))})Follow the typed links
Relationships are declared with a semantics that is deliberately narrower than their name:
| Relation | Semantics | Reaches |
|---|---|---|
boundary_failure | causation | the failing activity record |
child_evaluations | nesting | evaluations parented by the failing one |
direct_boundary_producer | causation | the child evaluation that produced the boundary value |
generated_source | association | that evaluation's generated program |
producing_turn | association | the model turn that emitted a program |
referenced_prelude_source | association | a component a program actually called |
dependency_prelude_source | dependency | a component's frozen direct dependencies |
causation requires proof: a workflow boundary failure, or a direct-return
origin marker plus exact equality with the retained child result. A parent edge
alone is never promoted to causation. When a run fails by calling fail
directly, no direct producer is proven, and that relation honestly reports
incomplete instead of pointing at the most likely child.
Generated entries embedded in turns carry the same relationships as their
generated_sources item, so a walker that starts from a model turn can follow
evidence without a second exact read.
From a generated program, one hop reaches the component it called, and each further hop reaches that component's frozen dependencies:
(let [referenced (first
(filter #(= (get % "rel") "referenced_prelude_source")
(get generated "relationships")))
entry (first (get (get (debug.nav/follow run-id referenced {}) "page") "items"))]
(get entry "source"))A component ID alone is not an occurrence identity: the same component can be
frozen into the workflow environment and into several missions with different
dependency edges. Every dependency filter therefore repeats its environment
and, for a mission occurrence, its mission_name. The edges come only from a
frozen prelude graph that satisfies its complete positional contract, so a
malformed or absent graph produces one incomplete relation rather than a
guessed edge.
Read the evidence state honestly
Every relationship carries one of four states, and a walker that ignores them will report confident nonsense:
| State | Meaning |
|---|---|
complete | the host proved this edge; the filters are exact |
incomplete | the capture cannot answer, so no claim is made |
ambiguous | the target is not uniquely determined; see below |
unavailable | a complete search proved there is no such target |
incomplete is the honest catch-all for "not established": a non-terminal or
truncated run, producer evidence that was cut off, an absent or
malformed frozen prelude graph, or a program whose prelude-call analysis was
never captured. It does not distinguish those causes, and a null-filtered
incomplete relation cannot be followed at all.
unavailable is a finding rather than a gap: the evidence exists and contains
no match.
ambiguous means different things by relation. Ambiguous boundary-producer
candidates are emitted as several separate relations, each with its own exact
filter, rather than one unbounded scan. An ambiguous prelude or turn
association is a single relation whose one filter matches more than one item;
following it returns them all, and choosing between them is the caller's
problem.
Never follow a relationship whose filters are null.
Pages carry their own completeness separately from relationship state:
next_cursor, omitted_count, truncated, and snapshot_hash. Turn pages
add an evidence block reporting canonical completeness, missing model
exchanges, and ambiguity counts.
A diagnosis is supported when every edge it rests on is complete and the
pages it read were not truncated. Otherwise the honest report is insufficient
evidence, naming which edge was missing.
What the completeness fields do and do not claim
A conversation's complete? is the conjunction of three specific facts: the
run reached a terminal event with no dropped events, no expected
model exchange is missing, and no turn or generated-source association is
ambiguous. It is a statement about the reconstruction, not a promise that every
field of every record is present. ptc transcript refuses to write a file at
all unless complete? holds, so that field is always true in a published
transcript; read it over /api/analysis/runs/{id}/conversation, which applies
no such gate, when you need it to be able to say no.
The refusal names which of the three facts failed and by how much, because they
have different next actions. transcript/ambiguous_evidence means nothing is
missing: the run is complete and every expected exchange was
captured, but some turn or generated-source association resolves to more than
one predecessor. Re-running does not help; read the ungated route instead.
transcript/incomplete_evidence means the trace is not terminal or
dropped events, or the inspection artifact does not carry every exchange the
trace expects — both facts about the capture rather than the reconstruction.
Reaching the ungated reconstruction
/api/analysis/runs/{id}/conversation is served by ptc viewer and needs the
project's private grant. Four separate decisions withhold it — two about the
project, two about the run — and the Viewer's private routes answer for each by
name, so the reason body is the next action rather than a bare status:
| answer | cause | next action |
|---|---|---|
404 inspection_not_configured | the project records no inspection artifact | set artifacts.trace and artifacts.inspection and run again |
404 inspection_not_private | the artifact exists, this Viewer was not granted it | set viewer.private and start the Viewer again; nothing needs re-running |
404 inspection_run_not_recorded | the Viewer reads private evidence, this run recorded none | run the project again, now that artifacts.inspection is set |
404 inspection_run_mismatch | the Viewer is pinned to a different run's artifact | start the Viewer for this run |
Every other answer is a transport status with prose, not a reason code: the
route reports a source that is unavailable, changed, oversized, or malformed as
the failure it is rather than as a setting the reader could change.
/api/analysis/runs/{id}/preludes answers on the same terms.
omitted_count is pagination: how many selected items this page did not
return. It never reports evidence withheld by policy. A source grant that
withheld trace files of the other kind reports that separately, as
excluded_private_trace_files or excluded_sanitized_trace_files on a run
listing or counters query.
Join on correlation ids, never on sequence numbers
A transcript turn and a trace event live in different sequence
spaces. Turn request_sequence orders records inside the inspection snapshot;
canonical sequence orders the run's event stream. Turn 1 reporting request
sequence 12 says nothing about trace event 12, which is an unrelated
record. Correlation identifiers — capability-5, mission-evaluation-9 — are
the same in both artifacts and are the only sound join key.
Run the credential-free example
The checked-in example needs no credential or network access. target/ prices
an order through orders → pricing.tax, which branches to pricing.base and
pricing.rule. The rule adds 2 while the captured call requires 20, so the run
fails. pricing.discount is an unused decoy.
Capture the failed run. It exits nonzero by design:
ptc init debug-a-failed-run --example debug-a-failed-run
ptc run debug-a-failed-run/target.ptc-project.json
Then navigate that capture:
ptc run debug-a-failed-run/debugger.ptc-project.json
The debugger is a private run, so read its value from the project's result directory rather than stdout:
cat debug-a-failed-run/debugger/.ptc/results/*.private.json
It reports the boundary failure, the exact generated program including the required total, and the frozen dependency closure with each component's source:
{
"boundary_kind": "workflow_failed",
"closure_complete": true,
"dependency_closure": ["orders", "pricing.tax", "pricing.base", "pricing.rule"],
"evidence_states": ["complete", "incomplete", "unavailable"],
"generated_source": "(let [quote (orders/place {\"subtotal\" 100})] (if (= (get quote \"total\") 120) quote (fail {:kind \"order-total-mismatch\"})))",
"terminal_reason": "explicit_failure"
}The decoy never appears, because the walk follows frozen dependency edges
rather than the manifest's component list. Both branches under pricing.tax
do appear: a walk that followed one edge per component would silently drop the
other, and might drop the defective one.
Two fields keep the report honest about its own coverage. closure_complete is
false whenever the walk stopped at an edge the host could not prove or hit its
own bound, so a partial closure is never mistaken for the whole one.
evidence_states includes incomplete here because this run failed by calling
fail, so no direct boundary producer was proven.
The generated program is what makes this diagnosis supported rather than
plausible. It carries both values the check ran on — subtotal 100 and required
total 120 — so the reached sources settle the question: pricing.base returns
the subtotal unchanged and pricing.rule adds 2, and no other component in the
closure decides the amount.
This is worth noticing, because the same example with data/params in the
generated program instead of the literal order is genuinely insufficient
evidence. The capture would still show the check and the whole source chain,
but not the value checked, and a wrong input would then be indistinguishable
from a wrong component. A live model given that earlier capture correctly
refused to name a component for exactly this reason. What a run generates is
what a later run can prove.
Confirming the diagnosis is a separate step: edit target/pricing.rule.clj,
remove the stale capture, and rerun the target.
Let a model do the walking
The same mission works for an agent. debugger-agent/ in the example replaces
the deterministic walk with the shipped agent loop over the same authority. It
is the one part of the example that needs a credential, named on the command
line so no environment file has to live inside the example directory:
ptc run debug-a-failed-run/debugger-agent.ptc-project.json --env-file .env
Keep debug.nav as the mission's only domain authority and select a model into
the workflow:
"workflow": {"components": [{"library": "agent.main"}], "entry": "agent.main/run"},
"missions": {
"evidence": {
"components": [{"library": "debug.nav"}],
"providers": ["debug.nav", "failed-run-traces"]
}
},
"providers": {
"workflow": [{"name": "deepseek"}],
"mission": [{"name": "debug.nav"}, {"name": "failed-run-traces", "config": {"expose": false}}]
}Selecting an inspection snapshot fixes the run's class to private_inspection,
so the model installation must declare
"accepts_data": ["normal", "private_inspection"]. That declaration permits
captured private evidence, including generated source, frozen component source,
and failure detail, to be sent to a model vendor. Enable it only when intended.
Name the mission that holds the evidence, and require a result contract with an explicit decision so an abstention is a first-class answer rather than an empty diagnosis slot:
"input": {"value": {"task": "...", "agent": {"max_turns": 14, "mission": "evidence"}}},
"contracts": {"result_schema": {"path": "report.schema.json"}}The agent loop runs inside one workflow evaluation, so workflow_timeout_ms
must cover every model turn, not one call. Its installed default of 30 s ends a
multi-turn investigation mid-flight; the example raises the host ceiling and
the manifest together.
What to expect
Four limits showed up repeatedly while this example was built against a live model, and all four are worth designing around.
Traversal beats budget. Told only to "read the boundary error", the model
looped on the public activity collection for eight turns and never reached
execution_errors. Naming the collections and the intended order — API
vocabulary, not answer hints — moved it onto the typed relationships
immediately. Generic navigation authority does not imply a generic traversal
plan.
Available tools postpone conclusions. With the evidence chain fully read by turn 7, the model spent its remaining turns re-reading unrelated collections. An explicit stopping rule in the task fixed it. Treat an exhausted turn limit as an unfinished investigation, not an absent cause.
Report shape is a separate failure mode. One run reached the right evidence and returned a report missing a single required field. The bounded contract feedback named exactly that field; the model resumed exploring instead of correcting. Stating the contract's fields in the task removed the problem.
What the run generated decides what the debugger can prove. Against the
current capture, whose generated program carries both the order and the
required total, the live model traced the branching chain and correctly named
pricing.rule, citing that pricing.base returns the subtotal unchanged while
the rule adds 2. Against an earlier capture whose program referenced
data/params instead of the literal order, the same configuration correctly
returned insufficient-evidence, reasoning that without the input value a
wrong input and a wrong component are indistinguishable. Both answers were
right about their own evidence.
A contract-valid report is still not a correct one. A third run returned a
confident diagnosed report whose evidence lines were all accurate and
genuinely read, and whose conclusion was wrong — it blamed the component that
never calls the unused decoy. Nothing in the substrate could have prevented
that, because the substrate deliberately does not choose a diagnosis. Treat a
single sample as one opinion.
That is the reason this layer stops where it does. Where a claim is mechanically testable, test it: apply the proposed change and run it against host-owned cases before believing it. The model may diagnose and author; whether a change is accepted stays with the host.
Scope and limits
This substrate is structural navigation, not automatic debugging:
- it reports evidence and typed links, and never selects a diagnosis;
- the walk follows dependency edges only — not callers, data flow, or capability side effects;
- it assumes a captured failure with retained generated source; a run that produced no program leaves nothing to walk;
- host validation, not a model report, decides whether a repair is accepted.
The one shape it demonstrably covers is a complete single-call failure whose relevant source lies inside the called component's dependency closure. Discovery outside that closure, and repair of nonlocal or weakly specified defects, are not established.
Next steps
- Kernel REPL covers
private-run-analysis-v1for interactive or unattended investigation of the same capture. - TraceLog and run-analysis reference defines every collection, filter, relationship, and completeness field.
- Running and debugging documents artifacts, diagnostics, and the transcript command.
- Components and preludes explains component identity, dependencies, and the shipped libraries.