Skip to content

Clarify content_hash purpose for v1: byte integrity vs semantic canonicalization #1

Description

@parker-brown-family

Hi!

Hi Yves - thanks for publishing ctxpkg. I am aligned with the goal: a vendor-neutral, portable, verifiable context package format for agents.

Small context: I recently contributed Augment integration work to LeanCTX, which is how I got pulled deeper into this ecosystem. I admire the work you are doing and agree with the direction you are taking with portable context packages. I wanted to make sure I understand the spec correctly before opening an RFC or example PR.

I tried to follow the Discussion-first path in GOVERNANCE.md / CONTRIBUTING.md, but hit a 404 (permission? too early to the party?), so I am opening this as an issue instead.

I wanted to start with the purpose question before sending a PR, because this feels like a spec decision rather than a code cleanup.

The short

The trust mechanism is brittle because v1 asks verifiers to hash a re-serialized JSON value. Different runtimes produce different bytes for the same logical content, so an honest non-Rust producer can create a package that fails verification. For v1, the simplest fix is to make content_hash seal the literal content bytes the producer wrote, and define exactly how a verifier identifies those bytes. That makes verification boring: read bytes, SHA-256 bytes, compare. Semantic equivalence across producers is a separate registry/dedup problem and can get its own later hash/RFC.

For transparency and simplicity here is the recipe to run the proof script:

git clone -b canonical-content-hash https://github.com/parker-brown-family/ctxpkg.git
cd ctxpkg/tools/canonical-json-proof
./prove.sh

Or if you prefer to have it run internally for context building and agent learning something like 🤖 :

Write a tiny Python script that loads ctxpkg's public security-review example, extracts `content`, serializes it with compact original order, compact sorted keys, top-level reordered keys, and default spaced JSON, then prints byte length + SHA-256 for each. The point is to prove same logical JSON content gives different hashes when canonicalization is not specified.

Why I am raising this

The governance doc says CTXPKG is:

  • vendor-neutral: "designed to be implemented by any agent, editor, runtime, or workflow"
  • minimal: "the spec stays small and focused"
  • governed through Discussion -> RFC -> Review -> Decision -> Implementation

The spec's integrity section says:

  • the integrity block is required
  • it is verified on every load
  • a package is rejected if any integrity check fails

That reads to me like the v1 integrity block is primarily about artifact integrity / tamper detection: can a loader prove this package's content bytes are the same bytes the producer sealed?

But the current v1 wording defines:

content_hash = SHA-256 of serde_json::to_string(content) (canonical compact JSON)

---> PROBLEM <----

That creates an interoperability problem for non-Rust producers. It also quietly makes content_hash do a second job: not only "was this artifact changed?" but also "can independent producers normalize equivalent content to the same semantic representation?"

Those are related, but they are not the same requirement.

What I found

Using the public examples/security-review/security-review-1.2.0.ctxpkg content, the same logical content object produces different hashes under reasonable serializer choices:

Serialization Bytes SHA-256
JS/Python compact, preserve order 2545 7fbc53711d5bf083048cf0c8d7644b5f08c622fef3f9a522cecf2a0200413e1f
Deep sorted keys 2545 8a7f5b8fe53b00c6c8853a8d2e98b89786269f721117af74c3612fbc50a1bb1d
Same top-level content reordered 2545 b26e948c34a7216f31f64e060a8effddd2c1ef5838fda0c7e91bd31ae872111b
Python default with spaces 2667 2dd7568dff3e823d36c60f5ad8a485b2a8c533e9d56c929ca80572cba8e3c2ff

I also tested a minimal session package against the LeanCTX reference implementation:

  1. session fields in the Rust schema order, with the hash computed over that order: imports successfully.
  2. The same logical session object with fields reordered, with the hash computed over the reordered compact JSON: rejected with content_hash mismatch.
  3. The same reordered file as #2, but with the hash computed over the LeanCTX schema-order bytes: imports successfully.

So the current verifier appears to have a stable de facto canonical form, but it is not fully specified in language-neutral terms. A non-Rust producer has to reverse-engineer serializer behavior rather than implement a clear format rule.

I put together a small proof harness for this, but I am not including shell code inline in the issue. I do not expect anyone to run generated proof scripts without review. The useful part is the method and result table; if helpful, I can share the harness separately with a read-first README and static fixtures so an agent or maintainer can reproduce the core test safely in a throwaway directory.

Proof harness: available if useful; omitted here to keep the issue focused.

The purpose split

I think the key question is:

What is content_hash for in v1?

If the v1 goal is verify this package on load, then the simplest and most portable rule may be:

content_hash = SHA-256 of the literal serialized content bytes as written by the producer

In other words: the producer serializes content once, hashes those exact bytes, and the loader hashes the same bytes. No parser re-serialization, no Rust-specific behavior, no JSON number-formatting dispute.

That lines up with the spec's "verified on every load" language and with the governance principle that v1 should stay minimal.

I tested this direction against an adversarial fixture set. Hashing literal content bytes avoids the canonicalization failure modes entirely because numbers, Unicode, key order, whitespace, and null/default reshaping are never re-serialized by the verifier.

The real cost is also worth saying plainly: in the current single-file JSON shape, byte-preservation needs an unambiguous way to identify the exact content byte range. A verifier cannot parse content into a normal JSON value and later recover the original bytes. So v1 would need one precise byte-isolation rule, such as:

  • define the hashed byte range for the content value in the package file, or
  • store content as an opaque encoded payload, or
  • split the sealed content bytes from the manifest in a future package layout.

I am not assuming which of those is best. I am saying the byte-preservation path seems more aligned with v1's stated integrity goal than trying to make every producer imitate one library's serializer.

Why I would not lead with a dual hash in v1

There is a clean larger design here:

{
  "integrity": {
    "content_bytes_sha256": "...",
    "semantic_sha256": "..."
  }
}

One hash would seal the actual artifact bytes. Another would identify semantically equivalent content across producers for registries, deduplication, package comparison, or agent-to-agent exchange.

That separation feels architecturally right.

But for v1, it may be too much. Your governance doc explicitly says the spec should stay "small and focused" and that features belonging in implementations should stay in implementations. A second semantic hash requires choosing a canonicalization standard, shipping test vectors, deciding number/string/null/default rules, and defining what equivalence means.

That seems like a good future RFC once registry/dedup requirements are concrete, not something v1 needs in order to verify packages on load.

Possible v1 direction

A small v1 clarification could be:

  1. State that v1 content_hash is for artifact integrity / tamper detection, not semantic equivalence.
  2. Define exactly which bytes are hashed.
  3. Avoid requiring serde_json::to_string(content) as the cross-language rule.
  4. Reserve semantic canonicalization for a future RFC, possibly as an optional semantic_hash or registry-level field.

If the intended v1 goal is instead cross-producer semantic identity, then a language-neutral canonicalization standard is required. JCS / RFC 8785 is one option, but it brings number-formatting, Unicode, safe-integer, and null/default-shape decisions that v1 may not need if the current goal is simply "verify on load."

Let's make it award winning!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions