Repository navigation
Description
Spec section affected
docs/verification.md — "Action receipts and embodied workflows" (the four-outcome table); spec/trace-v0.2.md §3.3 (verification protocol, receipt outcomes); schema/trace-claim.json (one new optional claim — schema PR to track the spec PR per CONTRIBUTING).
Problem
The four action-receipt verifier outcomes conflate two materially different missing-receipt cases under receipt_missing_required:
- an adversarial or negligent silence — a receipt for a consequential action is simply absent; and
- an operator-disclosed, bounded loss — the emitter crashed, lost a tail of receipts, and said so.
Under a profile with action_receipts: required (#66), completeness is the load-bearing property — but no emitter can be made gap-proof: any high-throughput writer has a crash window in which a tail of receipts is lost. As currently specified, an honest operator who loses 50 ms of receipts in a crash produces the same verifier outcome as an adversary who silently deleted the receipts for a consequential action.
This creates a perverse incentive: backfilling or fabricating receipts scores better than disclosing the loss. An evidence standard should reward honesty under failure, not punish it.
Proposed change
1. New optional, chain-bound claim GapDisclosure:
{
"type": "GapDisclosure/1.0",
"range_start_after": "<chain position of the last receipt before the gap>",
"range_end_before": "<chain position of the first receipt after resumption>",
"cause": "crash | shutdown | backpressure | unknown",
"receipts_lost_estimate": 3,
"disclosed_at": "<chain position where this disclosure is sealed>"
}- Gap boundaries MUST be expressed in chain/log order, not timestamps — a key that signs receipts also signs its own timestamps, so self-reported time cannot bound a gap (same rationale as the entry-ID anchoring in [pre-v1.0] Key revocation: TraceRevocation claim + entry-ID anchor #67).
- The disclosure MUST be signed by the receipt-issuing key or its hierarchical parent, and MUST itself be sealed into the receipt chain at the resumption point, so the disclosure is as tamper-evident as the receipts it accounts for.
receipts_lost_estimateMAY be null when the emitter cannot bound the count; the range bounds are still normative.
2. Fifth verifier outcome, and a re-scope of the fourth:
| Outcome | Meaning |
|---|---|
receipt_gap_disclosed |
The profile required receipts and one or more are missing, but a valid GapDisclosure covers the missing range. Bounded, operator-attested negative evidence — the evidence-layer analogue of receipt_valid_rejected's "valid rejection is not malformed evidence". |
receipt_missing_required (re-scoped) |
A required receipt is missing and no valid disclosure covers it. Silent, presumptively adversarial. |
3. Verifier obligations (normative):
- The verifier MUST validate the disclosure's signature, its chain binding, and that the disclosed range covers the missing receipts.
- The verification statement MUST report disclosed gaps (range, cause, estimate) rather than collapsing them into a boolean — the gap detail is exactly what a relying party's policy needs.
- Whether
receipt_gap_disclosedis accepted (degraded-with-disclosure) or rejected (fail-closed) MUST be a verifier policy input, not implementation-defined behavior. - A
GapDisclosurethat fails validation, or whose claimed coverage contradicts receipts actually present, MUST yieldreceipt_invalid— a forged or self-contradictory disclosure is worse than none.
Backward compatibility
- Non-breaking (new optional field, informative addition)
- Breaking
One new optional claim + one added outcome. Suggest gating the new outcome under the widened profile version already contemplated in #114, so existing v0.2 verifiers are untouched.
Motivation
Production precedent: I operate a per-action receipt system whose emitter writes a functionally equivalent disclosure on crash recovery (bounded tail loss, chain resumes, disclosure sealed into the chain) — and relying parties treat a disclosed gap very differently from a silent one. Without this distinction, TRACE profiles with action_receipts: required are either unachievable at production rates or quietly dishonest — "perfect or broken" chains pressure operators to backfill.
If accepted, I will submit the spec PR after the comment window, the schema PR tracking it, and conformance vectors for trace-tests:
- receipts dropped, no disclosure →
receipt_missing_required - receipts dropped, valid covering disclosure →
receipt_gap_disclosed - disclosure present but range does not cover the missing receipts →
receipt_missing_required - disclosure signature invalid or not chain-bound →
receipt_invalid - disclosure claims a gap while the "missing" receipts are in fact present →
receipt_invalid(contradiction)
Related issues or PRs
#66 (action_receipts axis — this is the ADR offered there) · #76 (disclosure vs. silence at verify time) · #67 (log-order anchoring rationale) · #114 (profile-versioned enum widening) · #116 (verification-statement content)
Activity
github-actions commentedon Aug 2, 2026
❔ Contributor Check: UNKNOWN
Automated check by AgenTrust Contributor Check.
docs(decisions): the vectors are in the right repository, and close o…
docs(proposals): settle the four open questions behind agentrust-io#117…
test: regenerate the gap-disclosure vectors under the resolved design
imran-siddique commentedon Aug 6, 2026
Thanks for this, and for the detail in the write-up.
A process note on disclosed receipt gaps via a GapDisclosure claim.
Normative spec text, meaning anything carrying an uppercase RFC 2119 keyword, needs an organizational sponsor accountable for the requirement. That is a provenance rule about who stands behind a requirement others implement against, not a judgment on the proposal or on you. Anyone may propose a normative change, and where a proposal is accepted without a sponsor, a maintainer carries the PR.
So this stays open. I have labeled it
policy: take-internal, which means the substance goes through review here and a maintainer carries any resulting spec change. This issue gets updated with the outcome either way.Two things are unaffected by the rule and are welcome directly: non-normative work such as documentation, informative crosswalks, examples and test vectors, and implementation work in the SDKs and tooling.
The rule is in CONTRIBUTING under Spec changes, pointing at
GOVERNANCE.md#who-may-author-normative-text. If that was not easy to find before you put this work in, say so and I will make it more prominent.lywinged commentedon Aug 7, 2026
The rule was not hard to find. It did not exist yet.
#116 and #117 were filed on 2 August.
GOVERNANCE.md#who-may-author-normative-textlanded on 4 August in #120, and that same PR added the pointer from
CONTRIBUTING.md.Before it, the spec-change checklist went from "allow 5 business days for comment"
straight to "submit a PR", with no mention of a sponsor.
So the sequence was the one your #120 description predicted — work first, rule second —
and the fix worked. Since reading it I have written two normative drafts and submitted
neither. Everything offered since has been in the set you name here: test vectors in
#122, and a coverage question in #124 that carries no RFC 2119 keyword.
A correction to my own proposal, before the review spends time on it
Offered as analysis rather than as text, and it is not a defence of the field list — it
is the opposite. I no longer think the field list in this issue holds, and I would
rather say so now than have it reviewed as written.
The issue rejects timestamps as gap boundaries because "a key that signs receipts also
signs its own timestamps". That argument does not stop at timestamps: an emitter-assigned
sequence number has the identical property, so reading "chain position" as an index keeps
the name and discards the property the name was introduced for. What remains is a hash.
Hashes are not ordered. So "the disclosure covers the gap" cannot be a range comparison —
there is no arithmetic that decides whether a third hash lies between two others. That is
the check the conformance fixtures could not express, and it is why they had to guess.
Two of the proposed fields make it concrete.
range_end_beforeanddisclosed_atareboth, in effect, the hash of the receipt that follows the gap — and that receipt does not
exist at the moment the disclosure is written. The emitter cannot know them. Writing the
disclosure afterwards is worse: a hash chain is append-only, so splicing an element in
behind an existing successor means rewriting the successor.
What the fixtures ended up implementing was
disclosed_at == range_end_before, whichcompares two fields of one object that one party controls completely. It can fail for a
buggy emitter and never for a lying one. That is the signature of a check that establishes
nothing, and it is in the set because the issue asked for something unbuildable rather than
because anyone was careless.
The shape that survives is smaller than what I proposed. A disclosure carries
previous_receipt_hash, like every other element in the chain, and the next receipt linksback to it:
Coverage stops being asserted and becomes structural: the chain is linear and unbroken, so
there is nowhere else the missing receipts could have been. Verification is two link
checks, both of which a verifier already performs on every ordinary receipt. No
disclosed_at, norange_end_before, no separaterange_start_after— the last of theseis
previous_receipt_hashunder another name.I have the full argument and the regenerated fixtures written up. Happy to post it here,
or to send it wherever the review actually happens — whichever is less noise for you.
Same answer to the process note on #116; replying once rather than twice.
carloshvp commentedon Aug 8, 2026
Conclusion: the revised chain-element shape is the viable direction, but the verifier claim needs to stay narrower than "covers the missing range."
With
GapDisclosure.previous_receipt_hash = hash(R7)andR11.previous_receipt_hash = hash(GapDisclosure), a verifier can establish that the signed disclosure follows the last retained receipt and that the next retained receipt follows the disclosure. It cannot establish that R8 through R10 ever existed, how many receipts were lost, or that the issuer did not selectively omit them. The issuer controls the signing key and the stream. The disclosure is therefore operator-attested negative evidence, not cryptographic proof of a bounded range.That distinction should be explicit in any resulting schema and verifier outcome:
range_start_after,range_end_before, anddisclosed_at; the chain links already carry the only checkable ordering;causeandreceipts_lost_estimateoptional and descriptive, never inputs to a coverage proof;receipt_gap_disclosedpolicy-controlled, and do not let it satisfy a profile that requires independently proven completeness.This preserves the useful distinction from silent deletion without overstating what the evidence proves. I did not run runtime tests because the revised object is not implemented on
main; validation was against the current receipt-chain verifier and the issue's proposed link semantics.lywinged commentedon Aug 8, 2026
All five points applied to the draft. Notes only where applying one changed something.
Stream binding was a live gap, not a wording fix.
session_idwas already in the disclosure object and already covered by the signature — and nothing compared it. So an honestly signed disclosure was transplantable: a genuine excuse for a gap that happened, replayed into a stream where receipts were concealed. It is now a MUST with rejection on mismatch, and a registered verifier rule with two vectors behind it. That one was in front of me in every fixture and I did not see it.causedemoted to descriptive. It was a required enum. A vocabulary constraint on a field nothing corroborates is precision without evidence, so it now sits besidereceipts_lost_estimate: both optional, and a verifier MUST NOT condition any outcome on either, MUST NOT treat either as established, and MUST NOT reject a disclosure because either disagrees with other evidence.range_start_after,range_end_beforeanddisclosed_atare removed, and acceptance ofreceipt_gap_disclosedis policy-controlled, with the draft stating explicitly that it never satisfies a profile requiring independently proven completeness. Your framing is now the draft's own "what this does not establish" text, with the three things named: that the missing receipts existed, how many were lost, and that omission was not selective. Operator-attested negative evidence, not proof of a bounded range.One thing your reasoning implies that my own tree had wrong. Checking the fixtures against the draft before replying: the draft says a disclosure not linked from both directions covers nothing, and the verifier was granting a clean disclosure at the live tail — no successor yet, everything else valid — full
receipt_gap_disclosed. Fail-open, and adversarially live, because a chain truncated immediately after a disclosure is indistinguishable from an honest tail, so whatever the tail is granted the truncation is granted too. Resolved with §3.3.1's principle a third time: no successor is an inability to check, not evidence of a defect. A clean tail is now unverified with a distinct advisory — accusing no one, conferring nothing, upgraded on re-verification once the chain resumes and the seal exists.Still open, and recorded rather than left to be found: the draft requires
typeto equalGapDisclosure/1.0and the fixture verifier does not implement that check, so the completeness suite cannot flag it — it only sees rules that exist. That is the class of gap the method does not close.None of this is offered anywhere. It is an unaccepted proposal and stays in the fork until the shape is settled and it has a sponsor.
Rebuild the fork on upstream instead of rebasing onto it
lywinged commentedon Aug 10, 2026
Posting the draft text, since the review is fully applied and the governance question that was holding it changed yesterday.
What moved. All five points from @carloshvp's review are in:
causedemoted to descriptive alongsidereceipts_lost_estimate,range_start_after/range_end_before/disclosed_atremoved, stream binding made a MUST with rejection on mismatch, acceptance kept policy-controlled, and the "what this does not establish" text carrying the review's precision. One thing I found in my own tree while applying them is in the text below: a clean disclosure at the live tail was being granted fullreceipt_gap_disclosedthough no successor seals it, which is fail-open and adversarially live.Why now. Both my drafts carried the same header: needs a sponsor or a Maintainer to carry it, neither has been sought. #127 changed what that means. A normative PR without a sponsor is no longer rejected on that basis, and a Maintainer may sponsor and carry it with the proposer credited. So the question is no longer whether I may put this in front of you; it is which shape you want it in, and that is yours to pick rather than mine to assume.
The text follows. Conformance fixtures implementing exactly this shape exist — eighteen of them, with a generator — and they need no sponsor either way.
Disclosed gaps in a receipt chain
Under a profile requiring action receipts, completeness of the receipt chain is the
load-bearing property. No emitter can be made gap-proof: any writer operating
asynchronously has a window in which a crash loses a tail of receipts. A specification
that offers only "complete" and "broken" therefore rewards concealment, because an
operator who backfills a lost receipt scores better than one who reports the loss.
A
GapDisclosureis a signed statement, occupying a position in the receipt chain, thatreceipts which would have occupied that position were never emitted. It is negative
evidence contributed by the emitter about itself.
Structure. A
GapDisclosureis a chain element. It MUST carry:type, the valueGapDisclosure/1.0;previous_receipt_hash, the digest of the chain element immediately preceding the gap,in the same form and computed the same way as on a receipt;
session_id, naming the receipt stream the disclosure belongs to;issuer_key_id, identifying the key that signed it;signature, over the canonical form of the disclosure with the signature field removed.It MAY carry
causeandreceipts_lost_estimate. Both are descriptive self-reports andnothing more (per the #117 review):
the receipts an estimate counts are absent by definition, so nothing in the chain
corroborates either field. A verifier MUST NOT treat them as established, MUST NOT
condition any outcome on their values, and MUST NOT reject a disclosure because either
disagrees with other evidence. They exist to be reported, not relied on. An earlier
draft made
causea required enum; a vocabulary constraint on an uncorroborated fieldis precision without evidence, and it was dropped with the review.
Stream binding. The
session_idis covered by the signature, and a verifier MUSTreject a disclosure whose
session_iddoes not match the receipt stream underverification. Without that comparison, a disclosure honestly signed for one stream is a
transplantable excuse for a gap in any other — replay, in the position where replay is
hardest to distinguish from recovery.
Chain binding. A
GapDisclosureMUST be spliced into the receipt chain at the pointof resumption. Concretely, its
previous_receipt_hashMUST name a chain element that ispresent, and the next chain element emitted after resumption MUST carry a
previous_receipt_hashnaming the disclosure. A disclosure that is not linked from bothdirections has not been sealed into the chain and MUST NOT be treated as covering
anything.
That requirement has a window in which it cannot be met honestly: at the live tail of
the chain — after the failure, before resumption — the sealing successor does not exist
yet. A verifier meeting a tail disclosure whose other checks pass MUST NOT report
receipt_gap_disclosed, and MUST NOT reportreceipt_invalideither: the absence of asuccessor is an inability to check, not evidence of a defect (the same principle as
spec §3.3.1's treatment of unknown keys). It MUST surface the disclosure as unverified
with a distinct advisory, and re-verification after the chain resumes upgrades or
impeaches it on the seal that then exists. This matters adversarially: a chain
truncated immediately after a disclosure is indistinguishable from an honest tail, so
whatever a verifier grants the honest tail, it grants the truncation.
Gap boundaries MUST NOT be expressed as timestamps or as emitter-assigned sequence
numbers. Both are signed by the same key that signs the receipts, so neither constrains an
emitter that is misrepresenting the gap. The chain links are the boundaries.
Issuer. A
GapDisclosureMUST be signed by the key that signed the chain element itsprevious_receipt_hashnames, or by an ancestor of that key in the hierarchy of §3.2.1. Adisclosure signed by any other key MUST be treated as invalid, whether or not that key is
otherwise trusted. A gap is the moment at which introducing an unrelated key is most
useful to an adversary and least distinguishable from recovery.
Consecutive disclosures. A
GapDisclosureMAY name anotherGapDisclosureas itspredecessor, which represents an emitter that failed again before emitting a receipt. A
verifier MUST report the number of consecutive disclosures. It MUST NOT reject solely on
that basis: an emitter failing repeatedly and disclosing each time is behaving better than
one that is silent.
Verifier outcomes
The outcome
receipt_missing_requiredis narrowed, and a fifth outcome is added.receipt_gap_disclosedGapDisclosureoccupies their position in the chain. Bounded, emitter-attested negative evidence.receipt_missing_requiredA verifier MUST report
receipt_gap_discloseddistinctly fromreceipt_missing_required. Collapsing them discards the distinction the disclosure wasissued to make.
Whether
receipt_gap_disclosedis accepted or rejected MUST be a verifier policy input,not implementation-defined behaviour. A relying party evaluating a payment authorisation
and one evaluating a telemetry batch will reasonably differ, and neither should have to
change verifier to express that. One bound on that policy is not negotiable: a profile
that requires independently proven completeness of the receipt chain MUST NOT accept
receipt_gap_disclosedas satisfying it. A disclosed gap is an attested absence, not aproof of completeness, and no policy setting may promote the former into the latter.
A
GapDisclosurethat fails signature verification, is not bound into the chain from bothdirections, names a session other than the stream under verification, is signed by a key
outside the permitted set, or whose claimed gap is contradicted by chain elements that are
in fact present, MUST yield
receipt_invalidrather than falling back toreceipt_missing_required. A forged, transplanted or self-contradictory disclosure isworse evidence than no disclosure: it is an attempt to convert silence into attestation,
and the attempt itself is a finding.
Reporting. A verification result MUST report each disclosed gap individually, carrying
at minimum the linked predecessor, the number of consecutive disclosures, and the
causewhen one was supplied. Reducing disclosed gaps to a count or a boolean discardsexactly the detail a relying party's policy needs.
Three notes that belong with it rather than in it.
The departure from this issue is deliberate. #117 lists
range_start_after,range_end_beforeanddisclosed_at. This draft has none of them.previous_receipt_hashisrange_start_afterunder the name the chain already uses; the other two are the hash of a successor that does not exist when the disclosure is written. Your review reached the same conclusion independently.What a disclosed gap does not establish, stated with the review's precision: not that the receipts were lost rather than suppressed, not that they ever existed, not how many were lost, and not that the issuer did not selectively omit them. What the splice proves is where the gap sits in the chain and nothing else. Every verifier claim above is written to stay narrower than "covers the missing range".
This is weaker alone than it is with #116. The profile gating this issue suggests — putting the new outcome behind a widened profile version so existing v0.2 verifiers are untouched — depends on a v0.2 verifier refusing a record under the widened profile rather than reading a fifth outcome it does not implement as one of the four. That is #116's subject. If only one of the two proceeds, #116 should go first, and I would rather say that than have this land on a version boundary that cannot carry it.
Unrecoverable gaps stay unrecoverable: a failure that destroys the signing key and its ancestors leaves an emitter unable to disclose anything, and the result is indistinguishable from silence. Nothing above closes that, and it belongs in
LIMITATIONS.mdas a boundary rather than being implied away.Parked work: agentrust-io#117 gap-disclosure and agentrust-io#116 ver…
Parked work: agentrust-io#117 gap-disclosure and agentrust-io#116 ver…
Rebuild the fork on upstream instead of rebasing onto it
imran-siddique commentedon Aug 30, 2026
@lywinged Nineteen days on a question you asked precisely so you would not have to assume the answer. That is the wrong way round and it is on me.
The shape: maintainer-carry. Open the PR.
You read #127 correctly. A normative PR without an organisational sponsor is no longer rejected on that basis, and a maintainer may carry it with the proposer credited. Authorship stays on your commits; what carrying means operationally is that I answer for the requirement, so if the text has to change under review it changes on my call rather than needing a sponsor to re-approve it.
I am doing the line-level review on the PR rather than against the draft in this thread. Spec text is easier to review as a diff against the section it lands in than as a block quoted in a comment, and after nineteen days the useful thing is to unblock you rather than to add another round here.
The defect you found in your own draft is the reason I am comfortable carrying this.
That is the single most important sentence in the thread. A disclosure mechanism whose best-case outcome can be obtained by disclosing at the tail, where nothing seals the claim, would have converted an honesty feature into a laundering one. Finding it while applying someone else's review, in text nobody had objected to, and then leading with it rather than quietly fixing it, is the standard I would like every normative contribution held to.
Please carry that case explicitly into the PR: the vector, the outcome it gets instead, and why the tail is different from a sealed interior gap. It should be a test, not a paragraph.
Two things I want to see in the PR description rather than discovered in review:
receipt_gap_disclosedis a fifth instance of a pattern that has surfaced across five threads this week: trace-spec#246 on revocation not performed, spec: where a verifier records an unresolvable citation: the cross-surface question from #187's review #190 on unresolvable citations, trace-tests#82's SKIP rather than PASS, agent-manifest#348 on undecidable admissibility, and this. All of them need "this was not established" to be a first-class outcome rather than a silent pass. I am taking the job of unifying that vocabulary, so expectreceipt_gap_disclosedto keep its semantics and possibly not its name. Do not block on that.@carloshvp, thank you for the review; all five points landing in the draft is why this can go straight to a PR.
spec: disclosed gaps in a receipt chain, and the receipt_gap_disclose…