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:

  1. 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.
  2. 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

membertypevalue
specstring"kitalpha-provenance/1"
spec_urlstringwhere this document is published
issuerstringthe legal name of the signer
toolstringthe operation that produced the payload (for the gateway: the MCP tool name)
canonicalizationstring"RFC8785"
hash_algorithmstring"SHA-256"
payload_hashstringlowercase hex, 64 chars: SHA-256 over the UTF-8 bytes of RFC8785(payload)
signed_atstringISO-8601 UTC instant with millisecond precision and a Z suffix, e.g. 2026-08-25T12:00:00.000Z
key_idstringthe identifier of the signing key in the issuer’s published registry (§4)
signature_algorithmstring"Ed25519"
public_key_urlstringthe URL of the issuer’s key registry (§4)
licencesarrayzero or more licence entries (§3.1), one per dataset the payload draws on
agent_termsstringone plain sentence stating the terms of use of the feed itself
signaturestringlowercase 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

membertypevalue
datasetstringa stable key for the dataset, e.g. gatewatch, calendar:us, calendar:intl:ons, series:UST10Y, briefs
labelstringa human label
licence_familystringthe licence family the data is published under (public_domain, cc_by 4.0, ogl 3.0, open_licence, kitalpha_editorial, …)
attribution_textstringthe exact attribution line a reuser must carry
resale_permittedbooleanwhether the source’s grant permits the data to be resold or redistributed as a product
terms_urlstring or nullwhere the terms are published
notestringOPTIONAL: 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:

  1. Shape. provenance.spec MUST equal kitalpha-provenance/1; canonicalization MUST be RFC8785; hash_algorithm MUST be SHA-256; signature_algorithm MUST be Ed25519; every member in §3 MUST be present. Reject otherwise.
  2. Key. Find provenance.key_id in the registry. Reject if absent. Reject unless valid_from ≤ signed_at and (valid_to is null or signed_at ≤ valid_to). A retired key still verifies envelopes inside its interval; a revoked key likewise up to the revocation instant.
  3. Signature. Compute signing_input per §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.
  4. Payload (when held). SHA-256(UTF-8(RFC8785(payload))) MUST equal payload_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.504.5, 1E301e+30, 2e-30.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.