Skip to content

spec: disclosed receipt gaps — GapDisclosure claim + receipt_gap_disclosed verifier outcome #117

Closed

Description

@lywinged

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:

  1. an adversarial or negligent silence — a receipt for a consequential action is simply absent; and
  2. 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_estimate MAY 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_disclosed is accepted (degraded-with-disclosure) or rejected (fail-closed) MUST be a verifier policy input, not implementation-defined behavior.
  • A GapDisclosure that fails validation, or whose claimed coverage contradicts receipts actually present, MUST yield receipt_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:

  1. receipts dropped, no disclosure → receipt_missing_required
  2. receipts dropped, valid covering disclosure → receipt_gap_disclosed
  3. disclosure present but range does not cover the missing receipts → receipt_missing_required
  4. disclosure signature invalid or not chain-bound → receipt_invalid
  5. 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

  1. github-actions commented on Aug 2, 2026

    @github-actions
    Contributor

    ❔ Contributor Check: UNKNOWN

    Check Result
    Profile UNKNOWN
    Credential LOW
    Overall UNKNOWN

    Automated check by AgenTrust Contributor Check.

  2. added
    policy: take-internalIndividual-authored normative spec change; handled internally per policy
    and removed on Aug 6, 2026
  3. imran-siddique commented on Aug 6, 2026

    @imran-siddique
    Member

    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.

  4. lywinged commented on Aug 7, 2026

    @lywinged
    CollaboratorAuthor

    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-text
    landed 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_before and disclosed_at are
    both, 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, which
    compares 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 links
    back to it:

    R7 ──prev──> R8..R10 were never emitted
     └──── GapDisclosure.previous_receipt_hash = hash(R7)
                └──── R11.previous_receipt_hash = hash(GapDisclosure)
    

    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, no range_end_before, no separate range_start_after — the last of these
    is previous_receipt_hash under 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.

  5. carloshvp commented on Aug 8, 2026

    @carloshvp
    Member

    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) and R11.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:

    • remove range_start_after, range_end_before, and disclosed_at; the chain links already carry the only checkable ordering;
    • keep cause and receipts_lost_estimate optional and descriptive, never inputs to a coverage proof;
    • bind the disclosure to the same issuer and receipt-stream/session identity as the surrounding receipts so a disclosure cannot be replayed across streams;
    • require the next retained receipt to link to the disclosure hash;
    • keep acceptance of receipt_gap_disclosed policy-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.

  6. lywinged commented on Aug 8, 2026

    @lywinged
    CollaboratorAuthor

    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_id was 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.

    cause demoted to descriptive. It was a required enum. A vocabulary constraint on a field nothing corroborates is precision without evidence, so it now sits beside receipts_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_before and disclosed_at are removed, and acceptance of receipt_gap_disclosed is 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 type to equal GapDisclosure/1.0 and 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.

  7. lywinged commented on Aug 10, 2026

    @lywinged
    CollaboratorAuthor

    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: cause demoted to descriptive alongside receipts_lost_estimate, range_start_after / range_end_before / disclosed_at removed, 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 full receipt_gap_disclosed though 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 GapDisclosure is a signed statement, occupying a position in the receipt chain, that
    receipts which would have occupied that position were never emitted. It is negative
    evidence contributed by the emitter about itself.

    Structure. A GapDisclosure is a chain element. It MUST carry:

    • type, the value GapDisclosure/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 cause and receipts_lost_estimate. Both are descriptive self-reports and
    nothing 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 cause a required enum; a vocabulary constraint on an uncorroborated field
    is precision without evidence, and it was dropped with the review.

    Stream binding. The session_id is covered by the signature, and a verifier MUST
    reject a disclosure whose session_id does not match the receipt stream under
    verification. 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 GapDisclosure MUST be spliced into the receipt chain at the point
    of resumption. Concretely, its previous_receipt_hash MUST name a chain element that is
    present, and the next chain element emitted after resumption MUST carry a
    previous_receipt_hash naming the disclosure. A disclosure that is not linked from both
    directions 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 report receipt_invalid either: the absence of a
    successor 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 GapDisclosure MUST be signed by the key that signed the chain element its
    previous_receipt_hash names, or by an ancestor of that key in the hierarchy of §3.2.1. A
    disclosure 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 GapDisclosure MAY name another GapDisclosure as its
    predecessor, 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_required is narrowed, and a fifth outcome is added.

    Outcome Meaning
    receipt_gap_disclosed Required receipts are absent, and a valid GapDisclosure occupies their position in the chain. Bounded, emitter-attested negative evidence.
    receipt_missing_required Required receipts are absent and no valid disclosure occupies their position. Silent, and treated as presumptively adversarial.

    A verifier MUST report receipt_gap_disclosed distinctly from
    receipt_missing_required. Collapsing them discards the distinction the disclosure was
    issued to make.

    Whether receipt_gap_disclosed is 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_disclosed as satisfying it. A disclosed gap is an attested absence, not a
    proof of completeness, and no policy setting may promote the former into the latter.

    A GapDisclosure that fails signature verification, is not bound into the chain from both
    directions, 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_invalid rather than falling back to
    receipt_missing_required. A forged, transplanted or self-contradictory disclosure is
    worse 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
    cause when one was supplied. Reducing disclosed gaps to a count or a boolean discards
    exactly 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_before and disclosed_at. This draft has none of them. previous_receipt_hash is range_start_after under 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.md as a boundary rather than being implied away.

  8. added a commit that references this issue on Aug 12, 2026
  9. imran-siddique commented on Aug 30, 2026

    @imran-siddique
    Member

    @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.

    a clean disclosure at the live tail was being granted full receipt_gap_disclosed though no successor seals it, which is fail-open and adversarially live

    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:

    1. What the claim does not establish. You already have text carrying @carloshvp's precision on this. It matters more than usual here, because a disclosed gap is a statement about missing evidence, and the failure mode is a reader treating disclosure as though it restored what was lost.
    2. The interaction with the undecidable state. receipt_gap_disclosed is 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 expect receipt_gap_disclosed to 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    policy: take-internalIndividual-authored normative spec change; handled internally per policy

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions