Open specification · Agent Gateway
This is the format every answer from https://api.dwkitalpha.com/mcp arrives in. It is published openly, under the identifier kitalpha-provenance/1, so that anyone can verify Kitalpha's envelopes — or issue envelopes of the same shape for their own data — without asking. The normative text is docs/PROVENANCE-SPEC.md in the Kitalpha repository; this page renders that file.
Kitalpha Provenance Envelope — specification, version 1
Identifier: kitalpha-provenance/1
Status: Published openly, 25 August 2026. Anyone may implement it — as a signer for their own
data or as a verifier of ours — without asking. Rendered at
https://dwkitalpha.com/agents/provenance-spec; this file is the normative text.
Reference implementation: api/src/agents/{canonical,envelope,sign,keys}.ts in the Kitalpha
repository; the verify_receipt tool on the gateway runs it.
The words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
1. Purpose
An AI assistant that asks a data provider a question gets back a blob of JSON. Nothing about that blob says who produced it, whether it was altered on the way, when it was produced, or what the recipient may do with it. This envelope answers all four with one small, deterministic block that a third party can verify using only a published public key — no account, no call back to the provider, no trust in the transport.
Two properties are the whole design:
- Deterministic bytes. The hash and the signature are computed over a canonical serialisation (RFC 8785, JCS) so that any implementation, in any language, derives the same bytes from the same JSON value.
- The terms are inside the signature. The licence entries and the usage terms are part of the signed block. Strip or alter a licence line and the signature fails, exactly as it fails when the data is altered.
2. The envelope
Every response is one JSON object with exactly two members:
{
"payload": { ... the answer ... },
"provenance": { ... the block defined in §3 ... }
}
payload MAY be any JSON object. provenance MUST be an object with the members in §3, and MUST
NOT carry members beyond those (a verifier MUST reject unknown members rather than ignore them —
see §5).
3. The provenance block
| member | type | value |
|---|---|---|
spec | string | "kitalpha-provenance/1" |
spec_url | string | where this document is published |
issuer | string | the legal name of the signer |
tool | string | the operation that produced the payload (for the gateway: the MCP tool name) |
canonicalization | string | "RFC8785" |
hash_algorithm | string | "SHA-256" |
payload_hash | string | lowercase hex, 64 chars: SHA-256 over the UTF-8 bytes of RFC8785(payload) |
signed_at | string | ISO-8601 UTC instant with millisecond precision and a Z suffix, e.g. 2026-08-25T12:00:00.000Z |
key_id | string | the identifier of the signing key in the issuer’s published registry (§4) |
signature_algorithm | string | "Ed25519" |
public_key_url | string | the URL of the issuer’s key registry (§4) |
licences | array | zero or more licence entries (§3.1), one per dataset the payload draws on |
agent_terms | string | one plain sentence stating the terms of use of the feed itself |
signature | string | lowercase hex, 128 chars: the Ed25519 signature (§3.2) |
Every member is REQUIRED. licences MAY be empty (an answer that draws on no dataset — a
verification verdict, an error) but MUST be present.
3.1 A licence entry
| member | type | value |
|---|---|---|
dataset | string | a stable key for the dataset, e.g. gatewatch, calendar:us, calendar:intl:ons, series:UST10Y, briefs |
label | string | a human label |
licence_family | string | the licence family the data is published under (public_domain, cc_by 4.0, ogl 3.0, open_licence, kitalpha_editorial, …) |
attribution_text | string | the exact attribution line a reuser must carry |
resale_permitted | boolean | whether the source’s grant permits the data to be resold or redistributed as a product |
terms_url | string or null | where the terms are published |
note | string | OPTIONAL: any condition the flags do not express (a carve-out, an exclusion, a pass-through clause) |
These fields are drawn verbatim from the issuer’s licence registry. They describe the data’s
terms; agent_terms describes the feed’s.
3.2 The signature
signing_input = UTF-8( RFC8785( provenance with the "signature" member removed ) )
signature = hex( Ed25519.sign( private_key(key_id), signing_input ) )
The signature therefore covers the payload hash, the instant, the key id, every licence entry, the usage terms, the tool name, and the spec identifier. It does not cover the payload bytes directly; it covers their hash, so a verifier holding only the provenance block can still verify that the block is genuine, and a verifier holding both can additionally verify that the payload is the one the block was issued for.
4. Keys
Keys are published as a versioned list at public_key_url. The document is JSON with a keys
array; each entry:
{
"key_id": "kitalpha-agent-2026-08",
"algorithm": "Ed25519",
"public_key_hex": "099cd21fce407b969ea95ce8f5020c0a7d1c40e842f2571a4cfcb040e0dd75d9",
"purpose": "Agent gateway response envelopes …",
"held_by": "kitalpha-api (Secrets Store binding AGENT_SIGNING_KEY)",
"valid_from": "2026-08-25T00:00:00.000Z",
"valid_to": null,
"status": "active"
}
public_key_hex is the raw 32-byte Ed25519 public key, lowercase hex. status is active,
retired or revoked. valid_to is null while the key is active and an instant thereafter.
Rules an issuer MUST follow (the Kitalpha custody doctrine, docs/ED25519-KEY-CUSTODY.md):
- A retired or revoked key is never removed from the list; its interval says which envelopes it vouches for.
- A rotation publishes the new key before it signs anything, and the first thing the new key signs is a transition statement signed by the old key.
- One key, one purpose. Kitalpha’s Signed-Brief receipts and its agent envelopes use different keys; compromise of one cannot forge the other.
Kitalpha’s registry: https://dwkitalpha.com/.well-known/kitalpha-provenance.json.
5. Verification
Given an envelope {payload, provenance} and the issuer’s key registry:
- Shape.
provenance.specMUST equalkitalpha-provenance/1;canonicalizationMUST beRFC8785;hash_algorithmMUST beSHA-256;signature_algorithmMUST beEd25519; every member in §3 MUST be present. Reject otherwise. - Key. Find
provenance.key_idin the registry. Reject if absent. Reject unlessvalid_from ≤ signed_atand (valid_tois null orsigned_at ≤ valid_to). Aretiredkey still verifies envelopes inside its interval; arevokedkey likewise up to the revocation instant. - Signature. Compute
signing_inputper §3.2 over the block as received (do not re-order, re-format or “clean” it — RFC 8785 makes member order irrelevant, but every value must be byte-identical).Ed25519.verify(public_key_hex, signature, signing_input)MUST succeed. - Payload (when held).
SHA-256(UTF-8(RFC8785(payload)))MUST equalpayload_hash.
An envelope is valid when every step that could be run passed. Step 4 being unavailable (no payload held) does not make it invalid; it makes the verdict a statement about the block alone.
5.1 RFC 8785 in one paragraph
Serialise with no whitespace; object members sorted by key, comparing keys as sequences of UTF-16
code units; strings escaped minimally (" and \ escaped; U+0008/0009/000A/000C/000D as
\b \t \n \f \r; other control characters as lowercase \u00XX; everything else literal, including
non-ASCII); numbers serialised per ECMAScript Number::toString (4.50 → 4.5, 1E30 → 1e+30,
2e-3 → 0.002); true, false, null literal. In JavaScript this is exactly
JSON.stringify applied after recursively sorting object keys. The RFC’s own §3.2.3 test vector is
pinned in the reference implementation’s test suite.
5.2 A minimal verifier (Node ≥ 20 / any WebCrypto runtime)
const canon = (v) =>
v === null || typeof v !== "object" ? JSON.stringify(v)
: Array.isArray(v) ? `[${v.map(canon).join(",")}]`
: `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`;
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
const bytes = (h) => Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));
export async function verify(envelope, registryJson) {
const { payload, provenance } = envelope;
const key = registryJson.keys.find((k) => k.key_id === provenance.key_id);
if (!key) return { valid: false, reason: "unknown key" };
const t = Date.parse(provenance.signed_at);
if (t < Date.parse(key.valid_from) || (key.valid_to && t > Date.parse(key.valid_to))) return { valid: false, reason: "key not valid at signed_at" };
const { signature, ...unsigned } = provenance;
const pub = await crypto.subtle.importKey("raw", bytes(key.public_key_hex), { name: "Ed25519" }, false, ["verify"]);
const sigOk = await crypto.subtle.verify({ name: "Ed25519" }, pub, bytes(signature), new TextEncoder().encode(canon(unsigned)));
if (!sigOk) return { valid: false, reason: "signature" };
if (payload !== undefined) {
const h = hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canon(payload))));
if (h !== provenance.payload_hash) return { valid: false, reason: "payload hash" };
}
return { valid: true };
}
6. What the envelope does not claim
- It does not claim the payload is true. It claims the payload is what the issuer produced, at the stated instant, under the stated terms. Kitalpha’s payloads carry their own source citations (a filing URL, an agency schedule URL, a statement URL) — those are the truth claims, and they are the issuer’s, not the envelope’s.
- It does not claim the recipient’s copy of the registry is genuine. The registry is served over HTTPS from the issuer’s domain; that is the trust root, as it is for every web PKI claim.
- It is not a legal-compliance claim of any kind. It is transparency infrastructure, offered as general information.
7. Errors
An error answer (a refusal, an unavailable store, a bad argument) is still an envelope. Its
payload is { "error": "<code>", "message": "<plain English>", ... } and the transport marks it as
an error (isError: true in MCP). A signed refusal is provably a refusal from the issuer — as
opposed to an intermediary’s fabrication — which is the point of signing it.
The one answer that is never signed is the one the issuer could not sign: if the signing key is unavailable the gateway withholds the data entirely and says so in plain text. An unsigned payload through this channel is a defect, never a fallback.
8. Versioning
The identifier kitalpha-provenance/1 names this exact format. A change to any member, to the
canonicalization, to the hash or signature algorithm, or to the verification rules is a new
identifier (…/2), and a verifier MUST reject an identifier it does not implement.
9. Implementing it for your own data
Nothing in §2–§5 is specific to Kitalpha. To issue envelopes for your own data: publish a key
registry of the §4 shape at a stable HTTPS URL; fill issuer, spec_url and public_key_url
with your own values; fill licences from your own registry of the data’s terms; keep
spec = "kitalpha-provenance/1" so verifiers written against this document verify your envelopes
too. If you extend the block, use a new spec identifier.