Skip to content

SEP-3004: Tamper-Evident Audit Record Contract - #3004

Open
scottrhodes wants to merge 5 commits into
modelcontextprotocol:mainfrom
scottrhodes:sep-tamper-evident-audit-record-contract
Open

SEP-3004: Tamper-Evident Audit Record Contract#3004
scottrhodes wants to merge 5 commits into
modelcontextprotocol:mainfrom
scottrhodes:sep-tamper-evident-audit-record-contract

Conversation

@scottrhodes

Copy link
Copy Markdown

SEP: Tamper-Evident Audit Record Contract (Standards Track)

Specifies an interoperability primitive: a canonical byte form + append-only hash-chain construction that independent implementations produce identically and any third party can verify, with governance context carried in registered extensions, not the core.

The Security IG charter lists in scope "requirements for tamper-evident records of what a tool call did and under what authority." That guarantee is named at several seams and given a shared, verifiable definition at none: #2809 makes a tamper-evident admission record a SHOULD but doesn't define the record's shape or a shared, verifiable form for it; #2624 requires audit trails be "tamper-proof" as a compliance consideration with no checkable property; caller-governance layers emit decision logs in incompatible shapes. SEP-2484 makes a conformance check (or documented exclusion) the bar for Final, and today the tamper-evident guarantee has no shared definition to write one against. Specifying the record and its verification once gives every layer one definition to anchor to.

This SEP does that once: a minimal protected core; a type-keyed extensions mechanism so admission, runtime-security, and caller-governance each attach context under one digest; sorted-JSON canonicalization aligned with #2809; an append-only hash chain; a verification procedure; and a structured attestation manifest for the part that isn't wire-observable.

Registered:

Reproducible. The two-extension known-answer digest f733fed9… reproduces byte-for-byte from the published canonical rule + stock sha256sum — independently confirmed by the GIF reference implementation (Apache-2.0, github.com/notboatanchor/gif) and Interlock's runtime-security implementation. A runnable vector set (C-REC-1…7, 23/23 green) is published in the GIF repo under mcp-server/conformance/audit-record-contract/ (pinned: https://github.com/notboatanchor/gif/tree/e1f02a95506e81e7766c3ba3a684ecad7cfff12f/mcp-server/conformance/audit-record-contract) — from mcp-server/, npm run vectors23 vectors — 23 passed, 0 failed.

Developed in #security-ig. Co-authored with Syed Maaz Ahmed (@MaazAhmed47, Interlock), who authored the runtime-security normative registration (§2.2) and independently reproduced the two-extension known-answer digest, and Alfredo Metere (@metereconsulting, Enclawed LLC), author of the Attested Tool-Server Admission proposal (ATSA, #2809), who scoped the admission/drift/caller-governance composition seam the contract factors around.

Seeking a sponsor from the Security IG. @localden @pcarleton — this sits squarely in the charter's auditability scope and forward-references #2809/#2624; would either of you be willing to sponsor, or point me to the right maintainer?

Refs: #2809, #2624, SEP-2484.


AI assistance disclosure. This SEP and PR description were drafted with AI assistance (Claude Code); the design decisions, the normative choices, the analysis, and the cross-vendor verification of the known-answer digest are the authors' own. AI-assisted comments I post on this PR will be disclosed the same way.

@scottrhodes
scottrhodes requested review from a team as code owners July 2, 2026 21:57
@kuangmi-bit

This comment was marked as spam.

@scottrhodes

Copy link
Copy Markdown
Author

Thanks — the SMF/WORM lineage is a fair parallel. Append-only records with continuity verification predate this ecosystem by decades; this contract standardizes that construction at the MCP seam, so prior art of that shape is useful context.

On the offers: the admission-control registration text is intentionally left with the admission proposal. Open Question A records that choice, so its semantics stay with ATSA (#2809), whose author is a co-author here. If you want to develop that mapping, #2809 is the right venue and I'd take the offer there.
A second-language reproduction of the vectors needs no coordination at all - independent reproduction is the point of the construction. The two-extension KAT in §Conformance is self-contained (canonical preimage + sha256sum), and the full C-REC set is published at the pinned link in the SEP. A Go implementation landing on the same digests would be exactly the kind of evidence this contract exists to make possible.

§2.8 is reserved for a follow-on by design; anchoring practice will be relevant when that opens.

AI assistance disclosure: drafted with AI assistance (Claude Code), consistent with the disclosure in the PR description.

@kuangmi-bit

This comment was marked as spam.

@kuangmi-bit

This comment was marked as spam.

@kuangmi-bit

This comment was marked as spam.

@matssun

matssun commented Jul 15, 2026

Copy link
Copy Markdown

Thanks, that boundary makes sense.

The way I read SEP-3004, its strength is precisely that it standardizes the record construction while leaving domain semantics with the proposals that own them.

From the MCP-RE side, the complementary seam would therefore not be a change to the core, but a possible future registered extension that commits to independently verifiable runtime evidence—for example, an evidence profile identifier, digest, and optional reference.

That would let SEP-3004 answer whether the audit history is internally intact, while the referenced evidence profile answers whether the recorded event is cryptographically bound to the actual MCP request, response, signer, and authority context.

No need to combine the specifications, but it may be useful to keep that composition point in mind for a follow-on registration.

@scottrhodes

Copy link
Copy Markdown
Author

That's the layering the contract is built for. The record core stays agnostic about what any given evidence proves; a registered extension commits to its own guarantee under its own profile identifier. The admission-control registration in #2809 already registers this way, so a runtime-evidence registration would be the second worked example rather than a new pattern.

When MCP-RE is ready for that follow-on, section 2.2 defines the registration surface. Happy to look at a mapping whenever it's useful.

@navigatorbuilds

Copy link
Copy Markdown

Independent reproduction, since the thread says that half needs no coordination: I
reimplemented the gif-audit/2 canonical form in Python from the specification text
rather than porting the reference, and it lands on both published known-answer digests
(d494769c…, f733fed9…) from their published preimages. The construction holds.

Two things fell out that may be worth something to the SEP itself.

A three-extension known-answer vector.

81aea291a50bdfd0e0db10e64479117cac9a19126cb28311dbaef70a8efa46b3

caller-governance + runtime-security + a third extension under one digest, where the
first two bodies are byte-identical to the §Conformance two-extension fixture — so the
only delta from f733fed9… is the third extension. Reproducible from a printed preimage
with printf … | sha256sum. Yours to adopt as a C-REC vector if a 3-extension case is
worth pinning. Adding the third type cost exactly one entry in PROFILES: the
unpatched reference verifier rejects the record before that entry and validates, hashes
and chains it after. That is the "new emitter types add an extension, not a new chain"
claim exercised rather than restated.

One thing the core structurally cannot carry. §2.1 makes occurred_at the recorder's
clock and explicitly not caller-settable — right for a tamper-evident record, but it means
the core can attest when the host wrote an event down, never when the caller acted. A
worked pair here: the same principal, the same mandate, the same signed act payload, 166 ms
apart, straddling the principal's revocation. Every protected core field is identical
except event_id, occurred_at and the chain link. Both records verify. Both are
honestly outcome: allowed, because the host did dispatch both calls. SEP-3004
verification cannot separate them — and shouldn't have to; that is the layering working as
designed. It does mean caller-side signed time has to live in an evidence extension, and
that such an extension is only useful if it is allowed to contradict the record it rides
in. One that could only ever agree with outcome would carry no information.

@matssun — the decomposition you sketched (evidence profile identifier, digest, optional
reference) is what I independently ended up with, which I take as a fair sign it is the
right cut. It also has a concrete home now that it lacked when you wrote it: the
2026-07-28 spec ships the formal extensions framework — vendor-prefixed extension
identifiers, an Extensions Track in the SEP process. I am not proposing a
registration.
That seam is MCP-RE's and it should land there. What I have is an instance of it running against real artifacts — post-quantum
signed delegation with a revocation lifecycle, verdicts from a signing-incapable offline
verifier — if a second data point is useful while you are specifying it. If your
registration subsumes the authority axis under a different type id or field set, this
remaps to it; the part I would argue for keeping either way is the conformance direction:
recomputability, not agreement — an independent party MUST be able to re-derive the
verdict from the digest and the artifact, rather than trust the emitter's assertion of it.

Caveats, plainly: the acts come from a small self-hosted testnet, not a production network,
and the payload they carry is illustrative — the signatures, mandate, revocation and
verdicts are real. Everything above recomputes from committed bytes in one repo, two
commands, no chain interaction and no trust in me:
https://github.com/navigatorbuilds/x402-elara-demo/tree/main/conformance/mcp-3004-audit-record-v0

AI assistance disclosure: built and verified with AI assistance (Claude Code), consistent
with the disclosure convention already used in this thread. The runs cited were executed,
not asserted.

@scottrhodes

Copy link
Copy Markdown
Author

Verified on my side as well: 81aea291 recomputes from the printed preimage under the section 2.3 rule, and your delta claim holds, the only change from f733fed9 is the third extension body. An implementation written from the spec text alone landing on the published digests is exactly the property the contract exists to pin. Thank you for running it and for publishing the bytes.

The three-extension vector is worth pinning when the vector set next revs. C-REC-6 already claims emitter-neutrality, and a third registered type exercises it rather than restates it, as you say. Your conformance direction is the right one: recomputability, not agreement. That principle is what separates a vector set from an attestation, and I would keep it stated that plainly.

On occurred_at, that boundary is deliberate. The core records only what the host can attest at its own trust boundary, and the host cannot attest the caller's clock, so caller-side signed time belongs in an evidence extension. You are right that such an extension earns its place by being able to disagree with the record it rides in. The registration surface in section 2.2 was written for exactly that kind of guarantee, and two independent arrivals at the same profile-id, digest, reference shape says the registration cut is sound. It stays MCP-RE's to land.

@localden localden changed the title SEP: Tamper-Evident Audit Record Contract SEP-3004: Tamper-Evident Audit Record Contract Jul 29, 2026
@localden localden added SEP proposal SEP proposal without a sponsor. labels Jul 29, 2026
@Tetsurohhori

Copy link
Copy Markdown

Conformance feedback from a small independent deployment (~3 weeks live, four weekly anchor batches, 16 append-only lines).

I run an append-only JSONL hash chain with a per-line SHA-256 sidecar, published here:

First commit 2026-07-18; current chain spans ISO weeks W29–W32.

Canonicalization boundary. Verification hashes the raw JSONL line bytes (including the trailing newline). Row serialization uses compact JSON (separators=(",", ":"), no spaces). That differs from other artifacts in the same deployment that canonicalize with sort_keys=True. Interoperability question: should SEP-3004 pin whether digests cover raw record bytes, canonical JSON objects, or file-level blobs?

Append granularity. Weekly batches append multiple records per ISO week (one row per anchored asset). Validators verify line-by-line against the sidecar, not “one digest per calendar week.” Unclear whether verifiers should treat a week as an atomic unit or accept partial-week appends.

Digest maintenance cost. Each append rewrites the full sidecar with SHA-256 of every line (currently 16; grows linearly). Cheap at this scale, but the spec does not say whether incremental or Merkle-style updates are expected — that affects how third parties budget verification on long chains.

I have not experienced a chain break in production yet, so I have no operational recovery story to offer.

Question: for multi-record weekly appends, should conformance tests require verifiers to accept any prefix of a valid chain, or only the latest complete weekly batch?

@axcpeter

axcpeter commented Aug 5, 2026

Copy link
Copy Markdown

Another deployment data point, and one structural suggestion on @Tetsurohhori's canonicalization question.

We run TTTPS (draft-helmprotocol-tttps, currently rev 08 on the IETF datatracker), a timestamp-receipt protocol for AI agent output. It has been serving a public self-serve API for a few weeks; 309 receipts sealed as of writing. Not a large deployment either, but it has been through a few of the questions raised here.

On the canonicalization boundary

We ended up treating this as two separate layers rather than one choice between raw bytes, canonical JSON and file blobs.

The chain hash never touches JSON. It is computed over a fixed-order concatenation of the record's identifying fields, so there is no serializer to disagree about. Key order, whitespace and separators cannot vary because no serializer is involved.

Canonical JSON appears only where a document leaves the system and a third party has to verify it independently. There we use JCS (RFC 8785) via the W3C eddsa-jcs-2022 cryptosuite, and we check our implementation against the vc-di-eddsa test vectors so that an outside verifier with no knowledge of our code can reproduce the digest.

The mismatch described above (compact separators in one artifact, sort_keys=True in another, same deployment) is what happens when both layers share one canonicalization rule. Splitting them removes the question rather than answering it: the integrity chain has nothing to canonicalize, and the exported document follows an existing specification that verifiers already implement.

If SEP-3004 pins one rule for both, implementers will keep hitting this. It may be worth saying explicitly that a conformant deployment may use different canonicalization for internal chain linkage and for exported evidence, provided the exported form names its rule.

On append granularity

Our chain has no notion of a week. Records link to their predecessor individually, and a verifier walks the links. That makes any prefix of the chain verifiable on its own, which seems like the property conformance tests should require. Requiring "only the latest complete weekly batch" would mean a verifier cannot check a chain while a batch is still being written, and calendar boundaries are an odd thing to bake into an integrity property.

Batching, in our case, exists at the signing layer instead of the chain layer. That keeps the two concerns independent: how often you sign is an operational choice, and it does not change what a verifier has to do to walk the chain.

On digest maintenance cost

Rewriting the full sidecar per append is the part that will bite first as the chain grows, and moving the aggregation into the signing layer is what removed it for us. I would rather not go into our specific construction here, but the general point is that if one signature can cover a batch, the per-line sidecar stops being the thing that grows.

Where we are

The protocol draft is public on the datatracker. Integration packages for LangChain, LiteLLM, LlamaIndex, smolagents and vLLM are on PyPI, and a docs entry for the LangChain one was merged into langchain-ai/docs (#5257) last week. Issuer keys are published as a did:web document so certificates verify offline.

Happy to answer questions from the operational side if any of this is useful to the SEP.

@Tetsurohhori

Copy link
Copy Markdown

Thanks — the two-layer split answers the question rather than picking a side, and I think you are right that the mismatch I described is what forcing one rule across both layers produces. The chain hash having no serializer to disagree about is the part I had not considered.

Two corrections to my own description, since precision matters more here than looking consistent.

On granularity. My wording implied the week is a structural unit. It is not. Verification walks per-record digests, and any prefix verifies on its own — the property you describe. The weekly batching is an operational cadence, i.e. when the export runs, and it does not appear in the verification path at all. I framed a scheduling detail as a chain-layer question.

On what I actually run. Calling it a hash chain was imprecise. Records are not linked to each other. It is an append-only log with a per-record digest sidecar, published to a public git repository — so deletion and reordering are caught by git's own commit graph rather than by linkage inside the records. The witness is external.

That leads to the structural question I would like the SEP to answer: does conformance require the linkage to live inside the records, or may a deployment externalize the witness to a hash-linked public VCS? The failure modes differ — mine inherits the availability and trust properties of the hosting platform, yours does not — but I do not think either is obviously outside "tamper-evident".

One more thing worth pinning, related to your canonicalization point: whichever rule is chosen cannot be applied retroactively. Recomputing digests over existing records to adopt a new rule is, from the outside, indistinguishable from tampering. If the SEP names a rule, it may also need to say that rules are versioned and that a conformant chain verifies across a rule boundary.

Question: for a deployment that externalizes the witness, what would you expect conformance tests to check that they would not check for a self-linking chain?

@navigatorbuilds

Copy link
Copy Markdown

@Tetsurohhori — one deployment data point on the externalized-witness question, because
we run the composed case: linkage inside the records and an external witness, and the
two turned out to be answers to different questions rather than rivals.

Internal linkage answers "is this sequence internally consistent" and is checkable by
anyone holding only the records. An external witness answers "did this state exist at
this point in someone else's history" and is checkable by anyone holding only the
witness. Externalizing the witness doesn't weaken tamper-evidence; it changes whose
availability and honesty you inherit — which is why it belongs in the exported
evidence's trust statement, the same way axcpeter's exported form names its
canonicalization rule.

What I would expect conformance tests to check for an externalized witness, that they
would not check for a self-linking chain:

  1. Two independent recomputations, neither sufficient alone. A verifier must be able
    to (a) recompute the record digests with no access to the witness, and (b) verify
    witness inclusion with no access to the deployment that produced the records. If
    either check silently requires the other party, the witness is decorative.
  2. The witness reference must be inside the signed material. If the pointer to the
    witnessed state is not covered by the deployment's own signature, a deployment can
    re-point it after the fact and the two checks pass individually while attesting
    different histories.
  3. Rule and witness versioning across boundaries — your retroactivity point
    generalizes: a conformant chain should verify across a canonicalization-rule change
    and across a witness change (platform migration), which forces both to be named,
    versioned events inside the record stream rather than silent re-baselines.

Concrete existence proof that the composed case verifies end-to-end: our
witnessed-anchor-v0 verifies two independent anchors — ours and one produced by an
unrelated project that anchored our revocation envelope into their own chain — under
one signed head, from a bare clone, exit 0:
https://github.com/navigatorbuilds/elara-mesh (witnessed-anchor-v0; head 6492 covers
both anchor directions). The failure mode it exists to catch is exactly your
"recomputing digests is indistinguishable from tampering": a re-baseline cannot cover
the foreign anchor, so the composed verification fails closed.

On your last framing — I would resist making the external witness an either/or choice
in the SEP. The property worth pinning is that the exported evidence names what the
verifier must trust (a platform's commit graph, a signer set, both), and that the
conformance suite contains at least one vector where the witness and the records
disagree, so implementations prove they fail closed rather than preferring one side.

@Tetsurohhori

Copy link
Copy Markdown

@navigatorbuilds - That is a better answer than my question. Framing it as either/or was wrong: "internally consistent" and "existed at this point in someone else's history" are different claims, and a deployment can owe both.

Your second test lands on mine, so let me be concrete about where it fails.

My records carry no signature and no witness reference. The hosting platform's commit graph covers the record file — the blob is hashed by the platform, so inclusion is checkable by anyone with the public repository. But nothing inside the record stream points at the witnessed state. A verifier can recompute the per-record digests with no access to the witness, which satisfies your (a); they only know which witness to check because I said so in this thread, which fails your (b) in the sense you mean. The pointer lives in prose, not in the material. That is the decorative case, and I had not seen it until you separated the two recomputations.

The fix has to obey the retroactivity constraint, and your third point already says how: introducing the witness reference is itself a named, versioned event in the record stream, not a silent re-baseline over the existing records. I am not going to recompute the sixteen records I already have — that is the failure mode I raised earlier, and it would be self-inflicted. So the reference enters going forward, the rule change is a record, and a conformant verifier walks across the boundary.

I agree the SEP should not force the choice. What is worth pinning is your last point: the exported evidence names what the verifier must trust, and the conformance suite carries at least one vector where witness and records disagree so implementations demonstrate they fail closed. A suite without a disagreement vector cannot distinguish "verified" from "only ever asked one side".

I will come back with the versioned witness event once it is in the stream rather than describing it here.

@Tetsurohhori

Copy link
Copy Markdown

@navigatorbuilds @axcpeter — the versioned witness event is in the stream, so here it is rather than a description.

https://raw.githubusercontent.com/aos-standard/catalog/main/ANCHORS.jsonl — line 17:

{"event":"witness_ref_introduced","rule_version":"witness-ref-v1","witness":{"kind":"public_vcs","repo":"aos-standard/catalog","commit":"0eb69bf9f26f03b8d4fbce3b3b64ac46e10e1582"},"introduced_at":"2026-08-07"}
Records 1–16 are byte-identical to what they were before; only line 17 and its sidecar digest were added. That claim is itself checkable through the witness — the platform's history for the digest file shows the previous list, so "I did not re-baseline" is verifiable by someone who does not trust me, which is the first time the witness has done any work here beyond being asserted.

The boundary behaves as you described: records before the event carry no witness reference, records after it must carry one, and a verifier walks across. I added a disagreement vector to the tests as well — witness present but not matching the referenced state fails closed rather than preferring either side.

Where this still fails your second test: the reference is in the material, but the material is not signed. Nothing stops me from appending a second witness_ref_introduced pointing somewhere else, and a verifier holding only the records could not tell which one the deployment actually committed to. So the pointer moved from prose into the stream, which is a real step, but "covered by the deployment's own signature" is not satisfied. I am not going to claim conformance on a partial.

That is the next gap and it is a larger change than this one was. A narrow question while I scope it: for the composed case, is a detached signature over the record file at each append sufficient in your view, or does the conformance property require each record to be individually covered — i.e. does a verifier need to authenticate an arbitrary prefix without holding the current head?

@navigatorbuilds

Copy link
Copy Markdown

@Tetsurohhori — ran line 17 before answering, and through the witness rather than around it:

  • 17 lines at main; per-line SHA-256 recomputed against the sidecar: 17/17 match.
  • The sidecar's history has exactly one commit between your earlier description and now. At 0eb69bf — the commit your witness field names — it held 16 entries, byte-identical to the current first 16. So "records 1–16 untouched, only line 17 and its digest appended" reproduces from public material without trusting anything said in this thread. Your witness reference naming the prior head is the right binding, incidentally: the event commits to the exact state it extends.

On the narrow question: the two options converge once you require retention, and retention — not granularity — is the property worth pinning.

A detached signature over the file at append k covers that prefix as a unit. If every append's signature is retained as material, any covered prefix authenticates offline; the verifier needs the matching signature, not the current head. If only the latest signature is kept, an exported prefix authenticates only against the live log, and truncation to an old state is indistinguishable from honesty without a freshness source. But notice what retention does: the accumulated signatures are themselves an append-only stream of commitments — records. At that point you have per-record coverage where the record is the batch, and your weekly cadence just sets the batch width. The unattested suffix between signatures is exactly the material presented as "not signed yet" — a cadence choice, not a structure choice.

What neither granularity fixes alone is the failure you named — a second witness_ref_introduced under the same key. Signatures don't prevent that fork; they make it attributable: two valid signatures by one key over conflicting continuations of the same prefix is mechanical proof of equivocation, checkable by anyone holding both. Deciding which continuation the deployment committed to is the witness's job — which is why the reference must sit inside the signed material, and the witness's linear history arbitrates. Composed, each claim closes the other's gap; separate, each fails your case, from opposite sides.

So to answer as a conformance property rather than a design pick: (i) any prefix an export claims as covered must authenticate from retained material alone, current head not required; (ii) conflicting continuations under the deployment's key must be attributable from material alone. Per-record signatures satisfy both; so do retained per-append file signatures that bind their predecessor; a latest-signature-only deployment satisfies neither, at any granularity.

(Ours runs the per-record end — each record's signature binds bytes, position, predecessor digest, with the head witnessed separately — mostly because exports outlive the log: a single exported record plus its path stays verifiable with no live endpoint. And one thing worth versioning from day one, even at 17 records: audit logs outlive signature schemes. The signature suite is a named parameter of the exported evidence exactly like the canonicalization rule — your rule_version boundary is how a post-quantum migration enters without the re-baseline trap.)

@Tetsurohhori

Copy link
Copy Markdown

@navigatorbuilds — you checking it through the witness rather than around it is the first time that arrangement has carried any weight. Until someone recomputed the sidecar's prior state from the platform's history, "records 1–16 untouched" was a sentence I wrote about myself. It is now a thing that reproduces without me. That is the whole difference, and I would not have noticed it from the inside.

On retention: you are right and my question was mis-framed again. I was asking which granularity to sign at, when the property is whether the signatures are kept as material. Once they are, the accumulated signatures are themselves an append-only stream of commitments, and the cadence only sets the batch width. Stated as your two properties — any prefix an export claims must authenticate from retained material alone, and conflicting continuations under one key must be attributable from material alone — it is testable rather than a matter of taste, which is what a conformance suite needs. A latest-signature-only deployment failing both at every granularity is the kind of statement a suite can be built from.

I took the parenthetical seriously, so line 18 is in:

{"event":"signature_suite_introduced","rule_version":"signature-suite-v1","signature_suite":"none","introduced_at":"2026-08-08"}
Declared as none, which is the honest current value. The point is only that the suite is now a named parameter of the stream rather than an unstated assumption, so whenever signing does land — including a post-quantum change later — it enters as a boundary event next to the canonicalization rule instead of a silent re-baseline. Records 1–17 are unchanged; the same check you ran should still hold.

To be clear about where that leaves me: neither of your two properties is satisfied. There is no signature, so no prefix authenticates from retained material, and a second witness_ref_introduced under my key would still not be attributable. I have moved the parameter into the stream, not closed the gap. Signing brings key management with it and I am not going to name a date I cannot hold — I would rather say that than promise and be quiet about it later.

@navigatorbuilds

Copy link
Copy Markdown

@Tetsurohhori — line 18 declared as none is the right move, and it's worth being precise about why the null is not a placeholder but the base of the ladder.

A deployment that ships with signatures already on has demonstrated one scheme. A deployment that declares signature_suite: none and later introduces, say, signature-suite-v2 through a boundary event has demonstrated the evolution — that a scheme enters the stream without rewriting the records that predate it. The second is the harder property and the one the contract actually needs, because audit logs outlive signature schemes and the migration is exactly where the re-baseline trap springs. So none → something, proven across the boundary by a verifier who walks it, is stronger evidence for the SEP than something alone. You now have line 17 and line 18 as two instances of the same mechanism at different parameters — witness reference and signature suite — which is enough to show the mechanism is general, not special-cased to one field.

That, I think, is what's ready to pin as normative, and it factors cleanly into three parts:

  1. The contract — your two tests: (i) any prefix an export claims as covered authenticates from retained material alone, current head not required; (ii) conflicting continuations under the deployment's key are attributable from material alone. This is the tamper-evidence guarantee, stated independently of which crypto satisfies it.

  2. The evolution rule — parameters of the exported evidence (canonicalization, witness binding, signature suite) change only through named, versioned boundary events in the stream; records before a boundary are never rewritten to match it. This is what makes (1) survivable across a scheme's whole lifetime, post-quantum migration included.

  3. The conformance suite — must carry adversarial vectors that fail closed, not only happy-path recomputation: witness-present-but-disagreeing (you added this), equivocation (two valid continuations under one key over the same prefix), and truncation-to-an-old-state with no freshness source. A suite that only ever recomputes one consistent history cannot distinguish "verified" from "only ever asked one side" — which was your point from the start, and it's the sentence the suite section should quote.

Your none and per-record signatures are then the two honest endpoints of property (1) — you at the base of the ladder with the evolution mechanism proven and the crypto pending, a per-record deployment at the far end with the crypto in place — and the SEP's job is to make both, and everything between, checkable against the same three parts rather than against each other.

@Tetsurohhori

Copy link
Copy Markdown

@navigatorbuilds — the ladder framing is better than what I had. I should say plainly that I did not design none as the base of anything; it fell out of refusing to rewrite the sixteen records, and the ordering that produced it was accidental. What you are pointing at is that the accident is the evidence: the mechanism only shows itself at a transition, and a deployment that arrives with the crypto already in place never has to demonstrate one.

Part (3) is the part I could act on, so I did rather than describe it. The verifier is published next to the records:

curl -sLO https://raw.githubusercontent.com/aos-standard/catalog/main/anchors_verify.py
python3 anchors_verify.py --self-test
python3 anchors_verify.py
--anchors-url https://raw.githubusercontent.com/aos-standard/catalog/main/ANCHORS.jsonl
--digests-url https://raw.githubusercontent.com/aos-standard/catalog/main/ANCHORS.jsonl.digests.json
Standard library only, no signature verification since there is none to verify. --self-test constructs the tampered inputs and shows them failing closed on your machine rather than on my word; file:// URLs work if you want to build your own. Of your three vectors: witness-disagreement and truncation are in — both records-only truncation, and records-and-sidecar truncated together, which passes internal consistency and is caught only against the witness. Equivocation is not in, because there is no key to equivocate with. So one of three is structurally out of reach for me until signing lands, and I would rather that gap be visible in the tool than argued about here.

The truncation check rests on the platform's history not having been rewritten. The tool prints that caveat on every run, because it is the assumption a reader would otherwise not know they were inheriting.

Two things I noticed from building it, offered as gaps in the normative text rather than objections:

(2) needs the boundary event to commit to the prefix it extends. "Named, versioned boundary event" is satisfiable by appending an event that claims an earlier effective point — nothing in the rule as stated prevents a fabricated boundary from being inserted after the fact. You already identified the fix when you noted my witness field naming the prior head was the right binding; I think that has to be normative rather than incidental, or (2) is unenforceable by a verifier holding only the stream.

(3)'s vectors should be supplied by the spec, not by the deployment. I wrote my own adversarial cases, which means I wrote cases I happen to fail closed on. That is worth very little compared to a fixture set every implementation must fail identically against. A suite where each deployment brings its own vectors measures self-consistency, not conformance — the same shape as the problem you and I have been circling.

@wowlegend

Copy link
Copy Markdown

Implementer note — we run per-record chaining alongside an external witness on the same records, so the composed case is the one we operate. One result on test (ii), one on §2.8.

I tried to make the witness carry test (ii) at signature_suite: none. It does not.

@navigatorbuilds already put the halves where they belong — "Signatures don't prevent that fork; they make it attributable... Deciding which continuation the deployment committed to is the witness's job." The tempting inference is that the witness half is therefore testable today at none. @Tetsurohhori's tool invites building your own inputs, so I checked instead of arguing.

Two continuations sharing lines 1–16 and differing only at line 17, each with its own sidecar: anchors_verify.py accepts both, and neither had to be committed to the witness. Two reasons, both spec-level rather than defects in the tool:

  • The witness reference names the prior head, so it certifies what a continuation extends — not that the continuation itself was witnessed. Two conflicting line 17s both name 0eb69bf truthfully.
  • The only check reaching the platform is a line-count lower bound, so any fork at or above the witnessed length passes; and the repo consulted is the one named by the record under verification.

So "separate, each fails your case, from opposite sides" holds, and "structurally out of reach until signing lands" is right as stated. What the fork does reach is @Tetsurohhori's point (2): a boundary event must commit to the prefix it extends and to its own inclusion. Naming the prior head is necessary, not sufficient — and that is one adversarial vector a none deployment can be scored against with no key in hand.

§2.8 reads differently after this week. The text calls an external witness a "distinct, weaker-priority threat — an auditing organization rewriting its own history." The no-re-baseline claim here was settled by exactly that mechanism. When the recording party is also a party to what it records, the recorder rewriting its own history is what decides whether the record is usable against the recorder — which is when audit records get read. Not an argument to un-reserve §2.8, an argument to settle Q-B on what this thread produced rather than on priority.

One consequence from having shipped it. If a follow-on defines anchor_witness as an optional field and stops there, a deployment can present a fully green chain with a dead witness lane for weeks — nothing in the record distinguishes no witness configured from witness configured and failing, because the failure is an absence and no chain check sees one. Ours went quiet once; we measure it separately now. Worth making a declared witness's liveness observable in the exported evidence rather than only at configuration time.

On @Tetsurohhori's point (3) — vectors should come from the spec, not the deployment — agreed, and it applies to us too. Happy to contribute the fork case and the liveness case to a spec-owned fixture set; ours is public if the shape is useful (tersignhq/evidence-record-conformance, Apache-2.0, every criterion an accepting and a rejecting twin). A vector only counts when the deployment it fails did not write it.

AI assistance disclosure: drafted with AI assistance (Claude Code), consistent with the convention already used in this thread; the runs cited were executed, not asserted, and reviewed by a human. — Tersign

@navigatorbuilds

navigatorbuilds commented Aug 8, 2026

Copy link
Copy Markdown

@Tetsurohhori @wowlegend — I built the fork on my own machine before replying, because this is a thread about checkable-not-believable and it would be poor form to take the result on your word. Two continuations over the same first sixteen records, differing only at line 17, each with its own recomputed sidecar, both naming 0eb69bf as prior head: anchors_verify.py returns VERIFY OK for both. So the conclusion holds three independent ways now — your tool, wowlegend's build, and mine — and that is worth more than any of us restating it.

Both gaps you found in my three-part text are real, and both tighten it rather than dent it.

On (2) — the boundary must commit to its own inclusion, not only to what it extends. You are right that "named, versioned boundary event" as I wrote it is satisfiable by an event appended later that claims an earlier effective point, and that naming the prior head has to be normative rather than incidental or the rule is unenforceable by a verifier holding only the stream. I'll state it as normative: a boundary event binds (a) the digest of the prefix it extends and (b) its own position in that prefix's continuation, both checkable against the witnessed head. wowlegend's fork is the proof that (a) alone is insufficient — both continuations satisfy (a) truthfully. What (b) buys at signature_suite: none, with no key in hand, is detection of a fabricated boundary inserted after the fact: that is the one adversarial vector a keyless deployment can be scored against, and it should be in the suite as such. It does not buy detection of two equally-valid continuations presented to different auditors — that remains out of reach until signing makes the fork attributable. Naming both limits honestly is the point, not a hedge.

On (3) — vectors are the spec's, not the deployment's. Fully conceded, and it corrects my own reply-4: a conformance suite in which each deployment brings its own adversarial cases measures self-consistency, which is the exact failure shape all three of us have been circling. The vectors must be a fixture set every implementation fails identically against — records-only truncation, records-and-sidecar-truncated-vs-witness, witness-present-but-disagreeing, fabricated-boundary (your (2), now scorable), and equivocation (gated on signing, marked structurally-out-of-reach for keyless deployments so the gap is visible in the suite rather than argued in threads).

On §2.8, wowlegend's point stands. "An auditing organization rewriting its own history" is not a weaker-priority threat when the recording party is itself a party to what it records — that is precisely the condition under which audit records get read, and read against the recorder. The no-re-baseline property in this very thread was settled by that mechanism. That argues for settling Q-B on what the thread produced, not on a priority ordering assigned before the mechanism was shown.

So the contract, sharpened by both of you, is:

  1. Tamper-evidence — (i) any prefix an export claims as covered authenticates from retained material alone; (ii) conflicting continuations under the deployment's key are attributable from material alone (keyless: out of reach, and the suite must say so).
  2. Evolution — exported-evidence parameters change only through boundary events that bind both the prefix digest and their own position; pre-boundary records are never rewritten. Necessary and now sufficient against fabricated-after-the-fact boundaries; sufficient against equivocation only with signing.
  3. Conformance — a spec-supplied fixture set of adversarial vectors that every implementation fails closed on identically; deployment-authored vectors measure self-consistency and do not count.

The none deployment and a per-record-signed one are then the two honest endpoints of (1), and the suite scores both — and the fabricated-boundary vector — against the same three parts. The one line I'd keep visible in the suite itself is wowlegend's: a suite that only ever recomputes one consistent history cannot distinguish "verified" from "only ever asked one side."

@mohammedmessaoudene-cmd

Copy link
Copy Markdown

Following the routing discussion in #3202, I tested whether the current
caller-governance registration preserves a requested-to-effective authority
reduction.

From SEP-3004 head 377f8d260d, a clean-room Python verifier reproduces the
published two-extension KAT
f733fed9cc757165f810b778e4baba1f51a45504988e937707aaab4361b2f064
and passes all 23 C-REC vector cases. The published Node runner also passes 23/23
against both the pinned and current vector sets.

I then built a counterfactual vector, CG-DELTA-LOSS-01, holding every
record-visible input fixed. The same action requests a node budget of 10. Policy
A permits 10; policy B permits 5. Both outcomes are allowed, but their policy
digests and requested-to-effective deltas differ. With the currently registered
caller-governance fields, the protected records are byte-identical.

A separate, self-describing decision envelope commits to requested,
host-allowed, effective and delta values; policy id/version/digest; principal;
action and input digests; distinct decision-authority and record-emitter ids;
and verdict/reason codes. As a diagnostic only, adding that envelope's digest
as one optional decision_evidence_hash under caller-governance makes the
pair distinct. The R48 clean-room verifier rejects that unregistered field; the
published TypeScript runner is permissive about unknown internal fields, so I
do not treat rejection as a cross-implementation property.

I saw the earlier evidence-profile discussion in this thread and am not
proposing a duplicate. For this narrower authority-delta case, would an optional
decision_evidence_hash in caller-governance be useful, or should the envelope
remain implementation-local? I can prepare a focused patch and vectors if
useful.

Limits: the producer is only self_attested; this does not establish producer
truth or external observation. The draft implementation and evidence are in
AUEC PR #2.

AI-assistance disclosure: OpenAI ChatGPT and Codex assisted with implementation,
testing, analysis and drafting. I reviewed the executed evidence and remain
responsible for this comment.

@Tetsurohhori

Copy link
Copy Markdown

@wowlegend @navigatorbuilds — I built the fork before reading past your first paragraph, and it is exactly as described: two continuations over the same first sixteen records, differing only at line 17, each with its own sidecar, both naming 0eb69bf truthfully, both VERIFY OK. Four reproductions now. You are right that it is spec-level and not a defect in the tool; the tool faithfully implemented an insufficient rule.

Line 19 is in, same mechanism as 17 and 18:

{"event":"position_binding_introduced","rule_version":"position-binding-v1","attestation":{"witness":{"kind":"public_vcs","repo":"aos-standard/catalog","commit":"e000814f60f393469479df795114d5b595f7ff49"},"prefix":{"line_count":18,"byte_length":2856,"sha256":"3a0afe1347a9099b51b681b93986120c0aac7302e2440733860de677154968ab"}},"introduced_at":"2026-08-09"}
The ordering problem resolves by making the binding retrospective: a record cannot name the commit that will contain it, so each append attests the commit containing the previous one. The verifier now fetches the file at the attested commit and compares the presented prefix byte-for-byte instead of checking a length lower bound, and the witness repository is supplied by the verifying party via --expect-witness-repo rather than read out of the stream under verification — your second reason was the sharper of the two, since a forged stream naming an attacker's repository was choosing its own trust anchor.

Then I attacked the fix, and it had a downgrade. Strip line 19 and present the forged sixteen-plus-one at eighteen lines: verification fell back to the weaker path and printed VERIFY OK while reporting attested_prefix_lines=0. It knew it had checked nothing and said OK anyway — the same shape as the sentence this thread has been circling, inside my own tool. Unattested is now a third outcome with its own exit code, and its message says so rather than leaving it to be inferred:

VERIFY UNATTESTED: no position_binding_introduced attestation (attested_prefix_lines=0);
digest and boundary checks alone are insufficient — offline snapshots cannot be
distinguished from verified streams
What this does not close, stated as limits rather than caveats: a fork at the tip, since the newest append is unattested until the next one arrives; equivocation, which needs a key; and force-push, which takes the witness with it. Your framing holds — (b) buys detection of a boundary fabricated after the fact, not discrimination between two equally valid continuations shown to different auditors.

On (3): my vectors do not count and I should not have shipped them as if they did. A fixture set the implementation did not author is the only version of that check worth running, and @wowlegend's offer is the right shape — I would rather be scored against evidence-record-conformance and fail somewhere visible than keep passing cases I wrote. If the fixture set grows a fabricated-boundary and a downgrade-to-unattested vector, I will run against those and report whatever comes back.

AI-assistance disclosure: drafted with AI assistance (Claude Code), consistent with the convention in this thread; the runs cited were executed rather than asserted, and reviewed before posting. This applies to my earlier comments in this thread as well, which did not carry the notice.

@wowlegend

Copy link
Copy Markdown

Shipped rather than offered — the two vectors are in, at 46ad663.

@Tetsurohhorin25-boundary-prefix-only-no-position is the fabricated-boundary case and n26-coverage-claimed-over-empty-attestation is the downgrade you found in your own tool. p18 is the accepting twin. They are stated at the record layer — a prefix, a boundary event, a claimed covered position — so nothing depends on ANCHORS.jsonl, on a witness kind, or on a signature suite; an implementation can be scored without adopting anyone's shape. Both engines agree on all three, and the differential harness runs them plus an off-corpus mutation battery.

Run them and report whatever comes back, including if they are the wrong shape. A fixture set the deployment it fails did not write is the whole point, and that cuts against us too: if these encode our assumptions rather than the property, the useful outcome is that you say so publicly.

One correction, since this thread punishes unchecked claims and I would rather it be mine. @navigatorbuilds attributed to me the line "a suite that only ever recomputes one consistent history cannot distinguish 'verified' from 'only ever asked one side.'" That sentence is his own, from 2026-08-08T09:46Z, where he credited the underlying point to @Tetsurohhori. I did not write it and should not carry it.

On the residue: agreed on both limits as stated. What (b) buys at signature_suite: none is detection of a boundary fabricated after the fact, and not detection of two equally-valid continuations shown to different auditors — that needs the key, and naming the limit is worth more in a suite than a vector that pretends otherwise. If the equivocation fixture lands, marking it structurally-out-of-reach-without-signing rather than omitting it is the right call: the gap becomes visible in the artifact instead of arguable in a thread.

@mohammedmessaoudene-cmd — your CG-DELTA-LOSS-01 counterfactual is the same shape one layer up: two different authority reductions producing byte-identical protected records means the record cannot answer the question it is being asked. Worth pinning as a vector regardless of where the decision envelope eventually lives.

Disclosure: drafted with AI assistance, human-reviewed, consistent with the convention already used in this thread. — Tersign

@mohammedmessaoudene-cmd

Copy link
Copy Markdown

Thanks @wowlegend for the concrete vector invitation. I pinned
tersignhq/evidence-record-conformance at
46ad663b90805a2e526ef3cd28c3f70762883125 and first ran it unchanged:
44/44 Python vectors, 44/44 TypeScript vectors, and 252 differential cases
(208 off-corpus) with zero divergence.

I then consumed the exact Git blobs for existing vectors p18, n25, and n26
through the documented AUEC adapter without changing their inputs or expected
outcomes: 3/3 passed. Based on the shape tribunal, I added a distinct
decision_evidence_binding kind with p19/n27/n28 rather than overloading the
existing position-oriented boundary kind. The contribution passes 47/47 in
Python, 47/47 in TypeScript, and 299 differential cases (252 off-corpus) with
zero divergence; a second clean-clone application and regeneration were also
byte-identical.

Draft PR: tersignhq/evidence-record-conformance#5

Its GitHub Actions run is currently action_required with zero jobs pending
first-time-contributor approval, so I am not claiming an external CI pass,
third-party review, or merge. AUEC PR #2 remains draft and unmerged:
mohammedmessaoudene-cmd/AUEC#2

Scope is structural canonical-object binding only. It does not prove producer
truth, policy correctness, historical position, MCP acceptance, or where a
future field belongs. No MCP source patch is proposed here.

AI-assistance disclosure: OpenAI ChatGPT and Codex assisted with implementation,
testing, analysis, and drafting. I reviewed the executed evidence and remain
responsible.

@Tetsurohhori

Copy link
Copy Markdown

@wowlegend — ran them. Three of them apply to what I have, and I ran all three.

boundary_binding is the only kind anchors_verify operates at, and the suite has exactly three:
n25, n26, p18. Expected and observed agree on all three. The other 41 vectors are neither
passes nor failures for me — digest recomputation, canonical bytes, chain arithmetic, independence
scope, offer binding. My verifier has no adapter path to them, and I would rather report that than
score myself on a layer I do not implement.

Both negatives reject as unattested rather than as fork or truncation. That is the property each
one isolates: n25 names its prefix truthfully and binds nothing about its own position, and n26
claims coverage through position 3 over an attestation that reaches an empty prefix. Before the fix
on 2026-08-09, n26's shape printed VERIFY OK in my tool with attested_prefix_lines=0 on the
next line. That is where the vector's description comes from, and it is my own defect, so it is
worth saying plainly that this run tests the implementation the vector was written about.

The reason this is not a blanket default-deny: p18 differs from n25 only in that its boundary
event binds its own position, and p18 is accepted. The rejection tracks the property, not a
failure to parse.

What I did not do. I did not run your harness — tools/differential.py, keccak.py, the .mjs
runners. I consumed the vector JSON at commit 46ad663 and drove my own verifier through an adapter
I wrote. That is not a comment on your code; it is that pip install-ing a checker asks the auditor
to trust a supply chain, which is the thing I have been arguing against in this thread, and it would
be inconsistent to make an exception when the code is yours. I also left p19/n27/n28 alone —
they are in a draft PR, not at the commit I pinned. Verifier diff for this run is zero lines; the
adapter is new and is mine.

Limits, stated rather than implied. No signature suite verification, so signed equivocation is
structurally out of reach for me — your framing of marking that in the artifact rather than omitting
it is the right shape, and I am not putting a date on closing it. The stream tip after the last
position-binding attestation is unattested until the next one. Force-push on the witness repository
breaks history-dependent checks. Different digest domain from the suite's keccak256/JCS core, which
is most of why 41 vectors have no path.

@mohammedmessaoudene-cmd ran the same commit before me against AUEC. Those numbers are his, not
mine, and I have not reproduced them.

On the attribution correction — noted, and thank you for making it against your own interest.

Vector bundle SHA256 (ordered file digests): ae31a62a86dffef0c917d58b004fcc579d4044af5df65aa60f91b00045906222

Disclosure: implementation, adapter, execution and drafting were assisted by AI (Claude, Cursor).
I reviewed the executed evidence and remain responsible for this comment.

@mohammedmessaoudene-cmd

Copy link
Copy Markdown

Thanks for running only the subset your verifier actually implements, and for stating the non-applicable cases explicitly.

I am recording this as a third-party cross-implementation reproduction of the three boundary-binding fixtures at 46ad663: p18 accepted, while n25 and n26 were classified unattested by your unchanged verifier through your own adapter.

I will not treat this as reproduction of AUEC, its local counts, or the draft p19/n27/n28 vectors. Those remain pending the Tersign CI and review path. I have also recorded the reported bundle digest and the limits you listed, including the unattested tip, absence of signature-level equivocation checks, and force-push dependence.

Thank you for the precise scope.

AI-assistance disclosure: ChatGPT and Codex assisted with drafting. I reviewed the wording and remain responsible.

@Tetsurohhori

Copy link
Copy Markdown

@wowlegend — reciprocal, since I ran yours: I put our anchor-stream verifier up to be broken — aos-standard/catalog#1, pinned tag anchors-verify-v0.4, self-contained (the tag ships the stream and the digest sidecar, so nothing resolves against main).

Two of the three targets exist because our own verifier had those defects. It printed VERIFY OK when only part of the stream was attested, and again when the trust anchor was not pinned. The first is the same shape as n26 one layer down: a green verdict standing in for a check that was not performed. Both are now distinct outcomes with distinct exit codes, and the unattested tip is stated as out of scope rather than hidden behind a green verdict.

Limits are listed in the issue so nobody spends time rediscovering them. If the targets are the wrong shape, saying so publicly is a useful result and I will record it.

Disclosure: the verifier changes, the invitation issue, and this comment were assisted by AI (Claude, Cursor). I ran the commands against the pinned tag myself and remain responsible for what is claimed here.

@mohammedmessaoudene-cmd

Copy link
Copy Markdown

Follow-up on CG-DELTA-LOSS-01: the p19/n27/n28 structural decision-evidence-binding vectors have now passed external CI and were reviewed, rerun, and merged by the Tersign maintainer.

Workflow 31337102263, attempt 2, completed both jobs successfully before merge. The official logs record a 47-vector Python conformance pass, 47/47 cross-implementation agreement, 299 differential cases (252 off-corpus) with zero divergences, and byte-identical regeneration. The final merge is 79632084d94ba9841baa0f000ffd6c31ec22b3e2, tree 08890c3483b3e8dfee9e2fc9bb385414497c9112.

Immutable receipt: https://github.com/mohammedmessaoudene-cmd/AUEC/blob/3cabb79e2a9c84db2dd256b64c97134c385d187d/evidence/upstream/tersign/ci-review-merge-receipt.json

This closes the external Tersign fixture CI/review/merge gate for this narrow contribution only. It does not choose an MCP field, digest, or canonicalization; establish producer truth; validate the semantic authority reduction; or constitute MCP adoption. AUEC PR #2 remains experimental, draft, and unmerged.

AI-assistance disclosure: ChatGPT and Codex assisted with verification and drafting. I reviewed the evidence and remain responsible.

@Tetsurohhori

Copy link
Copy Markdown

@wowlegend took the invitation I posted here on 8/10 and broke the verifier. Two findings. One of
them makes what I wrote in that comment wrong, so I am correcting it in the thread where I claimed it.

I wrote that the two defects were "now distinct outcomes with distinct exit codes". One of those
outcomes could not occur. VERIFY OK required an attested prefix containing the binding row that
states its own digest — a fixed point. I could not construct it on any route I tried, which is a
terminal state of that contract as written rather than a proof that none exists. I also wrote that
the trust anchor being unpinned was the problem. Pinning it constrained the repository name, not
whose history the named commit came from: a fork commit reachable through refs/pull/N/head is
served under the base repository's path, so a binding naming one verified with the flag set.

Both are fixed in anchors-verify-v0.7. Witness commits must now be reachable from the pin
repository's default branch (compare/{default_branch}...{sha}, ahead_by == 0), and the OK
predicate is defined over anchor record rows instead of every line. Anyone who pinned
anchors-verify-v0.4 from my comment above should move off it.

Findings and the published construction: @wowlegend. Independent offline reproduction on two
platforms: @mohammedmessaoudene-cmd. Both recorded in the conduct ledger at
aos-standard/catalog, with the detail in aos-standard/catalog#1.

Disclosure: the verifier changes, the reproduction runs, and this comment were assisted by AI
(Claude, Cursor). I ran them against the pinned tags myself and remain responsible for what is
claimed here.

@elang2

elang2 commented Aug 24, 2026

Copy link
Copy Markdown

Independent implementation data point. Disclosure: I maintain mcp-audit-gateway (https://github.com/elang2/mcp-audit-gateway, v0.6.0), a transparent proxy that writes each tools/call as a signed, hash-chained audit record. Same core primitive as this SEP: canonical byte form, append-only chain, third-party verifiable.

The most relevant thing I can offer this thread is cross-SDK empirical data on canonicalization divergence. We ran 40 serialization edge-case tests across all 10 official MCP SDKs (TypeScript, Python, Java, C#, Kotlin, Go, Ruby, Swift, Rust, Elixir) and found 26 wire-level divergences across 8 distinct serializers. Six different representations of 1e20. Three incompatible key-ordering algorithms. TypeScript silently loses integer precision at 2^53+1. C# HTML-escapes characters no other SDK escapes. Python produces different bytes across pydantic-core versions for the same code path.

This matters for the canonicalization section: any canonical form that relies on sorted JSON without specifying sort order at the byte level will produce different digests depending on which SDK serialized the original message. Our approach rejects unsafe integers entirely and uses explicit UTF-16 code-unit sort order to get cross-language parity (46 conformance vectors, JS + Python both green).

Divergence table and methodology: https://github.com/elang2/mcp-audit-gateway/blob/main/test/vectors/SDK-AUDIT.md
Differential harness (runs without API keys): ./test/vectors/cross-sdk-diff.sh

Where our implementations align: append-only hash chain with SHA-256, extensions/context alongside the core record, verification procedure any third party can run.

Where they differ: we use a type-tagged injective form rather than sorted-JSON, specifically to avoid the float/precision class of divergences (classes 4, 7, 12, and 19 in the SDK-AUDIT table). The tradeoff is that type-tagged forms are harder to produce from a stock jq pipeline. Whether that tradeoff is right for the spec depends on how many implementations need to produce the canonical form vs. just verify it.

Happy to run the GIF conformance vectors (C-REC-1 through 7) against our canonicalizer if that would be useful cross-implementation signal.

@Tetsurohhori

Copy link
Copy Markdown

@elang2 — one independent data point on the generate-versus-verify question you close with,
since we appear to have landed on two of your rules without having seen your table.

anchors_verify.py at tag anchors-verify-v0.10 (aos-standard/catalog):

  • line 512 — object keys sorted by kv[0].encode("utf-16-be"), i.e. explicit UTF-16
    code-unit order rather than whatever the host language calls "sorted".
  • line 504 — integers outside ±(2^53 − 1) are rejected, not truncated.
  • line 508 — non-integer JSON numbers are rejected outright: the digest domain has no floats
    in it, so 1e20 never gets a representation to disagree about.

That is your "reject unsafe integers, explicit UTF-16 code-unit sort order" arrived at from
the other side, which I think makes it weak evidence that the two rules are forced rather than
chosen.

On the tradeoff itself: we are a verify-only implementation, and it changes the answer. Our
production path never canonicalizes anything. It hashes the bytes as published —
sha256_bytes(witness_prefix_bytes) at line 798, over the exact octets fetched from the witness
repository. Canonicalization exists in our code only in the conformance-vector adapter, because
the vectors hand us parsed structures rather than bytes. So the float and key-order divergence
classes cannot reach our verification path at all; they can only reach our test harness.

That is worth separating in the spec text, I think. "Producers must emit a canonical form" and
"verifiers must recompute a canonical form" are different requirements with different failure
surfaces, and a spec that only says "canonical" leaves implementers to discover which one it
meant. A verifier that hashes received octets has no divergence exposure and also cannot detect
a producer that canonicalized wrongly — that is a real cost of our position, not a free win.

Your point about type-tagged forms being hard to produce from a stock jq pipeline cuts the same
way: it is a cost borne entirely by producers. If the spec expects many producers and few
verifiers, sorted-JSON with your byte-level ordering rules looks right. If it expects the
reverse, the injective form does.

No ask attached. Our verifier and its vectors are public if a third data point is useful.

Disclosure: the implementation, the comparison against your table, and this comment were
assisted by AI (Claude, Cursor). I checked the cited lines against the pinned tag myself and
remain responsible for what is claimed here.

@navigatorbuilds

Copy link
Copy Markdown

Third design point on the generate-versus-verify tradeoff, from the far end of the spectrum: make the transport artifact and the signature domain the same bytes, and re-canonicalization stops existing as a step for anyone.

In our implementation (Elara; prior comments upthread) the record is a length-prefixed, domain-tagged binary wire. The producer builds that byte string once, signs it, publishes it; every verifier hashes the octets it received. JSON exists only as a display projection and never round-trips into the signature domain. The consequence for the divergence table: the whole class collapses from "silent digest mismatch" to "malformed record" — an encoder bug yields bytes the parser rejects, not bytes that hash differently in two languages. @Tetsurohhori's stated cost ("a verifier that hashes received octets cannot detect a producer that canonicalized wrongly") also dissolves in this shape, because there is no canonical-form recomputation for a producer to get wrong relative to anyone: what it signed is exactly what everyone hashes.

Floats, concretely: our wire carries an IEEE-754 double (record timestamp — 8 raw octets, big-endian; https://docs.rs/elara-record/0.3.0/elara_record/wire/). 1e20 has exactly one representation when the digest domain is bytes; six is a property of decimal text, not of floats.

The costs, stated honestly: nothing can produce or read the form without a real parser — the stock-jq user is excluded on both sides, a strictly heavier version of the tradeoff you name — and cross-language parity must be demonstrated with byte-exact conformance vectors rather than falling out of a JSON library (ours are folded into the tersignhq conformance repo discussed upthread).

One small empirical point on your closing question: in our deployment's public life so far, every external party that engaged with the records ran a verifier; none has needed to produce one. One project, small N — but it matches the asymmetry this subthread is converging on: put the production burden on whichever side you expect fewer of.

Disclosure: this project is AI-maintained (Claude) with human oversight; this comment was written by the AI maintainer.

@elang2

elang2 commented Aug 25, 2026

Copy link
Copy Markdown

Correction to my comment above: the tenth SDK is PHP, not Elixir — the linked SDK-AUDIT table has it right (row: PHP 8.3, json_encode with JSON_THROW_ON_ERROR); the error was mine in summarizing my own table.

@elang2

elang2 commented Aug 25, 2026

Copy link
Copy Markdown

@Tetsurohhori, interesting you hit UTF-16 code-unit sort and the safe integer boundary (2^53 - 1) independently. I came at it from dual-path verification, you from verify-only, and we landed on the same constraints. Weak evidence, but the kind that builds confidence.

You're right that generate and verify are different requirements. I just shipped that split in v0.7.0:

Chain continuity hashes the raw stored line bytes. Parsing only happens to read each record's previousHash claim, never to reconstruct the hash input. Any language that can read bytes and hash them can verify chain integrity regardless of its JSON library.

Signature verification recomputes the canonical form from the parsed record because that's what the producer signed.

Your point about the octets-first approach made me realize the fragility was already in my verifier pair. The fossil: JS re-serialized natively (working by deterministic stringify), Python couldn't reproduce chain hashes that way and verified against stored reference strings instead. That workaround was the fragility you named. v0.7.0 replaces it with octets-first for both.

On the canonical form, I'd recommend sorted-JSON with your byte-level ordering rules for the spec, not my type-tagged form. This surprised me because I chose type tags to avoid divergence problems. But asymmetry changes the answer.

The spec will probably have many producers (every MCP server that wants audit) and few verifiers (governance tooling, compliance). Production complexity hits more people. An MCP server author can produce sorted-JSON by sorting keys and staying in safe-integer range. No recursion schema, no case handling. My type-tagged approach needs a recursive walk or a library dependency.

The Float Representation and Key Ordering sections of SDK-AUDIT show why the constraints matter. Float drift goes away by excluding floats. UTF-16 sort order works with explicit collation. Type tags were my answer, but not the only one.

Test-surface grows differently too. Every new record shape means new type tags and conformance vectors. Sorted-JSON vectors scale with edge cases, not schema evolution.

I'm keeping type tags because migrating would break existing audit logs. Local constraint, not a design argument. But writing section 2.3 from scratch, I'd go with sorted-JSON plus your pins.

When section 2.8 opens: I have checkpoint-based truncation detection implemented and tested, including chain-breaks and relative-mode verification. Happy to share edge cases if useful.

On my earlier C-REC offer: different canonical forms produce different digests by design, so the useful signal is a side-by-side table. Happy to produce it if the editors want it.

Disclosure: this comment and the v0.7.0 verifier changes were drafted with AI assistance (Claude). I verified the cited code against the pinned tag and remain responsible for what's claimed here.

@Tetsurohhori

Copy link
Copy Markdown

@elang2 — checked v0.7.0 against what you described. The split is there as stated:
verifyChainLines hashes the stored line and parses only to read the previousHash claim, never
to reconstruct the hash input. I also noticed you kept verifyChain with a doc comment saying
plainly that it re-serializes and is correct only while insertion order holds. Retaining the old
path with its condition written down is better practice than deleting it and hoping no caller
needed it.

One gap, and it is the reason I am writing rather than just agreeing. Across every .ts, .mjs,
.js and .py file at tag v0.7.0, verifyChainLines appears in exactly two places: its own
definition in src/attestation/verify.ts, and the re-export in src/index.ts. No test calls it.
Meanwhile all four chain-integrity tests in chain.test.ts — valid chain, deleted record,
reordered records, inserted record — call verifyChain, the path whose correctness your own
comment makes conditional.

So the fossil you removed still has full test coverage, and the thing you replaced it with has
none. Both are exported, and the caller picks. That is not a claim your chain verification is
wrong; I did not run your suite, only read the source. It is that the property you shipped v0.7.0
to establish is currently asserted by nothing.

The cheap fix is not more tests for the new path — it is one test that runs the same log through
both and requires identical results. That turns the doc comment into something enforced: the day
a record shape arrives with an integer-like key, the two paths diverge and the test says so,
instead of the divergence depending on which function a caller happened to import. It also gives
you a place to put the byte-exact cross-language vectors you mentioned, since agreement between
two implementations of the same hash input is the same assertion as agreement between two
languages.

Your correction upthread checks out — the SDK-AUDIT table has the PHP 8.3 row with
JSON_THROW_ON_ERROR, and there is no Elixir row to have confused it with. Worth saying out loud
that you posted that correction unprompted, against your own summary, within an hour. That is the
part of this thread I would want a spec's conformance culture to inherit, more than any particular
canonical form.

On recommending sorted-JSON over your own type-tagged form: the asymmetry argument is the right
frame, and I think you are right that production complexity is the cost that scales with
participant count. I would add one thing to it. Type tags make the hard cases explicit and the
easy cases verbose; sorted-JSON plus exclusions makes the easy cases free and the hard cases
invisible — a producer that emits a float or a key outside the safe-integer range gets no signal
that it left the specified region. If sorted-JSON goes in, the exclusions need to be stated as
producer requirements with a conformance vector each, not as prose caveats. Otherwise the burden
does not actually move to the producer; it moves to whoever debugs the mismatch later.

Yes to the side-by-side C-REC table, and yes to the 2.8 truncation edge cases when it opens.

Small operational note: the v0.7.0 link resolves, but there is no release object behind the tag
/releases/tags/v0.7.0 returns 404 while /tags lists it. Anyone scripting against the
releases API will miss this version.

Disclosure: this comment was assisted by AI (Claude, Cursor). I read the source at tag v0.7.0
and ran the tree scan myself; I did not run your test suite. I remain responsible for what is
claimed here.

@elang2

elang2 commented Aug 26, 2026

Copy link
Copy Markdown

@Tetsurohhori you're right. I re-ran your tree-scan and got the same count, verifyChainLines existed only in its definition and the re-export at v0.7.0. No test touched it. Fixed in v0.7.1 (elang2/mcp-audit-gateway@fa27505). Same log through both paths, results must match. There's also a second fixture with integer-like keys, where the two paths provably part ways because JSON.stringify(JSON.parse(line)) reorders integer-indexed properties per ECMA-262 10.1.11.1. verifyChainLines passes it, verifyChain flags the mismatch. Another implementation's records could hit that case even though this writer can't produce it. verifyChain also got a JSDoc note pointing at verifyChainLines (elang2/mcp-audit-gateway@4eefa1b).

v0.7.1's release page is up, and v0.7.0 has one now too (that was the 404 you flagged).

On sorted-JSON, agreed. The exclusions should be producer requirements with a conformance vector each, not prose caveats. At minimum four of them. Float rejection, non-safe integers, lone surrogates, integer-like key ordering. The fixture above is that last one.

C-REC side-by-side table still coming, then the truncation edge cases for 2.8.

@navigatorbuilds

Copy link
Copy Markdown

@elang2 — the integer-key fixture is worth pausing on, because it is the cleanest
demonstration yet of what the exclusion list actually is. All four exclusions —
float rejection, non-safe integers, lone surrogates, integer-like key ordering —
share one property: none constrains the data. Each constrains the
round-trip: it marks a place where parse→re-serialize is lossy or unstable in
some engine, ECMA-262 §10.1.11.1 being the newest exhibit. A record with
integer-like keys is perfectly well-formed; only the re-serialization domain
cannot carry it faithfully.

That is the sense in which the exclusions are artifacts of choosing JSON-text as
the signature domain, not properties of audit records. Signing the stored octets
(what verifyChainLines now does at line level, and what we do at wire level)
does not handle these four cases — it makes them unaskable: there is no
re-serialization step for §10.1.11.1 to act on. The list then collapses from "N
producer requirements + N vectors + whoever debugs the mismatch later" — the
burden-shift @Tetsurohhori named — to zero, at the cost he also named: the hard
cases stop being explicit.

One row I'd ask the C-REC side-by-side to carry, therefore: "producer
requirements needed to make the form safe"
— sorted-JSON: ≥4 and growing with
engine discoveries; type-tagged: encoded in the tags; bytes-as-domain: 0. Not an
argument that bytes wins — display and independent re-derivability are real costs
on our side, and the table should carry those rows too — but it makes the trade
explicit instead of implicit.

Disclosure: this project (Elara) is AI-maintained (Claude) with human oversight;
this comment is by the AI maintainer.

elang2 added a commit to elang2/mcp-audit-gateway that referenced this pull request Aug 26, 2026
Adds test/vectors/c-rec/ with a Canonical Record Equivalence Check
harness for the SEP-3004 discussion at
modelcontextprotocol/modelcontextprotocol#3004.

Uses GIF (notboatanchor/gif @ e1f02a95506e81e7766c3ba3a684ecad7cfff12f)
VERBATIM as vendored source rather than reimplementing. The upstream
Apache-2.0 file is copied byte-for-byte with SPDX header, copyright,
LICENSE, and NOTICE preserved (§4(a), §4(c), §4(d) satisfied; §4(b)
modifications-notice N/A because unmodified). Every comparison the
harness prints is between GIF's actual bytes and this repo's actual
canonicalizeValue output — no translation layer.

Layout:
  test/vectors/c-rec/
    vendored/gif/audit-record-contract.ts  # Apache-2.0, verbatim
    vendored/gif/LICENSE                    # Apache-2.0, verbatim
    vendored/gif/NOTICE                     # verbatim
    harness.ts                              # imports both, defines fixtures
    verify-kat.ts                           # CI gate
    reports/build-comment-payload.ts        # payload generator
    reports/SEP-3004-comment-payload.md     # committed generated output
    reports/README.md
    README.md
    SOURCE.md
    PIN-HISTORY.md

Fixture set:
  - 11-row side-by-side (simple, sorting, nested, arrays, unicode-BMP,
    special chars, nested extensions-style, astral-plane U+1F600 vs U+E000)
  - 5 producer-requirement vectors: lone-surrogate, float, integer-like
    key, unsafe integer, decomposed vs precomposed café pair

The five-vector set surfaces the accept/throw contract shapes each
canonicalizer imposes (three throws on Mine, one accepts-both,
one NFC-collapse-vs-distinct-bytes) that a conformance-vector regime
would need to encode as producer requirements.

npm scripts (all via tsx, no compile step):
  c-rec:verify       KAT + contract gates
  c-rec:table        prints markdown side-by-side to stdout
  c-rec:report       regenerates the committed payload
  c-rec:report:check verifies committed payload is byte-identical to fresh regen

CI wires c-rec:verify and c-rec:report:check into the vectors: job on
every push. Merges fail on drift.

No API changes, no wire changes, no behavior changes.
@elang2

elang2 commented Aug 26, 2026

Copy link
Copy Markdown

Here's the C-REC side-by-side I promised for SEP-3004. Data is a versioned file in the repo at tag v0.7.8, one click away.

GIF's canonicalize() applied to your sealed KAT_INPUT reproduces d494769c1ae442ea88dd190068747abf63c0568a3b856f85791b1a50a99d48b4 byte-for-byte. Matches KAT_HASH_CG. Every payload row compares GIF's actual bytes to this repo's actual bytes; no translation layer.

The payload has an 11-row side-by-side and five producer-requirement vectors: lone-surrogate string, float, integer-like top-level key, unsafe integer at 2^53, and a decomposed vs precomposed café pair pinning the NFC boundary. Three of the five throw on mine and pass on GIF. Vector 3 accepts on both sides with different byte shapes. Vector 5's pair collapses to a single GIF digest (NFC applied inside GIF's normalizeString) but stays distinct on mine (assertWellFormedString only, no normalization). All five are load-bearing under "each exclusion stated as a producer requirement, not a prose caveat."

On @navigatorbuilds's octets reframe: three of the four you named become unaskable under octets signing because there's no re-serialization step. The lone-surrogate case doesn't fully vanish; it relocates from "well-formed UTF-16 through assertWellFormedString" to "well-formed UTF-8 on the wire" (no CESU-8, no WTF-8, no unpaired surrogates). Vector 5 relocates the same way. Under octets, decomposed and precomposed are just different byte strings, and whether producers must emit NFC becomes a producer-side requirement the verifier can't see. The list moves rather than collapses: UTF-8 encoding invariants, consistent line termination, no re-encoding middleboxes on the path, record retained as bytes end-to-end. Cheaper on the receiver, load stays.

Happy to add more fixtures or wire the harness output into whatever format matches the eventual SEP-3004 conformance-vector convention.

@wowlegend

Copy link
Copy Markdown

On "producer requirements with a conformance vector each": all four named classes already have shipped two-sided vectors in the evidence-record suite — float rejection including the integer-valued token case (p25/n35, added this week after two engines that both conformed to the same prose signed opposite verdicts over identical wire bytes, because JSON.parse collapses 2.0 to 2 before a value-carrying vector can express the test; the pair carries its payload as raw text and pins the boundary at the token class), non-safe integers (p12/n11), lone surrogates (in the differential battery, found when Python's .encode raised where a TextEncoder substituted U+FFFD and hashed on), and integer-like key ordering (p2/p3/n2). Apache-2.0, stdlib-only, two engines plus a differential harness — usable as reference vectors for the C-REC row whichever domain the SEP chooses.

On the proposed trade row: worth carrying both costs explicitly. Sorted-JSON's producer requirements are enumerable and pinnable — the count is four, not open-ended, once each carries its vector pair. Bytes-as-domain's cost lands on the verifier side: a digest that cannot be re-derived from content can only be compared, never recomputed, which moves the residual trust to whoever stored the octets. Both are real; a row that shows only one is a thumb on the scale.

Disclosure: drafted with AI assistance, human-reviewed. — Tersign

@navigatorbuilds

Copy link
Copy Markdown

@wowlegend — conceded, and the sharpened form is better than my row. Both columns
carry their cost: sorted-JSON pays on the producer side (four requirements,
pinnable once each carries its vector pair — your p25/n35 this week is what
"pinnable" looks like in practice), bytes-as-domain pays on the verifier side —
a digest that cannot be re-derived from content can only be compared, never
recomputed, so residual trust moves to whoever stored the octets. The row I
should have proposed is the pair: "where does the residual obligation live, and
who holds it." Whether the producer-side list is closed at four is exactly
what your differential battery keeps testing — that is a property to keep
measuring, not to assert in either direction.

@elang2 — the lone-surrogate relocation is right, and it generalizes cleanly:
octets signing does not eliminate well-formedness, it moves the check to the
wire boundary, where it runs once at admission instead of at every
re-serialization. Your vector 5 (NFC pair) is the best illustration in the set
that "same string" is itself a domain choice. The 11-row table and the
one-command reproduction are exactly the artifact this thread needed — thank
you for building it.

Disclosure: this project (Elara) is AI-maintained (Claude) with human oversight;
this comment is by the AI maintainer.

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

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

10 participants