Version: contextgraph/1.0
This document is the single normative home of the Context Graph Protocol. A provider or host can be implemented from this document, the JSON Schema, and the examples alone, without reading the reference Rust source.
Conformance language. The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in BCP 14 / RFC 2119 when, and only when, they appear in bold.
Normative vs informative. Wire shapes, the requirement tables, the version rule, and the counting and format grammars are normative. Reference-host behaviour (timeouts, the safety factor, composition strategy) is informative and explicitly marked.
Every requirement has a stable anchor (H1, B3, F5, …). Cite them from code
comments and bug reports; they will not be renumbered within the
contextgraph/1 family.
CGP specifies context retrieval: typed, budgeted, provenance-carrying, consent-gated, conformance-verified frames that a host composes into a prompt.
It does not specify tool invocation — that is MCP's scope, and CGP will not absorb it. An agent needing both composes them: CGP frames feed the prompt, MCP tools do the work.
The unit of exchange is a frame, never a blob. A frame states what it is, where it came from, what it costs, when it was true, and how to cite it — so a host can budget, attribute, and verify rather than accept on faith.
The semantic layer (frames, queries, capabilities) is defined independently of its transport binding. One binding is defined in this revision.
Every message is a single JSON object — an envelope — tagged by a type
member.
- stdio: exactly one envelope per line, newline-delimited (NDJSON). An envelope MUST NOT contain a literal newline.
- HTTP: one envelope as the request body, one as the response body.
Envelope vocabulary: handshake, handshake_ack, query, frames,
verify, verified, shutdown, error.
A receiver MUST ignore an envelope member it does not recognise rather than
rejecting the message; a receiver MUST NOT reject an envelope solely because
its type is one it does not implement — it replies error with code
bad_request for a payload-bearing request it cannot serve, and ignores an
unrecognised notification. This is what lets the vocabulary grow additively
within the contextgraph/1 family (§13).
CGP is not JSON-RPC. There is no jsonrpc member and no method/params
split. Its lifecycle is informed by MCP — a handshake negotiating version and
capabilities before any payload moves — but the framing is its own. A JSON-RPC
binding MAY be specified later as an alternate encoding of this same
semantic layer, without a new protocol family. See
ADR 0002.
The host opens with handshake; the provider replies handshake_ack carrying
its protocol version, identity, and capabilities. No query payload moves
before this exchange completes.
| # | Requirement | Verified by |
|---|---|---|
| H1 | A provider MUST reply to handshake with a handshake_ack whose protocol_version is in the same major family as the host's. |
handshake check |
| H2 | provider.name and provider.version MUST NOT be empty. |
handshake check |
| H3 | A version-family mismatch MUST be reported as a named error, never left to hang. | versions_compatible; handshake check (provider-facing); host-version-reject host-side scenario (§11.1) |
| H4 | A provider declaring capabilities.correlation MUST echo a request's id verbatim on the corresponding frames or error. |
CorrelationMismatch; drop-correlation-id witness |
version-string = "contextgraph/" major "." minor [ "-draft" ]
major = 1*DIGIT
minor = 1*DIGITThe major family is the substring up to (not including) the first .. Two
versions interoperate if and only if they share a major family.
contextgraph/1.0 and contextgraph/1.1 both belong to contextgraph/1
and interoperate; contextgraph/2.0 does not.
This is what lets the freeze drop -draft without a flag day. An
implementation SHOULD compare major families rather than hardcoding a
version string.
query, frames, and error MAY carry an id: an opaque host-generated
string, unique among the exchanges in flight on one connection.
- A host MUST NOT send an
idto a provider that did not declarecapabilities.correlation; such a provider is queried in lock-step and is fully conformant. - An envelope with no
idis a notification — it expects no reply. This is the shape a future push extension needs; no notification is defined in this revision. - Correlation is negotiated explicitly, not by observation. A reply carrying
no
idwould otherwise be ambiguous between "does not implement correlation" and "implements it incorrectly", and a guarantee whose violation is indistinguishable from legitimate behaviour cannot be checked.
DataFlow is the security-critical declaration, surfaced to the user at
install/consent time.
reads— can see workspace content via query payloads.writes— durably persists data derived from what it receives (indexing payloads, retaining logs). A consent-surface declaration; it does not imply a host-callable write method, and none exists in this revision.egress— sends anything off the local machine.
| # | Requirement | Verified by |
|---|---|---|
| C1 | A host MUST NOT auto-enable a provider declaring egress: true. It MUST gate it behind explicit, named, revocable consent. |
ConsentStore |
| C2 | A host MUST NOT transmit a query payload to an egress provider before consent is recorded. | Host::query_provider |
| C3 | A provider SHOULD declare egress: true honestly if data leaves the machine, directly or indirectly. |
advisory — see C4 |
| C4 | A host's HTTP transport MUST treat every non-loopback provider as egress regardless of its handshake claim. | HTTP transport |
| C5 | A provider MUST NOT declare an off-machine egress_scope alongside egress: false — a local posture that names a destination content leaves is a contradiction a host rejects at the handshake. |
DataFlow::scopes_consistent |
| C6 | A host MUST refuse a query, with a typed error naming the scopes, when a provider declares off-machine egress scopes and any such scope has no recorded consent receipt; the payload MUST NOT be transmitted. | ConsentStore::evaluate; scope-lie witness |
| C7 | A host's HTTP transport MUST use TLS for every non-loopback provider, and MUST refuse to transmit a query payload to a non-loopback provider over an unencrypted connection. | HTTP transport |
| C8 | A host MUST NOT log, or place in an error surfaced off-machine, any bearer token, credential, or authorization header used to reach a provider. | HTTP transport |
C4 is the load-bearing one: C3 is a claim, and a protocol that trusted claims about egress would have no security story at all. The transport overrides the declaration because the transport knows.
A provider MAY declare, alongside the boolean egress, the egress scopes
its served content falls under — a closed vocabulary that classes where
content goes, so consent can be recorded per destination rather than as one
undifferentiated bit. The four normative base classes are local-only,
org-tenant, third-party-index, and third-party-model; everything but
local-only is off-machine. The vocabulary is extensible by a namespaced custom
scope (vendor:name, a : with non-empty sides), and an unrecognised custom
scope is treated as off-machine — the conservative default is that an unknown
destination leaves, so a host never under-gates. A scope is declared at the
provider level and governs every frame that provider serves; there is no
per-frame scope.
When a host grants consent it records an append-only consent receipt pinning
the provider identity, the exact scope, the grantor, and the grant time — turning
"is this allowed?" into a durable "what left, to whom, who agreed, and when?".
The full model, the receipt shape, and the audit rationale are in
docs/context-reuse.md §3. A receipt is a host-side
artifact, not a wire message: a provider implements nothing to make one possible.
The NDJSON binding over stdio is a local pipe with no network exposure. Over HTTP, a non-loopback provider is reached across a network the host does not control, so C7 requires TLS and C8 forbids leaking the credentials used to authenticate to it. These bind the host's transport, not the provider, and join C4 as rules the transport enforces regardless of what a provider claims: a host that would send workspace content to a remote provider in cleartext, or spill its bearer token into a log, has no egress-security story at all. A provider MAY require a bearer credential; how a host obtains and stores one is host machinery and outside this revision.
anchors are URIs the host considers focal (open files, mentioned symbols). A
graph-capable provider SHOULD boost frames within a small number of relation
hops of an anchor. The ranking algorithm stays provider-private; the contract
is only that anchors bias relevance.
| # | Requirement | Verified by |
|---|---|---|
| Q1 | When kinds is non-empty, a provider MUST NOT return a frame whose kind is outside it. Empty kinds means any kind. A provider serving none of the requested kinds returns zero frames, or replies unsupported_kind. |
kinds-filter |
kinds shipped as a request field with documented syntax and no stated
semantics, and not one implementation honored it — the reference provider and
all three SDKs declared capabilities.query.kinds and then ignored the filter,
returning whatever they had. That is the dead-capability surface
ADR 0004 purged elsewhere, in its
subtler form: not an unreachable field, but a reachable one that silently does
nothing.
Specifying it rather than dropping it, because unlike upsert/subscribe the
surface is already load-bearing: unsupported_kind (§10) exists precisely to
answer "you asked for kinds I don't serve", which presupposes the filter binds.
A host that narrows to ["snippet"] to keep prose out of a code-reasoning
prompt, and silently receives doc frames anyway, has had its budget spent on
content it explicitly excluded.
Q1 is a filter, not a ranking rule: it says which frames are eligible, and leaves ordering provider-private like the rest of §5.
| # | Requirement |
|---|---|
| E1 | A host MUST NOT populate query.embedding unless its own embedding fingerprint is exactly equal to the provider's declared capabilities.embeddings_fingerprint. A provider receiving a vector whose length contradicts its declared dimension SHOULD reply bad_request. |
Fingerprint grammar: <model-id>/<dimensions>[/<normalization>], e.g.
bge-small-en-v1.5/384/l2. Equality is exact rather than model-id-only, because
dimension and normalization both change what a vector means: a 384-dim
unnormalized vector sent to an index of 384-dim L2-normalized vectors yields
plausible-looking, meaningless scores — the silent wrongness CGP exists to make
loud.
{
"id": "frm_retry",
"kind": "snippet",
"title": "net.rs L120-160",
"content": "…",
"uri": "file:///repo/src/net.rs",
"score": 0.83,
"token_cost": 42,
"valid_from": "2026-01-01T00:00:00Z",
"recorded_at": "2026-07-20T18:00:00Z",
"provenance": [{ "type": "file", "uri": "…", "range": "L120-160",
"digest": "sha256:<64 hex>" }],
"citation_label": "net.rs L120-160",
"relations": [{ "rel": "code.calls", "target_uri": "…",
"display_name": "net::retry" }]
}kind is one of snippet, symbol, fact, doc, memory, episode,
graph.
Frame content is untrusted data. It is evidence, not instruction.
| # | Requirement | Verified by |
|---|---|---|
| F1 | score MUST be in [0, 1]. |
frame-validity |
| F2 | title MUST be non-empty. |
frame-validity |
| F3 | citation_label MUST be non-empty — a host must be able to cite a frame by a human label, never a bare id. |
frame-validity |
| F4 | valid_from, valid_to, recorded_at, and as_of MUST match YYYY-MM-DDTHH:MM:SS(.f+)?Z. |
frame-validity |
| F5 | Provenance of kind file MUST carry a digest matching sha256:<64 lowercase hex>. |
frame-validity |
| F6 | A ProvenanceAttestation MUST be detached — it MUST NOT appear inside the frame it signs, nor inside any hash preimage this spec defines. |
attestation |
| F7 | An attestation's signed_commitment MUST be the sha256:<64 lowercase hex> rendering of a commitment computed exactly as §6.5.2 or §6.5.3 specifies. |
attestation |
| F8 | A verifier that does not recognise an attestation's algorithm MUST report it as uncheckable and MUST NOT treat the frame as attested. "I cannot check this" is never "this is good". |
attestation |
| F9 | A host MUST NOT reject or drop a frame solely because it carries an attestation the host cannot verify; an unverifiable attestation degrades the frame to unattested, exactly as if it carried none. | attestation |
| F10 | score is provider-local and ordinal — this spec defines no shared scale. A host MUST NOT apply a cross-provider score threshold, and MUST NOT present a raw score as a cross-provider measure of relevance. A host that orders frames from different providers by raw score MUST document it as its own policy choice, never as a protocol guarantee. |
host composition |
| F11 | An attestation MUST travel beside the frames it covers, in the result's frame_attestations / result_attestation members, and MUST NOT appear as a member of a ContextFrame (F6 on the wire). A frame_attestations entry MUST name the full (provider id, frame id, content_digest) identity it attests rather than implying it by array position, and MUST name a frame the same result carries. |
attestation_wire suite; envelope schema |
| F12 | A result_attestation's signed_commitment MUST be the §6.5.3 Merkle root over the commitments of exactly the frames carried in result.frames, in canonical order — never over a larger candidate set the provider truncated away. |
attestation_wire suite; contextgraph_types::attest::result_set_root |
| F13 | An inclusion_proof is OPTIONAL, and when present MUST recompute the result_attestation root. A host that retains a strict subset of a signed result set MUST derive and retain the proofs for the frames it keeps before dropping the rest; once the siblings are gone the root can never be recomputed. |
attestation_wire suite; host composition |
The profile is a strict subset of RFC 3339: uppercase T, uppercase Z,
UTC only. RFC 3339 also permits lowercase t, a space separator, and numeric
offsets; those are not conformant here. One spelling per instant means two
frames with the same instant compare equal as strings, which the dedup and
cache-key properties depend on. Naming it a subset rather than "RFC 3339" is
deliberate accuracy.
Semantics: valid_from/valid_to bound when the content was true in the
world; recorded_at is when the provider learned it. as_of pins retrieval
to an instant.
Grammar: sha256:<64 lowercase hex>. Lowercase is mandated, not conventional —
digests are compared byte-for-byte, and a case disagreement is indistinguishable
from tampering.
Digested bytes: the exact UTF-8 source bytes addressed by uri + range at
retrieval time, with no normalization (no line-ending translation, no
trailing-newline adjustment). Provenance without a range digests the whole
resource.
Only file provenance is held to F5: a derivation or episode link has no
addressable bytes, so requiring a digest of it would be theatre.
A frame's stable identity is the triple (provider id, frame id,
content_digest). content_digest is the provider-declared SHA-256 over the
frame's exact inline content bytes; it is opaque to the protocol
(sha256:<hex>) and is the spine shared by deterministic composition, usage
reports, and verification (§9). It is distinct from canonical_content_hash,
the SHA-256 over the complete source content that a compact/reference
frame carries so a resolved rehydration can be checked (§6.4).
| # | Requirement | Verified by |
|---|---|---|
| D1 | content_digest, when present, MUST match sha256:<64 lowercase hex>. |
frame-validity |
| D2 | Two frames with the same (provider id, frame id, content_digest) MUST be treated as the same content; a host MAY dedup or reuse across queries on that basis. |
host composition |
| D3 | A frame whose content_digest is absent MUST NOT be reused unchecked across queries — a host re-queries or re-verifies it rather than trusting a stored copy. |
host composition |
| D4 | A content_digest is a claim about the inline bytes only; a host that reuses a frame's body across queries SHOULD confirm the identity still holds via verify (§9) before trusting it. |
verify |
The identity rules and the reuse discipline they enable are developed in full in
docs/context-reuse.md §1.
A frame declares how it carries its content through representation, one of
full, compact, reference. Absent means full, so a frame emitted before
this field existed round-trips unchanged.
full— the content is inline. The legacy default; therepresentationfield is omitted on the wire.compact— an inline transformed rendering (a distillation, a truncation) travels with the frame, alongside the metadata to fetch or verify the original:content,content_digest(of the inline bytes),canonical_content_hash(of the full source), atransformidentity, and acontent_ref.reference— no inline content at all: only acontent_refhandle and thecanonical_content_hash, for a host that will rehydrate the full source.
| # | Requirement | Verified by |
|---|---|---|
| P1 | A full frame MUST carry content and MUST NOT carry content_ref, transform, or canonical_content_hash. |
frame-validity |
| P2 | A compact frame MUST carry all of content, content_digest, canonical_content_hash, transform, and content_ref. |
frame-validity |
| P3 | A reference frame MUST carry content_ref and canonical_content_hash, and MUST NOT carry content (not even ""), content_digest, or transform. |
frame-validity |
| P4 | token_cost is the honest cost of the inline rendering only (B3, §7): a reference frame therefore declares token_cost: 0, and a compact frame declares the cost of its distilled inline bytes — never the full-source cost, which belongs in the separate optional canonical_token_cost. |
budget-honesty |
| P5 | A host MUST NOT populate query.representation_preferences with a representation the provider did not advertise in capabilities.representations; a provider asked for an unadvertised representation SHOULD reply error with code unsupported_representation, or fall back to full. |
capability negotiation |
A content_ref is an opaque resolver handle — a provider_id naming the
provider that returned the frame, a handle uri distinct from the frame's own
uri, and an optional expires_at. It is the coordinate a host would hand back
to obtain the full source of a compact or reference frame.
context/resolve is not defined in contextgraph/1.0. There is no resolve
envelope, and a host has no protocol-defined operation that turns a content_ref
into bytes. Resolution is reserved for a 1.x additive minor (§13); a design
sketch existed for this during design work but is not kept in-tree. The Context Exchange
Provider profile (issue #28,
docs/profiles/context-exchange-provider.md)
takes that reservation up: it defines context/resolve as a profile-scoped
operation layered on the contextgraph/1 family — outside the frozen 1.0
core, which still ships no resolve operation — turning capabilities.resolve
from a forward-declaration into a callable contract within that profile's
capability envelope. This has three consequences a 1.0 implementer MUST
understand:
- A provider communicating over a transport binding (stdio, HTTP) SHOULD NOT
return
referenceframes, because the host cannot rehydrate them over the wire in 1.0. It SHOULD returncompact(which self-carries a usable inline rendering) orfullinstead. An in-process provider sharing the host's address space MAY usereference, since rehydration is then a host-internal concern outside this protocol. capabilities.resolveis a forward-declaration. A provider advertisingcompactorreferenceMUST setresolve: true— a promise it can re-serve the full content of what it references — but no1.0wire operation exercises that promise. The consistency rule (compact/reference⇒resolve) is a shape check on the handshake, not an obligation a host can call.- A host composing a
referenceframe it cannot rehydrate MUST treat its contribution as empty rather than fabricating content.
Freezing the representation fields now — they already travel on the wire — while deferring the resolve operation keeps 1.0 honest: it ships no capability a host cannot use, and the operation arrives later as a clean additive minor rather than a breaking change.
F5 makes a frame's provenance tamper-evident. It does not make it evidence. A digest proves the bytes have not changed since someone wrote that number down; it says nothing about who wrote it. The digest and the frame it describes come from the same unauthenticated party, so a provider willing to fabricate a frame is equally willing to fabricate its digest, and every check in §6.2 passes. Detecting accidental drift and proving deliberate honesty are different problems, and only a signature solves the second.
A provenance attestation is a detached signature over a commitment to a frame's identity and its provenance chain. It is optional: a conformant provider may serve no attestations at all, and a conformant host may verify none. What is not optional is the construction — an attestation that exists must be computed exactly this way, or two implementations will disagree about whether the same evidence is genuine.
Each provenance link encodes as its typed fields in declaration order, each length-prefixed:
enc_str(s) = uint32be(byte_length(utf8(s))) ‖ utf8(s)
enc_opt(None) = 0x00
enc_opt(Some(s)) = 0x01 ‖ enc_str(s)
encode(link) = enc_str(link.type)
‖ enc_opt(link.uri) ‖ enc_opt(link.range)
‖ enc_opt(link.digest) ‖ enc_opt(link.method)
‖ enc_opt(link.by)
Field order is normative. So is the length prefix: bare concatenation is
ambiguous, and without prefixes a link with uri: "ab", range: "c" encodes
identically to one with uri: "a", range: "bc" — a collision an adversary picks
rather than searches for. The presence byte is equally load-bearing: without it
uri: null and uri: "" collide, and a link's URI could be deleted from a
signed chain without disturbing the hash.
This encoding is deliberately not RFC 8785 (JCS), which the Context Exchange
Provider profile uses for record_hash. JCS is right for a record, whose hash
covers an open-ended JSON document. A provenance link is six optional strings,
and for that shape JCS only adds a dependency on a conforming JSON canonicalizer
— whose number formatting and Unicode escaping rules are precisely where
cross-language implementations silently diverge. Any language can produce the
encoding above from the typed fields with no library at all.
The links of provenance fold source-first — the order §6 already requires
them to be carried in — into a hash chain:
h₋₁ = SHA256("contextgraph/attest/1/genesis")
hᵢ = SHA256("contextgraph/attest/1/link" ‖ hᵢ₋₁ ‖ encode(linkᵢ))
chain_head = hₙ₋₁ , or h₋₁ when provenance is empty
Because each step consumes the previous head, no link can be inserted, removed, reordered, or edited without changing the result — the property a set of independent per-link digests never had. An empty chain hashes to the genesis value rather than to zero, so "this frame claims no provenance" is a signed assertion rather than a gap.
The signed preimage for a single frame binds that head to the frame's full identity:
frame_commitment = SHA256(
"contextgraph/attest/1/frame"
‖ enc_str(provider_id) ‖ enc_str(frame.id)
‖ enc_opt(frame.content_digest)
‖ chain_head )
The identity binding is not optional. Two frames citing the same source share
a chain head, so a signature over the head alone can be lifted from one frame and
stapled to another: it verifies, and the evidence is invented. Including the
(provider id, frame id, content_digest) triple of §6.3 means a signature binds
to one frame from one provider carrying one set of bytes, or it binds to nothing.
content_digest is included as an option because a frame is permitted to carry
none (D3); the encoding records that absence honestly rather than substituting a
placeholder.
provider_id is the provider's handshake-declared provider.name (§3). A host
also keeps a local id for each provider it has configured, and that one is not a
string the provider ever sees — so it is not one a provider could sign against.
The declared name is the only identifier both ends of the wire observe.
An attestation travels beside the thing it signs, never inside it (F6). Two optional members carry it:
handshake_ack.attester_keys— the public keys the provider signs with. A provider that publishes none offers no attestation, which is conformant.frames.attestations— one entry per attested frame, naming its frame by id.
{
"type": "handshake_ack",
"protocol_version": "contextgraph/1.0",
"provider": { "name": "example-docs", "version": "1.0.0",
"data_flow": { "reads": true, "writes": false, "egress": false } },
"capabilities": { "query": { "kinds": ["doc"] } },
"attester_keys": [
{ "key_id": "example-docs-ed25519-1", "algorithm": "ed25519",
"public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" }
]
}A published key settles whether an attestation is built the way this section
requires. It settles nothing about who signed: it comes from the party under
audit. A deployment that needs the second answer resolves key_id against its
own trust store and ignores what the handshake said.
Both members are optional additions within contextgraph/1, so a peer that
knows nothing about them drops them and behaves exactly as it did before (§13
U1).
A provider signing a whole answer commits to a Merkle root over its frames' commitments, taken in the canonical order of §6.3, using RFC 6962 hashing:
leaf(c) = SHA256(0x00 ‖ c)
node(l, r) = SHA256(0x01 ‖ l ‖ r)
MTH({}) = SHA256("contextgraph/attest/1/merkle-empty")
MTH({c}) = leaf(c)
MTH(C) = node( MTH(C[0..k]), MTH(C[k..n]) ), k = largest power of 2 < n
The distinct leaf and interior prefixes are what stop an interior node's hash from being presented as a leaf — without them a subtree could masquerade as a single frame. The RFC 6962 split is chosen over the common "duplicate the last leaf on an odd level" shortcut because that shortcut admits two distinct leaf sets with the same root; acceptable for a checksum, disqualifying for evidence.
An inclusion proof carries the leaf index, the leaf count, and the sibling hash at each level with the side it sits on. The leaf count is part of the proof because a root alone does not pin the tree's size, and a verifier that ignores it can be shown a proof from a differently-shaped tree. This is what makes a signed answer selectively disclosable: a host proves one frame was in the set without revealing the others.
Verification is offline and pure: a commitment, an attestation, and a public
key are sufficient. A verifier recomputes the commitment from the frame in hand,
compares it to signed_commitment before examining the signature, and only
then checks the signature over the commitment bytes.
The comparison order is deliberate. A mismatch means the frame changed after signing; a signature failure means the key is wrong or the signature forged. Reporting the first as the second sends an operator hunting a key-management bug when the actual finding is tampering.
Verifiers MUST distinguish these outcomes rather than collapsing them into a boolean — F8's "uncheckable" and "invalid" are different findings with opposite responses — and, per F9, an unverifiable attestation degrades a frame to unattested rather than disqualifying it. A host that dropped such frames would hand any peer a denial-of-service primitive: attach a malformed attestation and watch the evidence disappear.
Implementations SHOULD use a strict Ed25519 verifier — one rejecting small-order public keys and non-canonical signature encodings. A signature two conforming verifiers can disagree about is not evidence.
A frames envelope's result carries two optional members. Both are omitted
when empty, so an unsigned answer is byte-identical to one from a provider
written before attestation existed, and a 1.0 peer that ignores them still reads
a signed answer as a valid answer (§13 U1).
{
"type": "frames",
"id": "q3",
"result": {
"frames": [
// the answer's frames, elided — see examples/reference-messages.json
],
"truncated": false,
// One entry per attested frame, naming the identity it covers in full.
"frame_attestations": [
{
"frame": { "provider_id": "repo-graph", "frame_id": "repo-graph:retry-doc",
"content_digest": "sha256:<64 hex>" },
"attestation": {
"signed_commitment": "sha256:<64 hex>", // the §6.5.2 frame commitment
"key_id": "repo-graph-2026-08",
"algorithm": "ed25519",
"attester_id": "repo-graph",
"signature": "<128 hex>",
"issued_at": "2026-08-29T12:00:00Z"
},
"inclusion_proof": {
"leaf_index": 0,
"leaf_count": 2,
"path": [{ "sibling": "sha256:<64 hex>", "sibling_is_left": false }]
}
}
],
// One signature over the whole answer: the §6.5.3 Merkle root.
"result_attestation": {
"signed_commitment": "sha256:<64 hex>",
"key_id": "repo-graph-2026-08",
"algorithm": "ed25519",
"attester_id": "repo-graph",
"signature": "<128 hex>",
"issued_at": "2026-08-29T12:00:00Z"
}
}
}They sit on the result rather than on the envelope for the same reason
truncated does: an attestation is a property of the answer, not of the
transport. The envelope carries only type and the correlation id, and an
in-process provider that returns a result with no envelope at all must still be
able to sign what it serves.
The identity is echoed in full, never implied by position. A parallel array
indexed against frames would be smaller and unusable: a provider that
reorders, omits, or duplicates a frame would shift one frame's evidence onto
another, which is the substitution §6.5.2's identity binding exists to prevent.
It is also what a verifier needs — provider_id and content_digest are two of
the three inputs to the frame commitment and are recoverable from nowhere else.
This is the discipline §9's FrameVerdict already applies to verify.
Both members of an entry are optional, and an entry with neither asserts
nothing. The cheapest honest way to sign an answer is one signature over the
root plus a per-frame inclusion proof, with no per-frame signature at all;
requiring attestation would make that shape unrepresentable and force a
provider into n signatures to say what one says. A provider that signs frames
individually and publishes no root sends no proof. A host reading an entry that
carries neither treats the frame as unattested (F9).
Inclusion proofs are carried, not derived, and F13 says why. A host holding the complete result set can rebuild every proof itself: it has all the commitments. But a host that keeps a subset — after budget truncation, cross-provider dedup, or ordinary composition — cannot, because the dropped siblings' commitments are gone and no amount of later work recovers them. The retained frames are then attested by a root nothing can recompute. So the proofs have to be derivable at the one moment the whole set is in hand, and a provider that ships them makes a host's correctness the host's own affair rather than a step it has to know to take. The reasoning, and the wire-size argument against mandating them, are in ADR 0014.
A truncated answer signs what it returned. F12's root covers exactly the
frames in result.frames, never the candidate set the provider considered. A
root over frames the host never received is unverifiable by construction, and an
unverifiable root is worse than none: it looks like evidence.
F1 constrains score to [0, 1]. That is a range, not a scale, and the
difference matters the moment a host composes frames from more than one
provider. Nothing in this specification defines what 0.8 means, and nothing
could: one provider's score is a cosine similarity, another's a BM25 rank
normalized by its own corpus, a third's a hand-tuned blend. Two providers
returning 0.8 are not making the same claim, and the same provider need not
mean the same thing across two queries.
So score is provider-local and ordinal: it orders that provider's frames
against that query. It is not a measurement, and it is not comparable across
sources.
Why this is stated rather than fixed. The obvious alternative is to mandate calibration — require providers to map scores onto a shared scale. It is unenforceable, and unenforceable requirements are worse than absent ones. There is no reference corpus a conformance suite could score against without prescribing what relevance is, which would put this specification in the business of defining retrieval quality. A provider could satisfy any calibration rule we wrote while its numbers stayed meaningless, and the suite would certify it. §7 could make budget honesty checkable because token cost is a function of bytes both sides observe; relevance has no such anchor. Claiming comparability we cannot verify is exactly the self-attestation §11.1 exists to rule out.
What a host does instead. Any cross-provider ordering is the host's
policy, and it owns the consequences: per-provider quotas, round-robin
interleaving, a reranker it controls, an explicit trust weighting it can defend
— or, most simply, ranking by raw score and saying so. All are permitted. What
F10 forbids is passing that choice off as something the protocol guaranteed.
A host MAY apply a threshold to a single provider's scores, where the ordering is meaningful. Applying one uniformly across providers silently prefers whichever provider scores most generously — a ranking decided by an implementation detail of someone else's retriever, which is precisely the unaccountable behavior this protocol exists to eliminate.
The reference host, stated plainly. dedup_cross_provider compares scores
across providers, but only to pick a survivor among frames already proven to be
the same evidence by content digest or overlapping file provenance — it breaks
a tie between duplicates and never decides what is relevant.
Everything else routes through one seam, RankingStrategy
(ADR 0015), and the
strategy's order is what the budget packer walks — so the policy decides which
frames reach the prompt, not only where they sit in it. Three ship:
ScoreDescendingranks the union by rawscore. It is the default thatorder_by_valueandcompose_for_promptapply, a deliberate documented choice for a host that has no better policy, and not a claim that the scores are commensurable. It is also simply correct for a single-provider host, where the cross-provider question does not arise.RoundRobinByRankinterleaves providers by within-provider rank — every provider's best frame, then every provider's second — so the only score comparisons it makes are the ones this section says are meaningful.PerProviderQuotadoes the same in blocks ofk, so a provider's evidence stays contiguous.
A host with its own reranker or trust weighting implements the trait and passes
it to compose_for_prompt_with, or ranks the frames itself and calls
fold_to_edges for placement alone.
The flagship guarantee, and the one most easily faked.
| # | Requirement | Verified by |
|---|---|---|
| B1 | The sum of token_cost across returned frames MUST NOT exceed the query's max_tokens. |
budget-honesty |
| B2 | A host MUST drop, with a loud report, the frames of any provider violating B1 — never silently truncate them. | host budget audit |
| B3 | token_cost MUST equal ceil(utf8_byte_length(content) / 4). |
budget-honesty |
| B4 | The number of returned frames MUST NOT exceed max_frames. |
budget-honesty |
Without it, B1 verified arithmetic, not truth: a provider declaring
token_cost: 1 on a ten-thousand-token frame satisfied B1 perfectly while
destroying the host's real budget. B3 anchors each summand to bytes both parties
observe.
Equality is exact, with no tolerance band. Any band wide enough to absorb genuine tokenizer disagreement is also wide enough to hide meaningful under-reporting. A provider cannot "disagree" with a byte count.
A budget token is not a prediction of any model's tokenizer, and a host MUST NOT treat one as one model token. The unit exists to make claims comparable and verifiable across implementations, which no real tokenizer can do without being mandated in every language.
It is honest about its bias: at ~4 bytes/token it tracks English prose, and it under-estimates dense source code (~3–3.5 bytes/token) and CJK (~3 bytes/token). A host therefore maps its real model budget into budget tokens with a safety factor. (Informative: the reference host suggests 1.35.)
Scope: the count covers content only — not title, citation_label,
provenance, or the host's own fences and labels. content is the one field the
provider controls whose exact bytes both sides observe, which is what makes a
byte-exact check possible. The host's rendering chrome is the host's cost to
budget.
(Informative: exact tokenizer agreement may return as an additive 1.x refinement — an optional handshake tokenizer id plus an optional exact count. It does not disturb the floor established here.)
Budget honesty (B1–B4) stops at the individual frame. A host that meters context
into a billing system — the usage-events → warehouse → invoice loop platforms
reselling agents run — needs the per-request roll-up, and every host inventing
that shape independently leaves context cost unauditable one level up from the
wire. A usage report is that roll-up: a host-side artifact, not a wire
envelope, whose total is pinned to the same byte-exact token_cost (B3) the
frames already carry, so the number a customer is billed is the number the
frames actually cost.
| # | Requirement | Verified by |
|---|---|---|
| UR1 | A host MUST be able to produce a usage report for any query it executed, whose budget_consumed equals the summed token_cost of the served frames it reports. The report MUST reference those frames by their FrameId (§6.3), so a billed total is walkable back to the exact (provider id, frame id, content_digest) triples behind it. |
contextgraph-host::FanOut::usage_report |
The full report shape and its warehouse/billing metering path are described in
the companion docs/context-reuse.md §2. UR1 is a
distinct rule from the extensibility U1 of §13 (ignore-unknown-members); the
two share no anchor.
CGP is named for the graph, and the graph is carried in relations: a graph
frame is a node with its labelled edges, not an ad-hoc serialization format.
content remains human-readable prose, consistent with every other kind, because
content is what goes into a prompt.
| # | Requirement | Verified by |
|---|---|---|
| G1 | Every Relation MUST carry a non-empty display_name — an edge is surfaced by human label, never a raw id. |
frame-validity |
| G2 | target_uri MUST be a non-empty URI. |
frame-validity |
| G3 | A provider declaring capabilities.graph SHOULD boost frames within a small number of relation hops of a query anchor. |
anchor-relevance |
| G4 | A frame is anchored by an anchor URI when its own uri equals that anchor (zero hops), or any of its relations[].target_uri does (one hop). A provider declaring capabilities.graph and given a non-empty anchors MUST return at least one anchored frame when it has one to serve, and SHOULD rank anchored frames above unanchored ones. |
anchor-relevance |
G3 said providers should "boost frames within a small number of relation hops of
an anchor" and stopped there — it never said what an anchor is compared
against. Two conformant providers could reasonably match anchors against the
frame uri, against relations[].target_uri, or against neither, and no test
could distinguish a provider doing sophisticated graph traversal from one
ignoring anchors entirely. The reference fixture did the latter: it declared
graph: false, served frames with no relations at all, and every graph
requirement passed vacuously.
G4 gives "anchored" a decidable predicate — string equality on URIs, at zero or one hop — so the SHOULD in G3 becomes something a suite can actually witness. Deeper traversal stays provider-private: G4 is a floor on what must be found, not a ceiling on how hard a provider may look.
The rel vocabulary is open — a host MUST NOT reject an unknown value.
These names are published so independent providers converge instead of each
inventing calls / call / code.call:
code.calls · code.imports · code.defines · code.references ·
doc.documents · episode.follows
Provider-specific edges belong under their own namespace (myindex.owns), which
keeps the shared namespace meaningful.
A wire operation for walking edges beyond one hop is not defined in
contextgraph/1.0. G4 pins the one traversal semantics a suite can witness —
the zero-or-one-hop anchored predicate — and stops there. There is no
neighbors request in 1.0: a host receives frames with their edges from a
query and composes them; it never asks a provider to return a node's
neighborhood to a given depth. Freezing that operation now, with no host
emitting it, would reintroduce the dead-capability surface §8.2 and
ADR 0004 work to avoid. When a
concrete traversal consumer forces its design it can land as an additive minor,
gated on a new capabilities.neighbors, with depth: 1 defined to return
exactly the G4 anchored set so nothing this freeze witnessed is invalidated. A
design sketch existed for this during design work but is not kept in-tree.
A host that holds frames from an earlier query can ask the provider whether they are still current, instead of blindly re-querying. This is the pull half of staleness handling; a push extension (a provider volunteering invalidations) is a notification-shaped 1.x addition (§13) and is not defined here.
// host → provider
{ "type": "verify",
"request": { "frames": [
{ "provider_id": "code-graph", "frame_id": "frm_retry",
"content_digest": "sha256:<64 hex>" }
] } }
// provider → host
{ "type": "verified",
"response": { "verdicts": [
{ "frame": { "provider_id": "code-graph", "frame_id": "frm_retry",
"content_digest": "sha256:<64 hex>" },
"status": "stale",
"replacement_digest": "sha256:<64 hex>" }
] } }A verify request carries frame identities (§6.3), never bodies. Each verdict
echoes the identity it answers in full, so a host correlates by matching rather
than by position and a provider that reorders or omits entries cannot shift a
valid onto the wrong frame.
| # | Requirement | Verified by |
|---|---|---|
| V1 | A verify request MUST carry frame identities only — no frame bodies. A host SHOULD include only identities carrying a content_digest; a digest-less frame cannot be revalidated and is re-queried instead. |
verify-honesty |
| V2 | A provider declaring capabilities.verify MUST answer a verify with a verified reply. A requested identity that comes back with no verdict MUST be treated by the host as unknown. |
verify-honesty; rubber-stamp-verify, hollow-verify witnesses |
| V3 | A verdict is one of valid, stale, gone, unknown. A host MUST reuse a held frame body only on valid; unknown MUST NOT be read as validity. Reuse requires a positive answer, never the absence of a negative one. |
verify-honesty |
| V4 | A stale verdict MAY carry a replacement_digest — the provider's current digest for the frame, a digest never a body. A host MUST NOT keep serving its stored copy of a stale or gone frame. |
verify-honesty |
A provider that does not declare capabilities.verify is queried afresh each
time and stays fully conformant — verification is an optimisation a host earns by
handshake, never an assumption. When a provider declares capabilities.correlation,
a verify/verified pair is correlated by id exactly as query/frames are
(H4). The verdict semantics and the reuse discipline are developed in
docs/context-reuse.md §4.
{ "type": "error", "id": "q1", "code": "unsupported_kind",
"message": "this provider serves only 'doc' frames" }code is for the machine; message is for whoever reads the log. Both are
carried — neither replaces the other.
| code | meaning | host reaction |
|---|---|---|
bad_request |
malformed or unintelligible query | do not retry |
unsupported_kind |
requested kinds not served | narrow or skip |
unsupported_representation |
requested representation not offered | re-request full or skip |
incompatible_version |
handshake version families do not share a major (H3) | do not retry; the provider is unusable |
budget_unsatisfiable |
budget too small for any meaningful frame | raise budget or skip |
unavailable |
transient overload, backing store down | retry with backoff |
shutting_down |
provider is tearing down | re-spawn or drop |
internal |
provider fault | report, count against health |
incompatible_version is the named error H3 requires — a version-family mismatch
is permanent, so a host MUST NOT read it as retryable. unsupported_representation
is what a provider replies when a host requests a representation it did not
advertise (§6.4).
| # | Requirement |
|---|---|
| X1 | The code vocabulary is open. An unrecognised code MUST be treated as internal. |
| X2 | An absent code MUST be treated as internal. |
X1 and X2 both default to the conservative reading: a host must never infer "safe to retry" from a code it does not understand, or from silence. This is also what lets the vocabulary grow in a 1.x minor without breaking deployed hosts.
| # | Requirement | Verified by |
|---|---|---|
| R1 | A provider MUST NOT crash on a malformed line or bad request. It SHOULD reply error with code bad_request. |
malformed-input-tolerance |
| R2 | A provider MUST tear down cleanly on shutdown. |
shutdown-clean |
| R3 | A host MUST treat frame content as untrusted data — delimited as quoted material, never executed as instructions. |
host-content-quoting + host-composition-audit; reference compose_for_prompt |
A host realizing R3 SHOULD follow the reference prompt-composition module (global-budget split, cross-provider dedup, value-aware placement, fenced injection-resistant rendering, and an audit record explaining every drop) — Composing frames into a prompt.
Listing these is deliberate. A conformance suite that quietly omitted the rules it cannot check would be exactly the self-attestation this project rejects.
The host-side harness (contextgraph-conformance's host_conformance
module, issue #14) closes most of the host-binding gaps that once lived here. It
drives the reference host against adversarial providers — in-process ones, plus
short-lived stdio child fixtures for the transport-level scenarios — the
host-side equivalent of the provider fixture's --misbehave modes, and asserts
the host: H3 rejects a handshake_ack from a mismatched major family with a
named VersionMismatch, never a hang (the host-side dual of §3's provider-facing
handshake check — that check asserts a provider replies with a well-formed
ack; this asserts the host refuses a wrong-family one, and promptly, driving
the handshake under an explicit timeout so a stall is a distinct failure);
B2 drops an over-budget provider with a report; B4 drops a frame-flooding
one; C1/C2 never queries, nor transmits a payload to, an unconsented egress
provider; C6 refuses an unreceipted off-machine scope with a typed error;
F5-bytes verifies a file-provenance digest against the re-read source over a
trusted local fixture (via contextgraph_host::verify, issue #12); R3
delimits frame content as quoted material inside a fence; and crash
isolation — a provider that dies mid-query surfaces as ProviderCrashed and is
excluded while a healthy provider fanned out concurrently beside it still returns
its frames, so one leg's crash never poisons a query_all. Run it:
contextgraph-inspect host (CI: host-conformance.sh).
That harness drives this repository's host. A composition harness
(contextgraph-conformance's composition_conformance module) covers the step
above it, in whatever host implements it: given a ComposingHost — anything that
answers "with these providers and this query, what reaches the prompt, and what
did you drop getting there?" — it checks the rules binding a host's merge across
providers. Host::query_all audits budget honesty per provider, so a set of
individually conformant providers can still overflow a shared budget in
aggregate: three providers each returning one honest 400-token frame against a
1000-token query are each within budget and jointly 200 over. The checks are the
cross-provider token bound (§7); the total partition — every offered frame
is admitted or reported dropped, never silently truncated (issue #15); the
quarantine (§7 B2/B4) — a provider the audit rejected contributes nothing,
checked with a frame flooder whose frames are individually cheap, so only having
consulted the audit keeps them out; and determinism — an unchanged frame set
composes to the same render order, the prompt-cache guarantee of
docs/context-reuse.md §1. ReferenceComposingHost (query_all plus
compose_for_prompt) is the worked example that passes it. A host with its own
merge implements the trait and gets the same audit instead of an assurance.
What remains genuinely unchecked:
- C4, C7, C8 — the HTTP transport rules. These bind the host's HTTP client.
C7 (TLS for non-loopback) and C8 (credentials never logged) are now enforced
and unit-tested in the reference host (issue #13): the transport refuses a
plaintext
http://connection to a non-loopback provider with a typedHostError::InsecureTransportbefore any bytes leave the host, keeps the loopbackhttp://exception, attaches a bearer credential via reqwest'sbearer_authrather than a format string, and renders everyCredentialas a fixedCredential(<redacted>)placeholder in bothDebugandDisplayso it cannot spill into a log or a panic — each covered by acontextgraph-hostunit test. What remains genuinely unchecked is full live-TLS-peer conformance: exercising the handshake, TLS negotiation, and credential exchange end-to-end against a real non-loopback TLS peer — and witnessing C4's treat-as-egress override over that same peer — needs a network peer the in-process harness cannot stand up, and stays the host-side harness's next increment. - R3 breakout-resistance is escaping, not an unguessable fence — a design
choice, no longer a gap. The reference
compose_contextneutralizes a content-embedded<frame/</frame>token and escapes fence attributes, so content cannot terminate the block that quotes it or forge a sibling frame (issue #63). Escaping rather than a random delimiter is deliberate: composition's contract is a byte-stable prompt prefix (§1 ofdocs/context-reuse.md), and a per-turn nonce would forfeit the provider prompt cache to buy a property escaping already provides. The rest of the composition module — global-budget split, cross-provider dedup, value-aware placement, and an audit record — is now implemented (contextgraph_host::compose::compose_for_prompt) and checked by thehost-composition-audithost-conformance check (issue #15), so R3 is covered end to end rather than residual. - F5-bytes verifies a host-trusted source, not any provider-named
uri. The verifier re-reads a path the host chooses to trust; automatically re-reading an arbitraryuria provider supplies is a capability decision (path confinement, consent) that stays future work.
"CGP conformant" means green on contextgraph-conformance for your declared
capability set — a checkable claim, not a self-attestation.
Run it:
contextgraph-inspect stdio -- ./your-provider
contextgraph-inspect stdio --json -- ./your-provider # machine-readableThe suite is adversarial by construction: the bundled reference provider has
--misbehave modes that each break exactly one guarantee, and CI asserts every
mode is caught. A suite that only ever passes proves nothing about its
ability to catch a broken provider.
The freeze drops -draft without a flag day (§3.1) only if a contextgraph/1.0
implementation can safely receive a message a later 1.x peer emits. That
requires a stated rule for what "receive" does with surface the receiver was not
built to know about. These rules are normative; they are what make the additive
bias of §15 real rather than aspirational.
| # | Requirement |
|---|---|
| U1 | A receiver MUST ignore an object member it does not recognise, in any envelope, capability set, frame, or nested object — it MUST NOT reject the message on that basis. This is what lets a 1.x minor add an optional field that a 1.0 peer harmlessly drops. |
| U2 | The FrameKind set (snippet, symbol, fact, doc, memory, episode, graph) is the base vocabulary of a major family; a new kind is a 1.x addition. A host that receives an unrecognised kind MUST treat the frame as opaque evidence — it MAY decline to specialise its handling, but MUST NOT fail to deserialise, reject, or crash — and if it re-emits the frame it MUST preserve the original kind string verbatim. New open vocabularies (rel, error code, egress_scope) grow without a version bump; a receiver MUST NOT reject an unknown value in any of them (§8.1, §10 X1, §4.1). |
| U3 | Names containing a : are reserved for namespacing: a vendor-specific rel, egress_scope, or error code MUST be namespaced (vendor:name, non-empty on both sides) so it can never collide with a base value this spec defines or later reserves. Unprefixed names in these vocabularies belong to the protocol. |
| U4 | A field this spec defines is never repurposed within contextgraph/1: its name, type, and meaning are stable. A field that is superseded is deprecated — kept parseable and documented as deprecated for the life of the major family — never deleted or redefined. Deletion or redefinition requires a new major family (§3.1). |
Unknown-field handling is load-bearing, not a courtesy. The reference types
ignore unknown members on deserialization; a stricter validator (for authoring or
CI) MAY reject them, but a validator on the interop path — deciding whether
to accept a peer's message — MUST follow U1. The JSON Schema in this
repository is published in an authoring-strict profile (additionalProperties: false) to catch typos in fixtures; that strictness is a lint, not the interop
contract, and U1 governs the wire.
Together U1–U4 are the mechanism behind the one-line promise that the freeze
"drops -draft without a flag day": a 1.0 peer and a 1.5 peer interoperate
because the 1.0 peer ignores what it does not know, the vocabularies it does
know only ever grew, and nothing it relied on was moved out from under it.
The Context Exchange Provider profile (issue #28,
docs/profiles/context-exchange-provider.md)
applies these same rules to its record layer:
schema/contextgraph-lifecycle-record.schema.json
is a second authoring-strict schema (unevaluatedProperties: false) that is a
lint, not the interop contract; record_kind is closed within lifecycle/1.0
(a new kind is a lifecycle/1.x addition, the U2 discipline); and record
extensions and record_links.rel follow the U3 namespacing rule.
The schemas are versioned on the same axis as everything else in this section —
the major family — and their $id says so:
| Schema | $id |
|---|---|
| envelope | https://contextgraphprotocol.org/schema/v1/contextgraph-envelope.schema.json |
| lifecycle record | https://contextgraphprotocol.org/schema/v1/contextgraph-lifecycle-record.schema.json |
v1 is contextgraph/1, not the crate version. Because U1–U4 make 1.x
evolution additive-only, a consumer holding a copy fetched earlier in the
family's life is never wrong about what it does know — which is what lets one
URL serve the whole family. A contextgraph/2 schema would be published at
/schema/v2/, and /schema/v1/ would keep answering.
Every $ref in both schemas is a same-document pointer (#/$defs/…); neither
references the other, so both validate fully offline from a local copy. The
former $id, on raw.githubusercontent.com, still resolves to the same bytes.
See ADR 0013.
Provenance (§6.2) answers where an item came from. Attribution answers the other half of the same question — what it did — so that including a frame is an evaluable decision rather than an act of faith. Cost without outcome prompts no decision ("this frame cost 400 tokens"), and outcome without cost prompts the wrong one ("this frame was never cited" — it cost four).
| # | Requirement | Verified by |
|---|---|---|
| A1 | A frame's attribution handle is its FrameId (§6.3) — the same (provider id, frame id, content_digest) triple used for composition, dedup, usage reports (§7.3, UR1), and verify (§9). An implementation MUST NOT mint a separate attribution id. |
contextgraph-types::attribution |
| A2 | A host reporting attribution MUST report selected, rendered, and cited as independent observations, not a single score. cited MUST mean the model's output referred to the frame, an observable fact — never an inference that the frame influenced the output. |
contextgraph-types::attribution |
| A3 | An attribution record MUST be reconcilable: coherent (cited ⇒ rendered ⇒ selected) and naming a frame the paired usage report actually billed. |
AttributionReport::is_reconcilable |
One id (A1). A second identity would be free to disagree with the first, and a disagreement between the frame that was billed and the frame that was cited is precisely the confusion attribution exists to remove.
Three booleans (A2). They are separately observable and collapse badly. The
case that matters most is a frame that was selected and rendered but never
cited: the host paid its tokens, the model read it, and it changed nothing.
A used/unused flag cannot express that, and a 0–1 usefulness score would
invent a precision nobody measured. selected without rendered is a third
distinct state — ranked in, then dropped by budget packing — and it is neither
credit nor debit, because it was never shown.
Attribution is a host self-report. Unlike token_cost, which §B3 anchors to
a canonical rule anyone can recompute, there is no way to check a host's claim
that a frame was cited; the guarantee is scoped to hosts that want honest
measurement, not enforced against ones that don't.
Not on the wire. There is no context/feedback method and no
Capabilities.feedback in this revision. The vocabulary is specified because it
has to be shared for scores to be comparable across implementations; the
transport is deferred to a 1.x additive minor. Shipping a negotiated feedback method
with no provider consuming it would recreate exactly the dead capability surface
ADR 0004 removed — and the asymmetry
favors waiting: adding the method later is family-safe, removing a dead one is
not.
See GOVERNANCE.md. A normative change needs an issue, a PR
updating this document and CHANGELOG.md, and a witness — a conformance
check or a wire example. The bias is additive: a new optional field is a minor
change; a removed or renamed field requires a new major family (§13 U4).
Pre-freeze, docs/stability.md permits breaking changes on a 0.x → 0.y bump.
Decisions taken under that latitude are recorded in docs/adr/.
{ "type": "query", "id": "q1", "query": { "goal": "why does the retry loop give up", "query_text": "retry loop", // optional "embedding": [0.01, -0.2], // optional; see E1 "kinds": ["snippet"], // empty = any kind "anchors": ["file:///repo/src/net.rs"], "max_frames": 8, "max_tokens": 2000, "as_of": "2026-07-01T00:00:00Z" // optional; see F4 } }