Repository navigation
Description
Summary
When a multi-agent workflow faults, or has a delegation revoked, part-way through, the evidence the stack produces cannot be verified end to end. The pieces each do their own job correctly, but there is no artefact that says "hop N never completed, and here is the signed statement of where", and no verifier that would accept one:
ca2a_verify.cross_check_trace_dagrequireslen(records) == len(chain)and nothing else.ca2a_verify.verify_trace_dagvalidates every element of the DAG as a TRACE record, so aGapDisclosure/1.0(trace-specspec/trace-v0.2.md§3.3.4) cannot be a DAG element.cmcpemits a claim only fromclose_session; an orphaned session is logged, not disclosed.- Neither the
ca2ahop record nor the cMCP claim carries a manifest digest (see the cross-reference totrace-spec#483below), so even a complete DAG cannot say which approved manifest each hop ran under.
The result is a black box exactly where a regulator or a counterparty most needs evidence: the failure window. A truthful emitter that discloses the loss gets the same verdict as one that silently dropped a hop, and a verifier operator has to choose between trimming the chain (which erases the revocation from the record) and failing the whole workflow.
I checked integrations (35 issues, 26 open PRs) before filing. #333 (revocation after dispatch, reference scenario) and #199 (acceptance harness for confidential workflows) are adjacent, and this issue is meant to give both of them a verifier-side contract to test against. Nothing open or closed covers the verifier gap itself, and GapDisclosure is not referenced anywhere in ca2a, cmcp or integrations on main.
Pinned at ca2a@945292b, cmcp@5c7ef0a, trace-spec@b938790.
Where it breaks
| Step | Code at the pinned SHAs | What happens |
|---|---|---|
| 1. Delegation + holder proof | ca2a ca2a_runtime/delegation/holder.py build_holder_proof |
Succeeds for the leaf. Nothing is recorded that the hop is now in flight. |
| 2. Fault or revocation before the hop's record is emitted | ca2a ca2a_runtime/trace_binding.py emit_dag |
The hop never produces a TRACE record. Absence is not representable. |
| 3. Offline verification | ca2a ca2a_verify/dag.py:239 |
cross_check_trace_dag raises DAG length 2 does not match chain length 3. |
| 4. Operator "fixes" the mismatch by trimming | same | cross_check_trace_dag(records, chain[:2]) returns without error. The revoked third credential disappears from the verified result. |
| 5. Emitter does the right thing and discloses | trace-spec §3.3.4, examples/action-receipts/gap-disclosure/ |
verify_trace_dag calls validate_json on every element (dag.py:169) and rejects the disclosure as an invalid TRACE record. The disclosure verifier exists only in tests/test_gap_disclosure_fixtures.py, not in any library ca2a can import. |
| 6. Retry creates an extra hop | emit_dag with the same credential twice |
verify_trace_dag accepts four records; cross_check_trace_dag raises DAG length 4 does not match chain length 3. A legitimate fenced retry and a replayed hop are indistinguishable. |
| 7. cMCP side of the same fault | cmcp session/manager.py:230 (close_session), :483 (generate_trace_claim), startup.py:910 (find_orphaned_sessions) |
The signed claim exists only if close_session runs. A crash leaves an orphaned audit chain that is warned about at the next start. The execution registry marks abandoned rows outcome_unknown (execution/registry.py:5) but no signed artefact leaves the gateway. |
Reproduction
Compact form, from a ca2a checkout at 945292b with trace-spec@b938790 on PYTHONPATH (src and conformance/src):
python - <<'PY'
import sys,time
sys.path[:0]=['src','examples/trace-dag']
import demo
from ca2a_runtime.trace_binding import HopContext,HopSpec,digest,emit_dag
from ca2a_verify import cross_check_trace_dag,verify_trace_dag
chain,keys=demo.build_chain(demo.SCOPES)
def hop(i): return HopSpec(subject=demo.SUBJECTS[i],signing_key=keys[i],context=HopContext.software(model_provider='p',model_id='m',image_label='x',policy_bundle_hash=digest(b'p')),iat=int(time.time()),credential_id=chain[i].credential_id)
T=[k.public_key() for k in keys]
for name,hops in (('fault',[0,1]),('retry',[0,1,1,2])):
r=emit_dag([hop(i) for i in hops]); print(name,'verify_trace_dag hops =',verify_trace_dag(r,trusted_keys=T).hops)
try: cross_check_trace_dag(r,chain)
except Exception as e: print(' ',type(e).__name__+':',e)
PY
fault verify_trace_dag hops = 2
ProvenanceLinkBroken: DAG length 2 does not match chain length 3
retry verify_trace_dag hops = 4
ProvenanceLinkBroken: DAG length 4 does not match chain length 3
Each DAG is individually valid, and the same DAG is rejected by the cross-check. Neither error says which of "crash", "revocation" or "replay" occurred, and neither can be resolved by anything the emitter can sign.
Full reproduction, including a spec-conformant GapDisclosure splice (run from a directory holding ca2a and trace-spec checkouts)
import base64
import hashlib
import importlib.util
import pathlib
import sys
import time
root = pathlib.Path(__file__).parent
sys.path[:0] = [str(root / "trace-spec/src"), str(root / "ca2a/src"), str(root / "trace-spec/conformance/src")]
import rfc8785 # noqa: E402
from ca2a_runtime.delegation.holder import build_holder_proof # noqa: E402
from ca2a_runtime.errors import CA2AError # noqa: E402
from ca2a_runtime.trace_binding import ( # noqa: E402
HopContext,
HopSpec,
build_trace_record,
digest,
emit_dag,
sign_trace_record,
trace_record_hash,
)
from ca2a_verify import cross_check_trace_dag, verify_trace_dag # noqa: E402
spec = importlib.util.spec_from_file_location("demo", root / "ca2a/examples/trace-dag/demo.py")
demo = importlib.util.module_from_spec(spec)
spec.loader.exec_module(demo)
now = int(time.time())
chain, keys = demo.build_chain(demo.SCOPES)
def hop(i):
return HopSpec(
subject=demo.SUBJECTS[i],
signing_key=keys[i],
context=HopContext.software(
model_provider="example-provider",
model_id="example-model",
image_label=f"ca2a-peer:{i}",
policy_bundle_hash=digest(b"policy"),
),
iat=now,
credential_id=chain[i].credential_id,
)
def attempt(label, fn):
try:
out = fn()
print(f"{label}: OK {out if out is not None else ''}")
except CA2AError as e:
print(f"{label}: {type(e).__name__}: {e}")
trusted = [k.public_key() for k in keys]
# The leaf holder proof for hop 2 is built (ca2a step) ...
hp = build_holder_proof(
keys[2], chain[2], audience="spiffe://ca2a.example/agent/retriever",
challenge="c1", requested_capability="tool:search", record_id="r-2",
)
print("holder proof for hop 2 built:", bool(hp.signature))
# ... then the workflow faults / hop 2 is revoked, so only hops 0-1 emit TRACE records.
recs = emit_dag([hop(0), hop(1)])
print("\n== A. mid-flight fault or revocation: 2 records, 3 credentials ==")
attempt("verify_trace_dag(records) ", lambda: verify_trace_dag(recs, trusted_keys=trusted).hops)
attempt("cross_check_trace_dag(rec, chain) ", lambda: cross_check_trace_dag(recs, chain))
attempt("cross_check_trace_dag(rec, chain[:2]) (verifier silently trims the chain)",
lambda: cross_check_trace_dag(recs, chain[:2]))
# GapDisclosure/1.0 per trace-spec section 3.3.4, signed by the key that signed the predecessor
prev_hash = trace_record_hash(recs[1])
gap = {
"type": "GapDisclosure/1.0",
"previous_receipt_hash": prev_hash,
"session_id": "wf-001",
"issuer_key_id": keys[1].public_key().public_bytes_raw().hex(),
"cause": "delegation_revoked",
}
gap["signature"] = base64.urlsafe_b64encode(keys[1].sign(rfc8785.dumps(gap))).rstrip(b"=").decode()
gap_hash = "sha256:" + hashlib.sha256(rfc8785.dumps(gap)).hexdigest()
print("\n== B. same fault, with a spec-conformant GapDisclosure spliced in ==")
attempt("verify_trace_dag([r0, r1, gap]) ", lambda: verify_trace_dag(recs + [gap], trusted_keys=trusted).hops)
# resumed hop 2 linked to the disclosure, as section 3.3.4 'Chain binding' requires
r2 = sign_trace_record(
build_trace_record(
subject=demo.SUBJECTS[2], iat=now, context=hop(2).context,
credential_id=chain[2].credential_id, parent_record_hash=gap_hash,
),
keys[2],
)
attempt("verify_trace_dag([r0, r1, r2->gap])", lambda: verify_trace_dag(recs + [r2], trusted_keys=trusted).hops)
attempt("verify_trace_dag([r0, r1, gap, r2])", lambda: verify_trace_dag(recs + [gap, r2], trusted_keys=trusted).hops)
attempt("cross_check_trace_dag([r0,r1,gap,r2], chain)", lambda: cross_check_trace_dag(recs + [gap, r2], chain))
print("\n== C. retry creates an extra hop: 4 records, 3 credentials ==")
retry = emit_dag([hop(0), hop(1), hop(1), hop(2)])
attempt("verify_trace_dag(records) ", lambda: verify_trace_dag(retry, trusted_keys=trusted).hops)
attempt("cross_check_trace_dag(rec, chain) ", lambda: cross_check_trace_dag(retry, chain))
print("\n== D. no manifest binding anywhere on the DAG ==")
print("HopContext fields containing 'manifest':", [f for f in HopContext.__dataclass_fields__ if "manifest" in f])
print("'manifest' in any emitted record :", any("manifest" in r for r in retry))Output:
holder proof for hop 2 built: True
== A. mid-flight fault or revocation: 2 records, 3 credentials ==
verify_trace_dag(records) : OK 2
cross_check_trace_dag(rec, chain) : ProvenanceLinkBroken: DAG length 2 does not match chain length 3
cross_check_trace_dag(rec, chain[:2]) (verifier silently trims the chain): OK
== B. same fault, with a spec-conformant GapDisclosure spliced in ==
verify_trace_dag([r0, r1, gap]) : TraceRecordInvalid: record 2 is not a valid TRACE record
verify_trace_dag([r0, r1, r2->gap]): ProvenanceLinkBroken: record 2 parent link does not match the previous record's hash
verify_trace_dag([r0, r1, gap, r2]): TraceRecordInvalid: record 2 is not a valid TRACE record
cross_check_trace_dag([r0,r1,gap,r2], chain): ProvenanceLinkBroken: DAG length 4 does not match chain length 3
== C. retry creates an extra hop: 4 records, 3 credentials ==
verify_trace_dag(records) : OK 4
cross_check_trace_dag(rec, chain) : ProvenanceLinkBroken: DAG length 4 does not match chain length 3
== D. no manifest binding anywhere on the DAG ==
HopContext fields containing 'manifest': []
'manifest' in any emitted record : False
Case B is the important one. The disclosure is signed by the key that signed the predecessor, names the predecessor by trace_record_hash, carries cause, and the resumed hop links to the disclosure's digest, which is every rule in §3.3.4 "Chain binding" and "Issuer". There is still no arrangement of the four elements the current verifiers accept.
Why this matters beyond a length check
- It rewards concealment, which is what §3.3.4 was written to prevent. The spec's own argument is that "an operator who backfills a lost receipt scores better than one who reports the loss." In
ca2atoday the best outcome for a disclosing emitter is a hard failure, and the best outcome for a silent one is a trimmed chain that verifies. - A revocation is erased by the workaround. Trimming
chainto matchrecordsis the only way to get green, and it deletes the credential that was revoked from the evidence. The revocation snapshot is then never consulted for that hop, because the hop is no longer in the chain under test. - Retries are unclassifiable. A fenced retry after a crash and an attacker re-presenting a hop both produce the same extra element. Without a fence rule the verifier can only reject both or accept both.
- The manifest is not in the picture at all.
HopContexthas no manifest member and no emitted record has amanifestkey (case D). The v0.3 draft addedmanifest {id, digest, media_type}(trace-spec#483, #538), andtrace-spec#483has my analysis of why cMCP cannot emit it yet. For this issue the consequence is that a retry or a resumed hop can run under a different manifest than the original attempt and nothing in the DAG would show it.
Proposed contract
This is a verifier and emitter contract; I am deliberately not proposing code while the PR queue is being merged (per the maintainers' request on trace-spec#653).
Chain model. A workflow's evidence is an ordered list of elements, each either a TRACE hop record or a GapDisclosure/1.0. cross_check_trace_dag reconciles by credential identity, not by count: every chain[i] must be covered by exactly one of (a) a hop record carrying that credential_id, signed by chain[i].subject, or (b) a verified disclosure whose position in the chain replaces it.
Disclosure verification. Reuse the rules and the vectors under trace-spec/examples/action-receipts/gap-disclosure/ as a library (they exist today only inside a test module): stream binding on session_id, issuer = signer of the predecessor or a permitted ancestor, two-sided splice, tail disclosure reported as unverified rather than disclosed or invalid, and receipt_invalid when the claimed gap is contradicted by an element that is present.
Outcomes. verify_trace_dag and cross_check_trace_dag return a result object rather than only raising: complete, gap_disclosed (with each disclosure's predecessor, consecutive count and cause), or fail-closed errors as today. A profile that requires proven completeness MUST NOT accept gap_disclosed, exactly as §3.3.4 requires.
Retry rule. An additional hop for an already-covered credential_id is admissible only if it follows a disclosure (or an explicit supersession link) and carries a higher fencing epoch than the attempt it replaces. Otherwise it is a replay and is rejected. This is where the compensation lease belongs:
draft-durga-agent-tx-compensation-lease-00defines the Pre-Release Transactional Compensation Lease (PTCL), acquired before a state-mutating call, withRECOVERY_WINDOW_INEQUALITY(t_req_ms <= t_granted_ms, and expiry checked against a monotonic clock) andMONOTONIC_CAS_FENCING(abort withERR_STALE_FENCE_EPOCHif the resource epoch is>= cas_fence_epochor the pre-image digest differs). The lease carriestrace_anchor_ref.- For this issue the lease gives the verifier a producer-independent trigger and a replay discriminator. A hop whose lease expired unreleased is the crash case, so the disclosure's
causecan name thelease_id. A retry is legitimate iff itscas_fence_epochis strictly greater than the crashed attempt's. A replay of the first attempt carries the stale epoch and is refused by the same rule that protects the system of record. draft-durga-agenttrust-trace-verification-00§5 specifies N-hop verification (signature validity, hash continuityhop[i].parent_record_hash == SHA256(JCS(hop[i-1])), scope attenuation, temporal monotonicity) but takes N from the hops themselves. It has no clause for a hop that is known to be missing. The disclosure gives that clause its input, and I would carry the fix into that draft at the same time.
The PTCL draft does not yet define how lease_id and the fencing epoch appear in a TRACE record or a GapDisclosure. That is a real open design question, and I would rather the maintainers choose the carrier (an optional cause detail on the disclosure, or a field on the hop record) than have me assume one.
Manifest binding. Once trace-spec#483 lands a carrier for manifest {id, digest, media_type} in ca2a hop records and cMCP claims, cross_check_trace_dag should also require that every hop of a workflow, and every retry, names the same manifest digest unless a signed supersession says otherwise. On the cMCP side, the orphaned-session path (find_orphaned_sessions) and the outcome_unknown recovery marker are the natural place to produce a signed disclosure for the claim that close_session never wrote.
Acceptance criteria
- A library entry point in
trace-specoragentrust-tracethat verifies aGapDisclosure/1.0and passes every existing vector in that directory, soca2aandcmcpdo not each re-implement it. ca2a_verify.verify_trace_dagacceptsGapDisclosure/1.0elements and reports each disclosed gap individually;cross_check_trace_dagreconciles by credential identity and no longer depends onlen(records) == len(chain).- Case A, B and C above become conformance cases in the shared acceptance harness (Build a reproducible acceptance harness for confidential workflows across components #199) with expected verdicts: A and B
gap_disclosed, C rejected unless fenced, and a profile requiring completeness rejects all of A and B. - Trimming the chain no longer silently succeeds: a verifier given a credential that has a revocation statement and no covering record or disclosure fails closed.
- A cMCP orphaned session produces a signed disclosure, or the verifier documents that it will report
receipt_missing_requiredfor it.
I have not opened a PR for any of this. If the maintainers agree on the shape, I can start with item 1 as the smallest piece once the queue allows.
Activity
github-actions commentedon Nov 4, 2026
🔴 Contributor Check: HIGH
Automated check by AgenTrust Contributor Check.