diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 251fb9c..2134833 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a defect in an Context Graph Protocol crate or the specification +description: Report a defect in a Context Graph Protocol crate or the specification title: "bug: " labels: ["bug", "needs-triage"] body: diff --git a/CHANGELOG.md b/CHANGELOG.md index 553706f..7666fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,22 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 `contextgraph-mcp-server` exposes a CGP host's fan-out as an MCP `query_context(goal, budget, kinds)` tool returning frames, provenance, citations, and a budget audit as structured content. +- **Context Exchange Provider profile ratified** (`contextgraph/lifecycle/1.0-draft`, + #28) — the draft skeleton becomes a normative profile + (`docs/profiles/context-exchange-provider.md`) with RFC-2119 rows and stable + anchors. Adds the discriminated `ContextRecord` JSON Schema + (`schema/contextgraph-lifecycle-record.schema.json`, `$id` on GitHub-raw per + ADR 0008) covering all 12 record kinds, `contextgraph-types::record` wire types + (zero new runtime deps), per-kind golden fixtures under `tests/fixtures/` with + an RFC 8785 JCS `record_hash` worked example, and a `contextgraph-conformance` + round-trip + hash suite. `context/resolve` is scoped to the profile (taking up + SPEC §6.4.1's reservation); reconciliation rows D1/D4/D5/D6/D7/B3/B5/C5/E3 are + resolved. +- **Docs: CGP abbreviation convention + a CI badge** (#21, buildable half of #2) + — README, CONTRIBUTING, and `docs/` now expand + "Context Graph Protocol (CGP)" on first mention and use "CGP" in body prose + (titles, link text, version strings, and crate names left intact); the + bug-report template grammar is fixed; and the README gains a CI status badge. - **`SPEC.md` normative completeness pass** — folds every shipped wire surface into the single normative home ahead of the freeze (#49, #50, #48, #13). Adds §9 **Verification** (`verify`/`verified`, V1–V4), §6.3 **Frame identity** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 965172e..bd7a1f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Context Graph Protocol -Thanks for wanting to make Context Graph Protocol better. This document is the whole game: +Thanks for wanting to make Context Graph Protocol (CGP) better. This document is the whole game: how to set up, where your change goes, what "done" means here, and how to get it merged. It's long because it's honest — but the short version is: @@ -60,7 +60,7 @@ locally before you push): ## Issues and labels -- **[Bug report](https://github.com/macanderson/context-graph-protocol/issues/new?template=bug_report.yml)** — include the Context Graph Protocol crate name and version, OS, and a repro. +- **[Bug report](https://github.com/macanderson/context-graph-protocol/issues/new?template=bug_report.yml)** — include the CGP crate name and version, OS, and a repro. - **[Feature request](https://github.com/macanderson/context-graph-protocol/issues/new?template=feature_request.yml)** — say what you're trying to do, not just what to add. Labels you'll see: `area:*` routes an issue to a crate; `P0`–`P2` is priority; @@ -69,7 +69,7 @@ a PR is waiting on its witness test. ## License -Context Graph Protocol is dual-licensed **MIT OR Apache-2.0**. By contributing, you agree your +CGP is dual-licensed **MIT OR Apache-2.0**. By contributing, you agree your contributions are licensed under the same terms, as certified by your DCO sign-off. No CLA, no copyright assignment. diff --git a/README.md b/README.md index 4ee6c83..118107c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Context Graph Protocol (draft v0.1.0) +[![CI](https://github.com/macanderson/context-graph-protocol/actions/workflows/ci.yml/badge.svg)](https://github.com/macanderson/context-graph-protocol/actions/workflows/ci.yml) [![contextgraph-types on crates.io](https://img.shields.io/crates/v/contextgraph-types.svg)](https://crates.io/crates/contextgraph-types) [![contextgraph-types docs](https://img.shields.io/docsrs/contextgraph-types)](https://docs.rs/contextgraph-types) [![contextgraph-host on crates.io](https://img.shields.io/crates/v/contextgraph-host.svg)](https://crates.io/crates/contextgraph-host) [![contextgraph-host docs](https://img.shields.io/docsrs/contextgraph-host)](https://docs.rs/contextgraph-host) [![contextgraph-conformance on crates.io](https://img.shields.io/crates/v/contextgraph-conformance.svg)](https://crates.io/crates/contextgraph-conformance) [![contextgraph-conformance docs](https://img.shields.io/docsrs/contextgraph-conformance)](https://docs.rs/contextgraph-conformance) @@ -14,7 +15,7 @@ https://contextgraphprotocol.org > If you want the deep research analysis, read [Advantages and Uniqueness](./docs/protocol-advantages.md). > If you want to build a provider today, read [Implementing a provider](./docs/implementing-a-provider.md). -> This page is the one-read explanation of what Context Graph Protocol is, why it exists, and why +> This page is the one-read explanation of what Context Graph Protocol (CGP) is, why it exists, and why > you would build against it. --- @@ -39,14 +40,14 @@ account for. It works until the budget silently overflows, a provider lies about cost, workspace content leaks to a third party, or an auditor asks "where did this answer come from?" and there is no trail. -The Context Graph Protocol makes every one of those questions answerable. +CGP makes every one of those questions answerable. Not by convention, but by contract. --- ## What Context Graph Protocol is, in one paragraph -Context Graph Protocol is an open wire protocol for context retrieval. It treats a piece of context +CGP is an open wire protocol for context retrieval. It treats a piece of context as a typed, budgeted, provenance-carrying, consent-gated, and conformance-verified unit of exchange called a **frame**. A host asks providers for frames relevant to a goal, under a token budget. Each provider returns @@ -63,7 +64,7 @@ conformance suite). All three are dual-licensed MIT OR Apache-2.0. ## The seven guarantees -Context Graph Protocol makes seven promises about every frame that enters a prompt. Each one is a +CGP makes seven promises about every frame that enters a prompt. Each one is a type in `contextgraph-types` and an enforcement path in `contextgraph-host` or `contextgraph-conformance`, not a line in a style guide. @@ -72,7 +73,7 @@ not a line in a style guide. | **Provenance** | Every frame carries its origin: URI, line range, cryptographic digest, method, and the agent that produced it | `ContextFrame.provenance` | | **Budget honesty** | A provider's frames never sum above the query's `max_tokens`. A provider that lies is detected and its frames are dropped, loudly | Host budget audit + `budget-honesty` conformance check | | **Consent enforcement** | A provider that sends data off-machine is never queried until you record named, revocable consent. The query payload is not transmitted first | `ConsentStore` gate in `contextgraph-host` | -| **Conformance** | "Context Graph Protocol conformant" is a checkable claim, not a self-attestation. The suite is adversarial and ships a mode that trips every failure on purpose | `contextgraph-conformance`, 5 checks | +| **Conformance** | "CGP conformant" is a checkable claim, not a self-attestation. The suite is adversarial and ships a mode that trips every failure on purpose | `contextgraph-conformance`, 5 checks | | **Citation** | Every frame has a non-empty title and citation label. Raw ids are never the on-screen identifier | `frame-validity` conformance check | | **Version stability** | The protocol evolves inside a major family. The draft-to-stable freeze needs no flag day and breaks no deployed provider | `versions_compatible` in `contextgraph-host` | | **Temporal validity** | Facts carry `valid_from` and `valid_to` windows. A query can pin retrieval to a point in time with `as_of` | `ContextFrame` temporal fields | @@ -80,7 +81,7 @@ not a line in a style guide. The properties compose, and the combination is the point. Provenance without budget honesty means you can trace a frame but not control its cost. Budget honesty without consent means costs are honest but data can still leak. Remove -any one and the trust model collapses back to the blob-pipe. That is why Context Graph Protocol is +any one and the trust model collapses back to the blob-pipe. That is why CGP is specified as one integrated protocol, not a menu of options. --- @@ -151,11 +152,11 @@ separates a message body from its headers. ## How Context Graph Protocol relates to MCP They are complementary, not competing. The Model Context Protocol (MCP) connects -**tools**: functions an agent calls to take an action. Context Graph Protocol connects **context**: +**tools**: functions an agent calls to take an action. CGP connects **context**: typed, budgeted, cited evidence a host composes into the prompt before the agent acts. MCP has no budget-honesty contract, no egress consent gate, no provenance chain, and no conformance suite, because those are outside its scope, not -deficiencies in it. An agent that needs both composes them. Context Graph Protocol frames feed the +deficiencies in it. An agent that needs both composes them. CGP frames feed the prompt. MCP tools do the work. --- @@ -166,7 +167,7 @@ prompt. MCP tools do the work. to writing a provider is a JSON codec and the wire table. In-process, over stdio, or over HTTP. - **Conformance is a test you run in CI.** Point `contextgraph-inspect` at your provider. - Green means it works with any Context Graph Protocol host. A broken provider is caught at CI time, + Green means it works with any CGP host. A broken provider is caught at CI time, not at integration time. The suite ships a `--misbehave` mode that trips every check on purpose, so you know the checks are real. - **Stability you can pin.** The protocol version is `contextgraph/1.0-draft`. Two versions @@ -178,7 +179,7 @@ prompt. MCP tools do the work. ## License -All Context Graph Protocol crates (`contextgraph-types`, `contextgraph-host`, `contextgraph-conformance`) and this repository +All CGP crates (`contextgraph-types`, `contextgraph-host`, `contextgraph-conformance`) and this repository are dual-licensed under **MIT OR Apache-2.0**, at your option. See [`LICENSE-MIT`](./LICENSE-MIT) and [`LICENSE-APACHE`](./LICENSE-APACHE). By contributing you agree your contributions are licensed under the same terms. @@ -187,7 +188,7 @@ contributing you agree your contributions are licensed under the same terms. ## Status -Context Graph Protocol is `contextgraph/1.0-draft` today. The wire types are stable enough to build against, +CGP is `contextgraph/1.0-draft` today. The wire types are stable enough to build against, the host runtime enforces the guarantees, and the conformance suite verifies them. The path from "open context as an idea" to "open context as a standard" is the conformance suite: anyone can build a provider, anyone can verify it, and the diff --git a/SPEC.md b/SPEC.md index ac0da14..722de43 100644 --- a/SPEC.md +++ b/SPEC.md @@ -359,8 +359,15 @@ 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 lives under [`docs/sketches/`](./docs/sketches/). This has three -consequences a 1.0 implementer **MUST** understand: +sketch lives under [`docs/sketches/`](./docs/sketches/). The **Context Exchange +Provider profile** (issue #28, +[`docs/profiles/context-exchange-provider.md`](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 `reference` frames, because the host cannot rehydrate them over the wire @@ -715,6 +722,15 @@ Together U1–U4 are the mechanism behind the one-line promise that the freeze 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`](docs/profiles/context-exchange-provider.md)) +applies these same rules to its record layer: +[`schema/contextgraph-lifecycle-record.schema.json`](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. + --- ## 14. Attribution diff --git a/contextgraph-conformance/tests/lifecycle_profile_examples.rs b/contextgraph-conformance/tests/lifecycle_profile_examples.rs new file mode 100644 index 0000000..c9ac4d1 --- /dev/null +++ b/contextgraph-conformance/tests/lifecycle_profile_examples.rs @@ -0,0 +1,255 @@ +//! The lifecycle-profile fixtures, the JSON Schema, and the Rust record types +//! must agree — the record-layer analogue of `examples_roundtrip.rs`. +//! +//! `schema/validate-examples.py` proves each `tests/fixtures/*.json` record +//! satisfies `schema/contextgraph-lifecycle-record.schema.json`. That is only +//! part of the contract. This suite closes the loop three ways: +//! +//! 1. **Round-trip.** Every fixture deserializes through +//! [`contextgraph_types::ContextRecord`] and survives a serde round-trip, +//! so a wire-type change that skips the fixtures turns a PR red (the record +//! analogue of issue #2). +//! 2. **Envelope invariants.** Each record passes +//! [`ContextRecord::envelope_invariants`] — schema_version, the record_hash +//! grammar, the confidence range, the origin→derivation matrix, and the +//! "a constraint directive states its effect" rule (reconciliation rows +//! B3/B5/C5/E3). +//! 3. **Content-addressed hash.** `record_hash` is recomputed as +//! `sha256:` over the RFC 8785 (JCS) canonicalization of the record +//! with its own `record_hash` member removed, and must match the stored +//! value. This is what makes the fixtures a golden vector for the hashing +//! rule rather than a hash a fixture merely asserts about itself. +//! +//! `tests/fixtures/` is the **canonical home** for lifecycle-profile example +//! records (resolving the draft's open "which repo owns the vectors" question). +//! +//! Regenerating the hashes: `REGENERATE_LIFECYCLE_HASHES=1 cargo test -p +//! contextgraph-conformance --test lifecycle_profile_examples` rewrites each +//! fixture's `record_hash` (and the attestation's `signed_record_hash`) to the +//! recomputed value, preserving the file's field order. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use contextgraph_types::{ContextRecord, LIFECYCLE_SCHEMA_VERSION, RecordAttestation}; +use sha2::{Digest, Sha256}; + +/// The 12 portable record kinds the profile defines (reconciliation row D1). +const EXPECTED_KINDS: [&str; 12] = [ + "observation", + "knowledge", + "memory", + "directive", + "record_proposal", + "evidence", + "artifact_contract", + "contract_validation", + "outcome_assessment", + "promotion_event", + "context_use", + "context_use_feedback", +]; + +const ATTESTATION_FIXTURE: &str = "record-attestation.json"; + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("tests") + .join("fixtures") +} + +/// Every `*.json` fixture except the detached attestation — i.e. the record +/// fixtures, one per `record_kind`. +fn record_fixture_paths() -> Vec { + let mut paths: Vec = std::fs::read_dir(fixtures_dir()) + .expect("tests/fixtures is readable") + .map(|entry| entry.expect("dir entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .filter(|path| { + path.file_name() + .is_some_and(|name| name != ATTESTATION_FIXTURE) + }) + .collect(); + paths.sort(); + assert!( + !paths.is_empty(), + "no lifecycle record fixtures found under {}", + fixtures_dir().display() + ); + paths +} + +/// The content-addressed `record_hash`: `sha256:` over the RFC 8785 (JCS) +/// canonicalization of the record with `record_hash` (or, for the detached +/// attestation, `signed_record_hash`) omitted from the preimage. +fn compute_hash(value: &serde_json::Value, hash_member: &str) -> String { + let mut preimage = value.clone(); + preimage + .as_object_mut() + .expect("a record is a JSON object") + .remove(hash_member); + let canonical = + serde_json_canonicalizer::to_vec(&preimage).expect("record canonicalizes under JCS"); + let hex: String = Sha256::digest(&canonical) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("sha256:{hex}") +} + +fn regenerating() -> bool { + std::env::var_os("REGENERATE_LIFECYCLE_HASHES").is_some() +} + +/// Replace the single `"": "sha256:…"` value in `text` with `new`, +/// preserving the file's field order and formatting. +fn rewrite_hash(text: &str, old: &str, new: &str) -> String { + assert!( + text.matches(old).count() == 1, + "expected exactly one occurrence of {old} to rewrite" + ); + text.replacen(old, new, 1) +} + +#[test] +fn every_record_kind_has_exactly_one_fixture() { + let kinds: BTreeSet = record_fixture_paths() + .iter() + .map(|path| { + let raw = std::fs::read_to_string(path).expect("fixture readable"); + let value: serde_json::Value = + serde_json::from_str(&raw).expect("fixture is valid JSON"); + value["record_kind"] + .as_str() + .unwrap_or_else(|| panic!("{} has no record_kind", path.display())) + .to_string() + }) + .collect(); + + let expected: BTreeSet = EXPECTED_KINDS.iter().map(|k| k.to_string()).collect(); + assert_eq!( + kinds, expected, + "tests/fixtures must hold exactly one fixture per record_kind" + ); +} + +#[test] +fn every_fixture_round_trips_and_satisfies_its_envelope_invariants() { + for path in record_fixture_paths() { + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display())); + + let record: ContextRecord = serde_json::from_str(&raw).unwrap_or_else(|e| { + panic!( + "{} does not deserialize into ContextRecord: {e}\n\ + The schema, the fixtures, and the Rust types describe one record \ + layer — if you changed a record type, update the fixtures in the \ + same commit.", + path.display() + ) + }); + + assert_eq!(record.schema_version, LIFECYCLE_SCHEMA_VERSION); + record.envelope_invariants().unwrap_or_else(|e| { + panic!( + "{} violates the profile envelope invariants: {e}", + path.display() + ) + }); + + // The filename stem is the record_kind, so the fixture set is + // self-documenting. + let stem = path.file_stem().unwrap().to_string_lossy(); + assert_eq!( + record.record_kind(), + stem, + "{} should carry record_kind == its filename", + path.display() + ); + + // Round-trip: re-serializing must produce something the types still + // accept, catching an asymmetric Serialize/Deserialize impl. + let reencoded = serde_json::to_value(&record).expect("record re-serializes"); + let back: ContextRecord = + serde_json::from_value(reencoded).expect("re-serialized record re-parses"); + assert_eq!( + back, + record, + "{} did not survive a serde round-trip", + path.display() + ); + } +} + +#[test] +fn record_hash_is_the_jcs_sha256_of_the_hashless_record() { + let regenerate = regenerating(); + for path in record_fixture_paths() { + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("could not read {}: {e}", path.display())); + let value: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + let stored = value["record_hash"] + .as_str() + .expect("record_hash present") + .to_string(); + let expected = compute_hash(&value, "record_hash"); + + if regenerate { + if stored != expected { + let rewritten = rewrite_hash(&raw, &stored, &expected); + std::fs::write(&path, rewritten).expect("rewrite fixture"); + eprintln!("regenerated record_hash for {}", path.display()); + } + } else { + assert_eq!( + stored, + expected, + "{} carries a record_hash that is not the JCS-sha256 of its hashless \ + form (run with REGENERATE_LIFECYCLE_HASHES=1 to refresh)", + path.display() + ); + } + } +} + +#[test] +fn the_detached_attestation_round_trips_and_signs_the_observation_record() { + let attestation_path = fixtures_dir().join(ATTESTATION_FIXTURE); + let raw = std::fs::read_to_string(&attestation_path).expect("attestation readable"); + + // It deserializes through the dedicated detached type. + let attestation: RecordAttestation = + serde_json::from_str(&raw).expect("attestation deserializes through RecordAttestation"); + let reencoded = serde_json::to_value(&attestation).expect("re-serializes"); + let back: RecordAttestation = serde_json::from_value(reencoded).expect("re-parses"); + assert_eq!(back, attestation); + + // It signs the observation record's hash — a coherent, cross-linked fixture + // set. The attestation is detached: it is validated on its own, never as a + // member of a ContextRecord. + // Compute the observation hash directly (not by reading its stored field) so + // this test never races the fixture that rewrites observation.json. + let observation: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixtures_dir().join("observation.json")).expect("readable"), + ) + .expect("valid JSON"); + let observation_hash = compute_hash(&observation, "record_hash"); + + if regenerating() { + if attestation.signed_record_hash != observation_hash { + let rewritten = rewrite_hash(&raw, &attestation.signed_record_hash, &observation_hash); + std::fs::write(&attestation_path, rewritten).expect("rewrite attestation"); + eprintln!( + "regenerated signed_record_hash for {}", + attestation_path.display() + ); + } + } else { + assert_eq!( + attestation.signed_record_hash, observation_hash, + "the example attestation should sign the observation fixture's record_hash" + ); + } +} diff --git a/contextgraph-types/src/lib.rs b/contextgraph-types/src/lib.rs index 0063405..b59e6b5 100644 --- a/contextgraph-types/src/lib.rs +++ b/contextgraph-types/src/lib.rs @@ -17,6 +17,7 @@ pub mod error_code; pub mod frame; pub mod identity; pub mod query; +pub mod record; pub mod scope; pub mod token; pub mod usage; @@ -36,6 +37,12 @@ pub use frame::{ }; pub use identity::{FrameId, canonical_order}; pub use query::{ContextQuery, ContextQueryResult}; +pub use record::{ + ConstraintEffect, ContextRecord, ContractRequirement, DirectiveKind, Enforcement, + KnowledgeKind, LIFECYCLE_SCHEMA_VERSION, OriginClass, RecordAttestation, RecordBody, + RecordLink, RecordProvenance, RecordScope, RecordStatus, RequirementResult, SharingScope, + ValidationOutcome, +}; pub use scope::EgressScope; pub use token::{ BYTES_PER_BUDGET_TOKEN, SUGGESTED_HOST_SAFETY_FACTOR, budget_from_model_tokens, budget_tokens, diff --git a/contextgraph-types/src/record.rs b/contextgraph-types/src/record.rs new file mode 100644 index 0000000..e219205 --- /dev/null +++ b/contextgraph-types/src/record.rs @@ -0,0 +1,758 @@ +//! `ContextRecord` — the immutable, provenance-bearing unit of the +//! **Context Exchange Provider** lifecycle profile +//! (`contextgraph/lifecycle/1.0-draft`, issue #28). +//! +//! Where a [`ContextFrame`](crate::ContextFrame) is the *read* unit a provider +//! returns from `context/query`, a `ContextRecord` is the *exchange* unit a +//! Context Exchange Provider appends, gets, and resolves: a durable, content- +//! addressed record with a common envelope and a discriminated `record_kind` +//! body. The profile — not the frozen `contextgraph/1.0` core — owns this layer +//! (ADR 0007 §4, `docs/profiles/context-exchange-provider.md`). +//! +//! ## Shape (mirrors `schema/contextgraph-lifecycle-record.schema.json`) +//! +//! Every record carries the same **envelope** (`schema_version`, `record_id`, +//! `lineage_id`, `record_status`, `scope`, `sharing_scope`, `observed_at`, +//! `origin`, `provenance`, `record_hash`, and the optional temporal/confidence/ +//! link/extension fields) plus a flat, `record_kind`-discriminated body. The +//! JSON is flat and snake_case: the discriminant `record_kind` sits at the same +//! level as the body's fields, exactly like the envelope's internally-tagged +//! `type` on the wire. +//! +//! ## Immutability & identity +//! +//! A record is never mutated in place. A correction is a **new** record sharing +//! the earlier one's `lineage_id`; `record_status` moves `active → retracted` +//! or `active → archived` (three values — "superseded" is *derived* from +//! `lineage_id`, never stored, per reconciliation row B5). `record_hash` is the +//! `sha256:` over the RFC 8785 (JCS) canonicalization of the record with +//! its own `record_hash` member omitted from the preimage; the detached +//! [`RecordAttestation`] signs that hash and travels as ledger metadata beside +//! the record, never inside its hash preimage (reconciliation row C5). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::validate::{is_protocol_timestamp, is_well_formed_digest}; + +/// The profile version every `ContextRecord.schema_version` names. Distinct +/// from the wire [`PROTOCOL_VERSION`](crate::PROTOCOL_VERSION) +/// (`contextgraph/1.0-draft`): the lifecycle layer is a *profile* on top of the +/// base family (ADR 0007 §5, reconciliation row D4), so it version-stamps +/// itself rather than riding the core version. +pub const LIFECYCLE_SCHEMA_VERSION: &str = "contextgraph/lifecycle/1.0-draft"; + +/// Lifecycle status of a record (reconciliation row B5). Exactly three values: +/// a host may keep richer internal states, but the wire status is these three. +/// `superseded` is **not** here — it is derived from a later record on the same +/// [`lineage_id`](ContextRecord::lineage_id), never stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordStatus { + /// The record is in force. + Active, + /// Withdrawn by its author or authority; no longer asserted. + Retracted, + /// Retired from active use but retained for audit. + Archived, +} + +/// Who a record is shared with (reconciliation row E3). Conjunctive with +/// [`RecordScope`]: `sharing_scope` widens visibility *within* the scope keys +/// present, it does not replace them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharingScope { + /// Visible only to the owning user. + User, + /// Visible across the repository. + Repository, + /// Visible across the workspace. + Workspace, + /// Visible across the organization. + Organization, +} + +/// The coarse origin class of a record, keyed by the origin→derivation validity +/// matrix (reconciliation row C5). The full structured detail lives in +/// [`RecordProvenance`]; this is the one axis that constrains which +/// `provenance.derivation_kind` values are meaningful: +/// +/// | `origin` | valid `provenance.derivation_kind` | +/// |-----------|-------------------------------------| +/// | `observed` | absent (a first-hand observation is not derived) | +/// | `derived` | required (`summarization`, `inference`, `transformation`, …) | +/// | `declared` | absent (an authored assertion) | +/// | `imported` | optional (may name the upstream derivation) | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OriginClass { + /// First-hand observation of a trace, log, or event. + Observed, + /// Produced from other records by summarization/inference/transformation. + Derived, + /// Authored directly by a human or agent as an assertion. + Declared, + /// Ingested from an upstream store or provider. + Imported, +} + +/// The 7-key portable scope (reconciliation row E3). Every key is optional and +/// the present keys are **conjunctive** (AND): a record scoped to +/// `{repository_id, workspace_id}` belongs to that repository *and* that +/// workspace. `tenant_id` and `project_id` are deliberately **absent** from the +/// portable core — there is no cross-provider registry contract for them yet +/// (rows E2/E3), so a host keys on them only internally. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordScope { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub organization_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, +} + +impl RecordScope { + /// Whether any scope key is set. An all-empty scope names nowhere and is a + /// smell a provider **SHOULD** reject, though the type permits it so a + /// deserializer never fails on a sparse record. + pub fn is_empty(&self) -> bool { + self.user_id.is_none() + && self.organization_id.is_none() + && self.repository_id.is_none() + && self.workspace_id.is_none() + && self.environment_id.is_none() + && self.session_id.is_none() + && self.task_id.is_none() + } +} + +/// Structured provenance for a record (reconciliation row C5). Distinct from the +/// frame-layer [`Provenance`](crate::Provenance): a record's provenance names +/// the *producing* provider and authority and how the value was derived, not a +/// file/range digest chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordProvenance { + /// The provider that first produced this record. + pub origin_provider_id: String, + /// The authority (tenant/principal namespace) the record was produced under. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_authority_id: Option, + /// The class of producer. Open vocabulary; recommended values `human`, + /// `agent`, `tool`, `system`. + pub producer_kind: String, + /// A stable reference to the producer (an agent id, tool name, user id). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub producer_ref: Option, + /// How a `derived`/`imported` record was produced. Open vocabulary; + /// recommended values `summarization`, `inference`, `transformation`, + /// `import`. Absent for `observed`/`declared` origins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derivation_kind: Option, + /// Records or frames this one was derived from, closest-source first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_refs: Vec, +} + +/// A typed link from one record to another (an open `rel` vocabulary, namespaced +/// per SPEC.md §13 U3). Distinct from `evidence_links`, which are bare refs to +/// supporting evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordLink { + /// The relationship, e.g. `supersedes`, `refines`, `contradicts`. Open; + /// a vendor-specific rel MUST be namespaced (`vendor:rel`). + pub rel: String, + /// The `record_id` this link points at. + pub target_record_id: String, +} + +/// A detached attestation over a record's `record_hash` (reconciliation row C5, +/// shared with issue #12). It is **never** part of the record or its hash +/// preimage — it travels as ledger metadata beside the record, so re-signing or +/// key rotation never perturbs the content-addressed identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordAttestation { + /// The `sha256:` `record_hash` this attestation signs. + pub signed_record_hash: String, + /// The signing key's id; validity windows govern rotation. + pub key_id: String, + /// The signature algorithm, e.g. `ed25519`. + pub algorithm: String, + /// The attesting authority. + pub attester_id: String, + /// The detached signature (base64/hex per algorithm). + pub signature: String, + /// When the attestation was issued (protocol timestamp). + pub issued_at: String, +} + +/// A knowledge record's sub-kind (reconciliation rows B2/D1). `memory` and +/// `fact` are **not** directive kinds; `fact` is a knowledge kind here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KnowledgeKind { + Fact, + Assumption, + Decision, +} + +/// The four **portable** directive kinds (ADR 0007 §4, reconciliation row B3). +/// The six-kind taxonomy in the superseded downstream drafts is a host-runtime +/// convenience, not a wire contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DirectiveKind { + Preference, + Rule, + Constraint, + Procedure, +} + +/// What a `constraint` directive does. Deliberately only `require`/`forbid` — +/// **never `allow`**: authorization stays host-side (ADR 0007 §3, row B3). A +/// record carrying a constraint is a stored value, not a grant of authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConstraintEffect { + Require, + Forbid, +} + +/// How strongly a directive is meant to bind. `blocking` is a *recorded intent*, +/// not an enforcement grant — the host still decides whether to enforce it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Enforcement { + Advisory, + Blocking, +} + +/// The outcome of a contract validation or a single requirement check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ValidationOutcome { + Pass, + Fail, + Inconclusive, +} + +/// One requirement of an artifact contract (reconciliation row D6). The +/// `requirement_kind` is an open vocabulary; the reference validator recognises +/// ten kinds. A `command` requirement carries an `execution_approval_ref` — a +/// pointer to an out-of-band approval, **not** an authorization to execute: +/// contract *execution* is a host concern (ADR 0007 §3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractRequirement { + /// e.g. `file_exists`, `content_matches`, `command`, `schema_valid`. Open. + pub requirement_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Present for `command` requirements: a reference to the approval that + /// authorizes running it. The protocol carries the reference; it never + /// authorizes execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_approval_ref: Option, +} + +/// The result of checking one requirement, carried by a `contract_validation`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequirementResult { + pub requirement_kind: String, + pub outcome: ValidationOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// The `record_kind`-discriminated body of a [`ContextRecord`] — the 12 portable +/// record kinds (reconciliation row D1). Internally tagged by `record_kind` and +/// flattened into the envelope, so a record's JSON is one flat object. +/// +/// Every variant's *schema* is portable; the *execution*, *promotion*, and +/// *judging* they might imply are host concerns and stay out of the protocol +/// (ADR 0007 §3, rows D6/D7). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "record_kind", rename_all = "snake_case")] +pub enum RecordBody { + /// A first-hand observation of a trace, log, git event, or user behavior. + Observation { + statement: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_ref: Option, + }, + /// A fact, assumption, or decision (see [`KnowledgeKind`]). + Knowledge { + knowledge_kind: KnowledgeKind, + statement: String, + }, + /// A remembered episode or salient fact. A distinct record kind — memory is + /// **not** a directive kind (ADR 0007 §4). + Memory { + statement: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + salience: Option, + }, + /// A portable directive: preference/rule/constraint/procedure. Carrying a + /// directive record is not the same as a frame instructing a model — the + /// host still decides whether it is admitted or enforced (ADR 0007 §4). + Directive { + directive_kind: DirectiveKind, + statement: String, + /// Required when `directive_kind == constraint` (see + /// [`ContextRecord::envelope_invariants`]); absent otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + constraint_effect: Option, + /// Absent ⇒ `advisory`. + #[serde(default, skip_serializing_if = "Option::is_none")] + enforcement: Option, + /// Ordered steps for a `procedure` directive. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + procedure_steps: Vec, + }, + /// A proposal that some record be created/promoted — recorded so the + /// *decision* is auditable. The decision itself is a host concern (row D7). + RecordProposal { + proposed_kind: String, + rationale: String, + }, + /// Supporting evidence for another record. + Evidence { + statement: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + evidence_kind: Option, + }, + /// A contract an artifact must satisfy (reconciliation row D6). The protocol + /// carries it; the host executes it. + ArtifactContract { + contract_name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + requirements: Vec, + }, + /// The recorded result of validating an [`ArtifactContract`](RecordBody::ArtifactContract). + ContractValidation { + contract_ref: String, + outcome: ValidationOutcome, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + requirement_results: Vec, + }, + /// A recorded assessment of an outcome. Semantic judging is host-side; this + /// records the judgment as an immutable event (row D7). + OutcomeAssessment { + subject_ref: String, + assessment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rating: Option, + }, + /// An immutable event recording that a host promoted a record. When to + /// promote (thresholds, policy) stays host-side (row D7). + PromotionEvent { + subject_ref: String, + to_status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + from_status: Option, + }, + /// A record that some context (record/frame) was used in a task. Overlaps + /// the usage-report U1 surface; carried here for durable audit (row D7). + ContextUse { + used_record_ref: String, + selected: bool, + rendered: bool, + cited: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + task_ref: Option, + }, + /// Feedback on a prior [`ContextUse`](RecordBody::ContextUse). + ContextUseFeedback { + context_use_ref: String, + feedback: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rating: Option, + }, +} + +impl RecordBody { + /// The wire `record_kind` discriminant for this body. + pub fn record_kind(&self) -> &'static str { + match self { + RecordBody::Observation { .. } => "observation", + RecordBody::Knowledge { .. } => "knowledge", + RecordBody::Memory { .. } => "memory", + RecordBody::Directive { .. } => "directive", + RecordBody::RecordProposal { .. } => "record_proposal", + RecordBody::Evidence { .. } => "evidence", + RecordBody::ArtifactContract { .. } => "artifact_contract", + RecordBody::ContractValidation { .. } => "contract_validation", + RecordBody::OutcomeAssessment { .. } => "outcome_assessment", + RecordBody::PromotionEvent { .. } => "promotion_event", + RecordBody::ContextUse { .. } => "context_use", + RecordBody::ContextUseFeedback { .. } => "context_use_feedback", + } + } +} + +/// One immutable, content-addressed exchange record. The common envelope plus a +/// flat, `record_kind`-discriminated [`RecordBody`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ContextRecord { + /// Always [`LIFECYCLE_SCHEMA_VERSION`]. + pub schema_version: String, + /// Stable, provider-scoped identity of this exact record. + pub record_id: String, + /// Groups every revision of the same logical item; supersession is derived + /// from this, never stored as a status (row B5). + pub lineage_id: String, + /// Lifecycle status — three values (row B5). + pub record_status: RecordStatus, + /// The 7-key conjunctive scope (row E3). + pub scope: RecordScope, + /// Who the record is shared with (row E3). + pub sharing_scope: SharingScope, + /// Sensitivity class. Open vocabulary; recommended `public`, `internal`, + /// `confidential`, `restricted`. Absent ⇒ provider default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sensitivity: Option, + /// When the provider observed/produced this record (protocol timestamp). + pub observed_at: String, + /// When the record's assertion became true in the world. Absent ⇒ unbounded + /// into the past. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option, + /// Producer confidence in `[0, 1]` when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Coarse origin class, keyed by the origin→derivation matrix (row C5). + pub origin: OriginClass, + /// Bare references to supporting evidence records/frames. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence_links: Vec, + /// Typed links to other records. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub record_links: Vec, + /// `sha256:` over the JCS canonicalization of this record with + /// `record_hash` omitted from the preimage (row C5, profile §hashing). + pub record_hash: String, + /// Structured provenance (row C5). + pub provenance: RecordProvenance, + /// Namespaced extension members (SPEC.md §13 U3). The reference type models + /// the common string-valued case; the wire schema permits an open object. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extensions: Option>, + /// The flat, `record_kind`-discriminated body. + #[serde(flatten)] + pub body: RecordBody, +} + +impl ContextRecord { + /// The record's `record_kind` discriminant. + pub fn record_kind(&self) -> &'static str { + self.body.record_kind() + } + + /// Whether every temporal field present is in the protocol timestamp + /// profile (`SPEC.md` §6.1/F4). + pub fn has_valid_temporal_fields(&self) -> bool { + [self.observed_at.as_str()] + .into_iter() + .chain(self.valid_from.as_deref()) + .all(is_protocol_timestamp) + } + + /// Checks the profile's envelope invariants, returning the exact violation + /// so a conformance failure is actionable. Mirrors + /// [`ContextFrame::representation_invariants`](crate::ContextFrame::representation_invariants). + pub fn envelope_invariants(&self) -> Result<(), String> { + if self.schema_version != LIFECYCLE_SCHEMA_VERSION { + return Err(format!( + "schema_version must be {LIFECYCLE_SCHEMA_VERSION}, found {}", + self.schema_version + )); + } + if !is_well_formed_digest(&self.record_hash) { + return Err(format!( + "record_hash must be a sha256:<64 lowercase hex> digest, found {}", + self.record_hash + )); + } + if let Some(confidence) = self.confidence + && !(0.0..=1.0).contains(&confidence) + { + return Err(format!("confidence must be in [0, 1], found {confidence}")); + } + if !self.has_valid_temporal_fields() { + return Err("observed_at/valid_from must be protocol timestamps".into()); + } + // Origin→derivation validity matrix (row C5). + match self.origin { + OriginClass::Observed | OriginClass::Declared => { + if self.provenance.derivation_kind.is_some() { + return Err(format!( + "origin {:?} must not carry a provenance.derivation_kind", + self.origin + )); + } + } + OriginClass::Derived => { + if self.provenance.derivation_kind.is_none() { + return Err("origin derived requires a provenance.derivation_kind".into()); + } + } + OriginClass::Imported => {} + } + // A constraint directive must state its effect (row B3). + if let RecordBody::Directive { + directive_kind: DirectiveKind::Constraint, + constraint_effect, + .. + } = &self.body + && constraint_effect.is_none() + { + return Err("a constraint directive requires constraint_effect".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal well-formed envelope carrying the given body, for round-trip + /// tests. Values are chosen so `envelope_invariants` passes. + fn record_with(body: RecordBody) -> ContextRecord { + ContextRecord { + schema_version: LIFECYCLE_SCHEMA_VERSION.to_string(), + record_id: "rec_0001".into(), + lineage_id: "lin_0001".into(), + record_status: RecordStatus::Active, + scope: RecordScope { + repository_id: Some("repo_42".into()), + workspace_id: Some("ws_7".into()), + ..RecordScope::default() + }, + sharing_scope: SharingScope::Repository, + sensitivity: Some("internal".into()), + observed_at: "2026-07-29T00:00:00Z".into(), + valid_from: None, + confidence: Some(0.9), + origin: OriginClass::Observed, + evidence_links: Vec::new(), + record_links: Vec::new(), + record_hash: format!("sha256:{}", "a".repeat(64)), + provenance: RecordProvenance { + origin_provider_id: "provider_example".into(), + origin_authority_id: Some("authority_1".into()), + producer_kind: "agent".into(), + producer_ref: Some("agent://coder".into()), + derivation_kind: None, + source_refs: Vec::new(), + }, + extensions: None, + body, + } + } + + fn all_bodies() -> Vec { + vec![ + RecordBody::Observation { + statement: "the build failed on a flaky test".into(), + subject_ref: Some("run_991".into()), + }, + RecordBody::Knowledge { + knowledge_kind: KnowledgeKind::Fact, + statement: "the retry ceiling is 5".into(), + }, + RecordBody::Memory { + statement: "the user prefers terse diffs".into(), + salience: Some(0.7), + }, + RecordBody::Directive { + directive_kind: DirectiveKind::Constraint, + statement: "never write secrets to logs".into(), + constraint_effect: Some(ConstraintEffect::Forbid), + enforcement: Some(Enforcement::Blocking), + procedure_steps: Vec::new(), + }, + RecordBody::RecordProposal { + proposed_kind: "directive".into(), + rationale: "recurred across three sessions".into(), + }, + RecordBody::Evidence { + statement: "log line at 12:04 shows the timeout".into(), + evidence_kind: Some("log".into()), + }, + RecordBody::ArtifactContract { + contract_name: "api-handler".into(), + requirements: vec![ContractRequirement { + requirement_kind: "command".into(), + description: Some("cargo test passes".into()), + execution_approval_ref: Some("approval_1".into()), + }], + }, + RecordBody::ContractValidation { + contract_ref: "rec_contract_1".into(), + outcome: ValidationOutcome::Pass, + requirement_results: vec![RequirementResult { + requirement_kind: "command".into(), + outcome: ValidationOutcome::Pass, + detail: None, + }], + }, + RecordBody::OutcomeAssessment { + subject_ref: "rec_task_1".into(), + assessment: "resolved the issue".into(), + rating: Some(0.8), + }, + RecordBody::PromotionEvent { + subject_ref: "rec_dir_1".into(), + to_status: "active".into(), + from_status: Some("proposed".into()), + }, + RecordBody::ContextUse { + used_record_ref: "rec_know_1".into(), + selected: true, + rendered: true, + cited: false, + task_ref: Some("task_9".into()), + }, + RecordBody::ContextUseFeedback { + context_use_ref: "rec_use_1".into(), + feedback: "was not helpful".into(), + rating: Some(0.2), + }, + ] + } + + #[test] + fn every_record_kind_round_trips_through_json_and_stays_flat() { + for body in all_bodies() { + let kind = body.record_kind().to_string(); + let record = record_with(body); + let json = serde_json::to_string(&record).unwrap(); + + // The discriminant is flat: `record_kind` sits beside envelope + // fields, not nested under `body`. + assert!( + json.contains(&format!("\"record_kind\":\"{kind}\"")), + "record_kind must be a flat member for {kind}: {json}" + ); + assert!( + !json.contains("\"body\""), + "the body must flatten, not nest under `body`: {json}" + ); + + let back: ContextRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(back, record, "{kind} did not survive a serde round-trip"); + assert_eq!(back.record_kind(), kind); + back.envelope_invariants() + .unwrap_or_else(|e| panic!("{kind} envelope invalid: {e}")); + } + } + + #[test] + fn optional_envelope_fields_are_omitted_when_absent() { + let mut record = record_with(RecordBody::Observation { + statement: "x".into(), + subject_ref: None, + }); + record.sensitivity = None; + record.valid_from = None; + record.confidence = None; + record.evidence_links.clear(); + record.record_links.clear(); + record.extensions = None; + let json = serde_json::to_string(&record).unwrap(); + for absent in [ + "sensitivity", + "valid_from", + "confidence", + "evidence_links", + "record_links", + "extensions", + "subject_ref", + ] { + assert!(!json.contains(absent), "{absent} should be omitted: {json}"); + } + } + + #[test] + fn a_constraint_directive_without_an_effect_is_rejected() { + let record = record_with(RecordBody::Directive { + directive_kind: DirectiveKind::Constraint, + statement: "…".into(), + constraint_effect: None, + enforcement: None, + procedure_steps: Vec::new(), + }); + assert!(record.envelope_invariants().is_err()); + } + + #[test] + fn a_derived_record_must_name_its_derivation_and_an_observed_one_must_not() { + // derived without derivation_kind → invalid. + let mut derived = record_with(RecordBody::Knowledge { + knowledge_kind: KnowledgeKind::Decision, + statement: "chose retry ceiling 5".into(), + }); + derived.origin = OriginClass::Derived; + assert!(derived.envelope_invariants().is_err()); + derived.provenance.derivation_kind = Some("inference".into()); + assert!(derived.envelope_invariants().is_ok()); + + // observed WITH derivation_kind → invalid. + let mut observed = record_with(RecordBody::Observation { + statement: "x".into(), + subject_ref: None, + }); + observed.provenance.derivation_kind = Some("summarization".into()); + assert!(observed.envelope_invariants().is_err()); + } + + #[test] + fn a_bad_schema_version_or_hash_is_rejected() { + let mut record = record_with(RecordBody::Observation { + statement: "x".into(), + subject_ref: None, + }); + record.schema_version = "contextgraph/1.0-draft".into(); + assert!(record.envelope_invariants().is_err()); + + let mut record = record_with(RecordBody::Observation { + statement: "x".into(), + subject_ref: None, + }); + record.record_hash = "sha256:abc".into(); + assert!(record.envelope_invariants().is_err()); + } + + #[test] + fn an_attestation_round_trips_and_is_not_part_of_the_record() { + let attestation = RecordAttestation { + signed_record_hash: format!("sha256:{}", "a".repeat(64)), + key_id: "key_2026".into(), + algorithm: "ed25519".into(), + attester_id: "provider_example".into(), + signature: "MEUCIQ…".into(), + issued_at: "2026-07-29T00:00:00Z".into(), + }; + let json = serde_json::to_string(&attestation).unwrap(); + let back: RecordAttestation = serde_json::from_str(&json).unwrap(); + assert_eq!(back, attestation); + + // The record type has no attestation field — it is detached. + let record_json = serde_json::to_string(&record_with(RecordBody::Observation { + statement: "x".into(), + subject_ref: None, + })) + .unwrap(); + assert!(!record_json.contains("signature")); + } +} diff --git a/docs/adaptive-context-reconciliation.md b/docs/adaptive-context-reconciliation.md index fb0f0f2..042d802 100644 --- a/docs/adaptive-context-reconciliation.md +++ b/docs/adaptive-context-reconciliation.md @@ -3,6 +3,16 @@ **Status:** delta table for [#27](https://github.com/macanderson/context-graph-protocol/issues/27). Anchored on [ADR 0007 — the protocol/product boundary](./adr/0007-protocol-product-boundary.md). +> **Lifecycle-layer rows are now resolved.** Rows **D1, D4, D5, D6, D7, B3, B5, +> C5, E3** — the entire lifecycle/records exchange layer — are **resolved by the +> ratified Context Exchange Provider profile** +> ([`docs/profiles/context-exchange-provider.md`](./profiles/context-exchange-provider.md), +> `contextgraph/lifecycle/1.0-draft`, issue #28), with the JSON Schema +> [`schema/contextgraph-lifecycle-record.schema.json`](../schema/contextgraph-lifecycle-record.schema.json), +> the Rust types [`contextgraph-types::record`](../contextgraph-types/src/record.rs), +> and golden vectors under [`tests/fixtures/`](../tests/fixtures). Each row below +> is annotated **RESOLVED (#28)**. + ## What this reconciles An "adaptive-context" spec bundle was merged into **stella** @@ -53,9 +63,9 @@ holding editable local copies plus CGEP naming. |---|---|---|---|---|---| | B1 | **Directive as the core engine unit** (DS/FS) | The single typed unit of the context engine. | Not a frame concept; frame content is evidence, not a directive (SPEC §6/R3). | **push-downstream** | stella runtime (`stella-core::context_record`, live `DirectiveKind`). | | B2 | **Six directive kinds** `memory\|fact\|rule\|preference\|constraint\|procedure` (DS/FS drafts) | Portable taxonomy. | Four portable kinds `preference\|rule\|constraint\|procedure`; `memory`/`fact` are separate record kinds. oxagen's own lifecycle spec already says "Memory is not a directive kind." | **reject** (as *portable* taxonomy) | ADR 0007 §4. Six-kind version is a host convenience; the portable directive record (if #28 defines one) is four kinds. | -| B3 | **Directive-as-record, 4 kinds** (BP) + subtype fields (`constraint_effect: require\|forbid`, ordered `procedure` steps, `enforcement`, `origin`) | Protocol should define a `directive` record. | No directive record yet; the atomic frame has none. Not foreclosed. | **adopt-upstream (issue)** | **#28** Context Exchange Provider profile — as one immutable record kind. Not the frozen 1.0 core. | +| B3 | **Directive-as-record, 4 kinds** (BP) + subtype fields (`constraint_effect: require\|forbid`, ordered `procedure` steps, `enforcement`, `origin`) | Protocol should define a `directive` record. | No directive record yet; the atomic frame has none. Not foreclosed. | **adopt-upstream (issue)** | **RESOLVED (#28)** — the [profile](./profiles/context-exchange-provider.md) `directive` record: four portable `directive_kind`s, `constraint_effect: require\|forbid`, `enforcement`, ordered `procedure_steps` (profile §4.1, LD1–LD4). One immutable record kind, not frozen 1.0 core. | | B4 | **Directive lifecycle** (citation-stat pruning thresholds, precedence layers, promotion_status) | Engine behavior. | Pruning/promotion/precedence = host policy; protocol "carries the value, does not authorize it." | **push-downstream** | stella. `promotion_stage` explicitly stays out of any portable `Directive` (BP's own rule). | -| B5 | **Directive `status` enum** `active\|stale\|superseded\|archived` (FS) vs `active\|superseded\|archived` (DS) | Stored status. | Record status is `active\|retracted\|archived` (superseded is derived from lineage). | **adopt-upstream (issue)** / reconcile | **#28**. Host may keep richer internal statuses; wire status is the three-value record status. | +| B5 | **Directive `status` enum** `active\|stale\|superseded\|archived` (FS) vs `active\|superseded\|archived` (DS) | Stored status. | Record status is `active\|retracted\|archived` (superseded is derived from lineage). | **adopt-upstream (issue)** / reconcile | **RESOLVED (#28)** — [profile](./profiles/context-exchange-provider.md) LR3: wire `record_status` is `active\|retracted\|archived` (three values); `superseded` is derived from `lineage_id`, never stored. Host may keep richer internal statuses. | ## C. Temporal, tokens, provenance @@ -65,19 +75,19 @@ holding editable local copies plus CGEP naming. | C2 | **Half-open `[from, until)` intervals + `known_at`/`valid_at` point queries** (BP) | Protocol temporal semantics. | CGP temporal fields exist but are free-form strings (no RFC 3339 validation, no `as_of` probe). | **adopt-upstream (issue)** | **#10** (validate temporal fields as RFC 3339, probe `as_of`). Decide half-open + `known_at`/`valid_at` naming there. | | C3 | **`token_cost` / `canonical_token_cost` / `tokenizer_ref`** (BP) | Protocol token fields; wire cost optional, host computes. | `token_cost` **already normative & required** (B3: `ceil(utf8_bytes/4)`, ADR 0003). `canonical_token_cost`/`tokenizer_ref` already in the type. | **adopt-upstream (already landed)** | B3 for compact/reference frames (reference frame cost) is open: **#50**. | | C4 | **`token_budget` / `token_estimate`** (FS) | Frame budgeting fields. | Budgeting/allocation is a host concern; CGP carries per-frame `token_cost`, not a budget. | **push-downstream** | stella (`CompiledContextFrame` budgeting). | -| C5 | **Provenance schema, `content_hash` vs `canonical_content_hash` golden vectors, `RecordAttestation`** (BP) | Protocol provenance + detached attestation. | Provenance digest format is normative-*grammar* only (`sha256:<64 hex>`); no byte-match verification; no attestation. | **adopt-upstream (issue)** | **#12** (digest format + host-side provenance verification). Attestation → **#28** profile. | +| C5 | **Provenance schema, `content_hash` vs `canonical_content_hash` golden vectors, `RecordAttestation`** (BP) | Protocol provenance + detached attestation. | Provenance digest format is normative-*grammar* only (`sha256:<64 hex>`); no byte-match verification; no attestation. | **adopt-upstream (issue)** | **#12** (digest format + host-side provenance verification). **RESOLVED (#28)** for records — [profile](./profiles/context-exchange-provider.md) §7: structured `provenance` (`origin_provider_id`, `origin_authority_id`, `producer_kind`, `producer_ref`, `derivation_kind`, `source_refs`) + `origin`→derivation validity matrix (LC1–LC2), and the detached `RecordAttestation` (LC3). | ## D. Lifecycle / records / operations (the exchange layer) | # | Item | Bundle says (BP) | CGP position | Class | Destination | |---|---|---|---|---|---| -| D1 | **`ContextRecord` 12-kind taxonomy** (observation, knowledge, memory, directive, record_proposal, evidence, artifact_contract, contract_validation, outcome_assessment, promotion_event, context_use, context_use_feedback) + canonical envelope | Add to the protocol. | Not in CGP; CGP is frame-retrieval-only today. This is a whole new layer. | **adopt-upstream (issue)** | **#28** profile. The *record schemas* are portable; their *execution/promotion/validation* is host (push-down). | +| D1 | **`ContextRecord` 12-kind taxonomy** (observation, knowledge, memory, directive, record_proposal, evidence, artifact_contract, contract_validation, outcome_assessment, promotion_event, context_use, context_use_feedback) + canonical envelope | Add to the protocol. | Not in CGP; CGP is frame-retrieval-only today. This is a whole new layer. | **adopt-upstream (issue)** | **RESOLVED (#28)** — the [profile](./profiles/context-exchange-provider.md) §4 `ContextRecord`: 12-kind discriminated union + common envelope, in [schema](../schema/contextgraph-lifecycle-record.schema.json) + [Rust types](../contextgraph-types/src/record.rs) + [fixtures](../tests/fixtures). Record *schemas* portable; *execution/promotion/validation* host (LX1–LX4). | | D2 | **`context/records/append`** (batch, idempotency ledger, retention negotiation) | Write path. | `Capabilities.upsert` is a dead bool (no envelope/API). #5 recommends drop-and-defer. | **adopt-upstream (issue)** | **#5** — BP's append is the concrete write-path design that unblocks #5's "specify or drop." | | D3 | **`context/records/get`** (by exact `record_id`) & **`context/resolve`** (opaque `content_ref`, verify canonical hash, typed resolve failures) | Read/resolve path. | `Capabilities.resolve` advertised but no envelope/API; reference frames un-rehydratable. #50 open. | **adopt-upstream (issue)** | **#50** — BP's resolve + failure taxonomy is the design #50 asks for. | -| D4 | **Capability negotiation** under `cgep/lifecycle/1.0-draft` (representations, `known_at`, resolve, record kinds, operations, limits, retention, consent) | Add capabilities. | CGP has handshake capabilities; no lifecycle profile. | **adopt-upstream (issue)** — **naming normalized** to `contextgraph/lifecycle/1.0-draft`. | **#28**. | -| D5 | **28 typed error codes** (unsupported_capability, invalid_record, idempotency_conflict, retention_rejected, partial_failure, …) | Add. | CGP §9 has a 6-code table + open vocab (X1/X2). | **adopt-upstream (issue)** | Frame/query errors → **#49** (add `unsupported_representation`, version-mismatch code). Record/append/resolve errors → **#28**/#5/#50. | -| D6 | **`ArtifactContract` + `ContractValidation` records** (10-kind requirement validator, `command` needs `execution_approval_ref`) | Portable records. | Absent. Execution/judging is explicitly host. | **adopt-upstream (schema, issue)** / **push-downstream (execution)** | Record *schemas* → **#28**; contract *execution* + semantic judging stay in the host. | -| D7 | **`OutcomeAssessment`, `PromotionEvent`, `ContextUse`, `ContextUseFeedback`, `RecordProposal`** | Portable records. | `ContextUse`/feedback overlap CGP's usage reports (U1). Promotion/proposal are host decisions recorded as immutable events. | **adopt-upstream (schema, issue)** | **#28**. Keep policy (when to promote, thresholds) host-side. | +| D4 | **Capability negotiation** under `cgep/lifecycle/1.0-draft` (representations, `known_at`, resolve, record kinds, operations, limits, retention, consent) | Add capabilities. | CGP has handshake capabilities; no lifecycle profile. | **adopt-upstream (issue)** — **naming normalized** to `contextgraph/lifecycle/1.0-draft`. | **RESOLVED (#28)** — [profile](./profiles/context-exchange-provider.md) §2/§2.1: `contextgraph/lifecycle/1.0-draft`, advertised in a namespaced `lifecycle` handshake capability block (representations, `known_at`, resolve, record kinds, operations, limits, retention, consent). | +| D5 | **28 typed error codes** (unsupported_capability, invalid_record, idempotency_conflict, retention_rejected, partial_failure, …) | Add. | CGP §9 has a 6-code table + open vocab (X1/X2). | **adopt-upstream (issue)** | Frame/query errors → **#49** (add `unsupported_representation`, version-mismatch code). Record/append/resolve errors → **RESOLVED (#28)** — the [profile](./profiles/context-exchange-provider.md) §8 typed error table (`idempotency_conflict`, `retention_rejected`, `consent_required`, `content_hash_mismatch`, …), profile-reserved + vendor-namespaced per §10 X1/§13 U3. | +| D6 | **`ArtifactContract` + `ContractValidation` records** (10-kind requirement validator, `command` needs `execution_approval_ref`) | Portable records. | Absent. Execution/judging is explicitly host. | **adopt-upstream (schema, issue)** / **push-downstream (execution)** | **RESOLVED (#28)** — [profile](./profiles/context-exchange-provider.md) §4.2 LX1/LX2: `artifact_contract` + `contract_validation` schemas portable (a `command` requirement carries `execution_approval_ref`, never authorization); contract *execution* + semantic judging stay host-side. | +| D7 | **`OutcomeAssessment`, `PromotionEvent`, `ContextUse`, `ContextUseFeedback`, `RecordProposal`** | Portable records. | `ContextUse`/feedback overlap CGP's usage reports (U1). Promotion/proposal are host decisions recorded as immutable events. | **adopt-upstream (schema, issue)** | **RESOLVED (#28)** — [profile](./profiles/context-exchange-provider.md) §4.2 LX3/LX4: `outcome_assessment`/`promotion_event`/`record_proposal` record decisions as immutable events; `context_use`/`context_use_feedback` are the durable projection of usage-report U1 and stay reconcilable. Policy (when to promote, thresholds) host-side. | | D8 | **`subscribe` / staleness push** | (BP is pull-based; leans on verify.) | `Capabilities.subscribe` is a dead bool; #6 recommends drop-and-defer; freshness in 1.0 is pull `context/verify`. | **push (defer)** | **#6** — no change to the recommendation; noted for completeness. | ## E. Scope / naming / rejections @@ -86,7 +96,7 @@ holding editable local copies plus CGEP naming. |---|---|---|---|---|---| | E1 | **Rename to "Context Graph Exchange Protocol / CGEP"**, `cgep/1.0-draft` namespace, `context-graph-exchange-protocol` repo (BP §naming; oxagen lifecycle §23; rationale: "AgentSpeak uses Context Graph Protocol") | Rename the protocol. | Canonical name is **Context Graph Protocol (CGP)**; wire `contextgraph/1.0-draft`; stem `contextgraph`. Owner confirmed 2026-07-23. | **reject** | ADR 0007 §5. Every adopted BP item is normalized to CGP naming. | | E2 | **Portable `project_id` in scope** (DS draft) | Add to portable scope. | BP itself forbids it ("do not add `project_id` to the portable core until there is a cross-provider registry contract"); oxagen marks the draft superseded. | **reject (defer)** | Not portable until a registry contract exists. Host may key on project internally. | -| E3 | **9-key `Scope`** (tenant/org/workspace/project/repo/env/session/task/user) (FS) vs BP's 7-key portable scope + `sharing_scope` | Frame scope. | CGP query scope differs; portable record scope belongs to the profile. | **adopt-upstream (issue)** / reconcile | **#28** defines the portable scope (7-key + `sharing_scope`, conjunctive); drop `tenant_id`/`project_id` from portable core. | +| E3 | **9-key `Scope`** (tenant/org/workspace/project/repo/env/session/task/user) (FS) vs BP's 7-key portable scope + `sharing_scope` | Frame scope. | CGP query scope differs; portable record scope belongs to the profile. | **adopt-upstream (issue)** / reconcile | **RESOLVED (#28)** — [profile](./profiles/context-exchange-provider.md) §5.1 LS1–LS3: portable `scope` is the 7-key `{user_id, organization_id, repository_id, workspace_id, environment_id, session_id, task_id}` + `sharing_scope: user\|repository\|workspace\|organization`, all conjunctive; `tenant_id`/`project_id` dropped from the portable core (schema rejects them). | | E4 | **`context/propose`, `context/promote`, `context/validate` operations** | (BP explicitly says do **not** expose these.) | Agree — policy-executing operations are host-only; the protocol records decisions after the host makes them. | **reject** | Recorded as a boundary invariant (ADR 0007 §3). | ## Disposition summary @@ -97,7 +107,9 @@ holding editable local copies plus CGEP naming. issue updated with a pointer to this table): **#49** (SPEC.md completeness: representations, verify, identity, `unsupported_representation`), **#28** (exchange-provider profile: record taxonomy, capabilities, scope, - attestation, artifact/outcome/promotion/use records), **#5** (append write + attestation, artifact/outcome/promotion/use records) — **ratified** as + [`docs/profiles/context-exchange-provider.md`](./profiles/context-exchange-provider.md) + (`contextgraph/lifecycle/1.0-draft`), **#5** (append write path), **#50** (resolve + B3 for reference frames), **#12** (digest format + provenance verification), **#10** (RFC 3339 temporal + `as_of` probe). **#6** unchanged (defer subscribe). diff --git a/docs/composition-walkthrough.md b/docs/composition-walkthrough.md index 08f62bd..0f73091 100644 --- a/docs/composition-walkthrough.md +++ b/docs/composition-walkthrough.md @@ -1,6 +1,6 @@ # Composing MCP and Context Graph Protocol -The README says Context Graph Protocol is "complementary to MCP — compose them." +The README says Context Graph Protocol (CGP) is "complementary to MCP — compose them." This is that composition, made concrete: one agent session that uses **MCP tools for actions** and **CGP frames for context**, with a budget audit and citations that MCP alone does not carry. diff --git a/docs/implementing-a-provider.md b/docs/implementing-a-provider.md index b66c43c..1c40a53 100644 --- a/docs/implementing-a-provider.md +++ b/docs/implementing-a-provider.md @@ -1,6 +1,6 @@ # Implementing a CGP provider -There are two ways to implement a CGP provider, depending on whether you're +There are two ways to implement a Context Graph Protocol (CGP) provider, depending on whether you're writing Rust that runs inside the same process as the host, or a standalone program (in any language) that the host talks to as a child process or a remote HTTP endpoint. @@ -45,7 +45,7 @@ with `host.register(Box::new(my_provider))` and it participates in ## Option B: out-of-process, via the wire protocol (any language) A provider written in any language — the common case for a third-party -integration — implements the Context Graph Protocol wire protocol directly. `contextgraph-host` speaks +integration — implements the CGP wire protocol directly. `contextgraph-host` speaks this protocol over two transports; you only need to implement one: - **stdio** — the host spawns your program as a child process and exchanges @@ -132,7 +132,7 @@ do: - `reads: true` — you can see workspace content via query payloads. - `writes: true` — you persist `context/upsert`-style writes (not yet part - of the query/frames exchange in this crate; reserved for a future Context Graph Protocol + of the query/frames exchange in this crate; reserved for a future CGP method). - `egress: true` — **anything you do sends data off the local machine.** diff --git a/docs/index.md b/docs/index.md index 0a380af..894ea49 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,23 @@ # Context Graph Protocol reference docs -Reference documentation for the **Context Graph Protocol** crates: +Reference documentation for the **Context Graph Protocol (CGP)** crates: [`contextgraph-types`](https://crates.io/crates/contextgraph-types), [`contextgraph-host`](https://crates.io/crates/contextgraph-host), and [`contextgraph-conformance`](https://crates.io/crates/contextgraph-conformance). - [**The Context Graph Protocol: A Technical Overview**](./overview.md) — the - one-read marketing overview for engineers: the problem Context Graph Protocol solves, the seven + one-read marketing overview for engineers: the problem CGP solves, the seven guarantees, the wire surface, how it relates to MCP, and why you would build - against it. Start here if you are new to Context Graph Protocol. + against it. Start here if you are new to CGP. - [**The Context Graph Protocol: Advantages and Uniqueness**](./protocol-advantages.md) - — standalone research analysis of the seven advantages that make Context Graph Protocol a + — standalone research analysis of the seven advantages that make CGP a qualitatively different approach to context retrieval (provenance, budget honesty, consent enforcement, conformance verification, citation guarantees, version stability, temporal validity), and why the combination is irreducible. - [**Protocol surface**](./protocol-surface.md) — the wire types: context frames, queries, capabilities, provenance. Start here to understand *what* - Context Graph Protocol is. + CGP is. - [**Context reuse**](./context-reuse.md) — the four interlocking guarantees that make reusing context across turns cache-friendly, auditable, and safe: deterministic composition (stable frame identity + canonical ordering), usage @@ -46,10 +46,10 @@ Reference documentation for the **Context Graph Protocol** crates: `[full]` on demand. Provider *policy*, not protocol — a worked example of building one. - [**Running conformance**](./running-conformance.md) — how to prove your - provider (or host) is Context Graph Protocol conformant, via the `contextgraph-inspect` CLI or the + provider (or host) is CGP conformant, via the `contextgraph-inspect` CLI or the `contextgraph-conformance` library. Start here to *verify* what you built. -- [**Conformance registry**](./registry.md) — providers that are Context Graph - Protocol conformant today, with a reproducible report backing each claim, +- [**Conformance registry**](./registry.md) — providers that are CGP + conformant today, with a reproducible report backing each claim, and how to get your own provider listed. - [**Stability**](./stability.md) — the crate-semver vs. protocol-version relationship, and what changes (and doesn't) as the protocol moves from diff --git a/docs/overview.md b/docs/overview.md index 852b002..b8df2fd 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -4,7 +4,7 @@ > analysis, read [Advantages and Uniqueness](./protocol-advantages.md). If you > want to build a provider today, read > [Implementing a provider](./implementing-a-provider.md). This page is the -> one-read explanation of what Context Graph Protocol is, why it exists, and why you would build +> one-read explanation of what Context Graph Protocol (CGP) is, why it exists, and why you would build > against it. --- @@ -29,14 +29,14 @@ account for. It works until the budget silently overflows, a provider lies about cost, workspace content leaks to a third party, or an auditor asks "where did this answer come from?" and there is no trail. -The Context Graph Protocol makes every one of those questions answerable. +CGP makes every one of those questions answerable. Not by convention, but by contract. --- ## What Context Graph Protocol is, in one paragraph -Context Graph Protocol is an open wire protocol for context retrieval. It treats a piece of context +CGP is an open wire protocol for context retrieval. It treats a piece of context as a typed, budgeted, provenance-carrying, consent-gated, and conformance-verified unit of exchange called a **frame**. A host asks providers for frames relevant to a goal, under a token budget. Each provider returns @@ -53,7 +53,7 @@ conformance suite). All three are dual-licensed MIT OR Apache-2.0. ## The seven guarantees -Context Graph Protocol makes seven promises about every frame that enters a prompt. Each one is a +CGP makes seven promises about every frame that enters a prompt. Each one is a type in `contextgraph-types` and an enforcement path in `contextgraph-host` or `contextgraph-conformance`, not a line in a style guide. @@ -62,7 +62,7 @@ not a line in a style guide. | **Provenance** | Every frame carries its origin: URI, line range, cryptographic digest, method, and the agent that produced it | `ContextFrame.provenance` | | **Budget honesty** | A provider's frames never sum above the query's `max_tokens`. A provider that lies is detected and its frames are dropped, loudly | Host budget audit + `budget-honesty` conformance check | | **Consent enforcement** | A provider that sends data off-machine is never queried until you record named, revocable consent. The query payload is not transmitted first | `ConsentStore` gate in `contextgraph-host` | -| **Conformance** | "Context Graph Protocol conformant" is a checkable claim, not a self-attestation. The suite is adversarial and ships a mode that trips every failure on purpose | `contextgraph-conformance`, 5 checks | +| **Conformance** | "CGP conformant" is a checkable claim, not a self-attestation. The suite is adversarial and ships a mode that trips every failure on purpose | `contextgraph-conformance`, 5 checks | | **Citation** | Every frame has a non-empty title and citation label. Raw ids are never the on-screen identifier | `frame-validity` conformance check | | **Version stability** | The protocol evolves inside a major family. The draft-to-stable freeze needs no flag day and breaks no deployed provider | `versions_compatible` in `contextgraph-host` | | **Temporal validity** | Facts carry `valid_from` and `valid_to` windows. A query can pin retrieval to a point in time with `as_of` | `ContextFrame` temporal fields | @@ -70,7 +70,7 @@ not a line in a style guide. The properties compose, and the combination is the point. Provenance without budget honesty means you can trace a frame but not control its cost. Budget honesty without consent means costs are honest but data can still leak. Remove -any one and the trust model collapses back to the blob-pipe. That is why Context Graph Protocol is +any one and the trust model collapses back to the blob-pipe. That is why CGP is specified as one integrated protocol, not a menu of options. --- @@ -141,11 +141,11 @@ separates a message body from its headers. ## How Context Graph Protocol relates to MCP They are complementary, not competing. The Model Context Protocol (MCP) connects -**tools**: functions an agent calls to take an action. Context Graph Protocol connects **context**: +**tools**: functions an agent calls to take an action. CGP connects **context**: typed, budgeted, cited evidence a host composes into the prompt before the agent acts. MCP has no budget-honesty contract, no egress consent gate, no provenance chain, and no conformance suite, because those are outside its scope, not -deficiencies in it. An agent that needs both composes them. Context Graph Protocol frames feed the +deficiencies in it. An agent that needs both composes them. CGP frames feed the prompt. MCP tools do the work. --- @@ -156,7 +156,7 @@ prompt. MCP tools do the work. to writing a provider is a JSON codec and the wire table. In-process, over stdio, or over HTTP. - **Conformance is a test you run in CI.** Point `contextgraph-inspect` at your provider. - Green means it works with any Context Graph Protocol host. A broken provider is caught at CI time, + Green means it works with any CGP host. A broken provider is caught at CI time, not at integration time. The suite ships a `--misbehave` mode that trips every check on purpose, so you know the checks are real. - **Stability you can pin.** The protocol version is `contextgraph/1.0-draft`. Two versions @@ -168,7 +168,7 @@ prompt. MCP tools do the work. ## Status -Context Graph Protocol is `contextgraph/1.0-draft` today. The wire types are stable enough to build against, +CGP is `contextgraph/1.0-draft` today. The wire types are stable enough to build against, the host runtime enforces the guarantees, and the conformance suite verifies them. The path from "open context as an idea" to "open context as a standard" is the conformance suite: anyone can build a provider, anyone can verify it, and the diff --git a/docs/profiles/context-exchange-provider.md b/docs/profiles/context-exchange-provider.md index b4a4a3d..be591a6 100644 --- a/docs/profiles/context-exchange-provider.md +++ b/docs/profiles/context-exchange-provider.md @@ -1,125 +1,265 @@ -# Profile: Context Exchange Provider (CEP) — DRAFT SKELETON +# Profile: Context Exchange Provider (`contextgraph/lifecycle/1.0-draft`) -> **Status: draft skeleton, not normative.** This frames issue #28 and marks the -> decisions a real profile needs. It is co-developed with the first -> implementation (Oxagen's platform-side Context Exchange Provider) and **must -> not** be treated as a frozen contract. Sections marked **[OPEN]** are -> maintainer/implementer decisions, not settled by this document. +> **Status: normative profile (draft).** This document ratifies the Context +> Exchange Provider profile for issue +> [#28](https://github.com/macanderson/context-graph-protocol/issues/28). It is +> the single source of truth for the lifecycle/records exchange layer: the wire +> shapes here, the JSON Schema +> [`schema/contextgraph-lifecycle-record.schema.json`](../../schema/contextgraph-lifecycle-record.schema.json), +> the Rust types +> [`contextgraph-types::record`](../../contextgraph-types/src/record.rs), and the +> example vectors under [`tests/fixtures/`](../../tests/fixtures) are one +> description of one layer. It supersedes the earlier "draft skeleton" and the +> downstream build prompt as the authority on record wire shapes. +> +> Requirement keys use RFC 2119 language and stable anchors (`LH1`, `LR1`, …) +> matching [`SPEC.md`](../../SPEC.md)'s style, so a conformance check or another +> document can cite them. +> +> **Scope guard.** This is a **profile on top of the base spec** (the same +> pattern as the host profile, issue #14), layered on the `contextgraph/1` +> family — **not** new frozen-`1.0` core surface. It carries record *values* and +> *decisions*; it never grants a host authority to act on them (ADR 0007 §3). +> Anchored on [ADR 0007 — the protocol/product boundary](../adr/0007-protocol-product-boundary.md) +> and the [adaptive-context reconciliation](../adaptive-context-reconciliation.md) +> delta table (rows D1/D4/D5/D6/D7, B3/B5, E3, C5), which this profile resolves. -## Why a profile, not core +## 1. Why a profile, not core `contextgraph/1.0` is a **read** protocol: a host queries providers for budgeted, provenance-carrying frames and optionally revalidates them (`context/verify`). It deliberately excludes the write path (`context/upsert`, issue #5), push invalidation (`subscribe`, issue #6), and content resolution -(`context/resolve`, issue #50) — each was removed or deferred pre-freeze -(ADR 0004; SPEC.md §6.4.1) precisely because core 1.0 had no consumer that -forced their design and freezing an unexercised operation is the +(`context/resolve`, issue #50 → this profile) — each was removed or deferred +pre-freeze (ADR 0004; SPEC.md §6.4.1) precisely because core 1.0 had no consumer +that forced their design, and freezing an unexercised operation is the dead-capability anti-pattern. -A **Context Exchange Provider** is the consumer that forces those designs. It is -a provider that, beyond answering `context/query`, offers a **durable, -multi-tenant, auditable exchange** of context records: append with idempotency, -retrieval by identity, content resolution, retention commitments, and signed -attestations. That is a larger contract than a read-only provider, and it earns -its own **profile** layered on the `contextgraph/1` family rather than bloating -the core every provider must implement. +A **Context Exchange Provider (CEP)** is the consumer that forces those designs. +Beyond answering `context/query`, it offers a **durable, multi-tenant, auditable +exchange** of immutable context *records*: append with idempotency, retrieval by +identity, content resolution, retention commitments, and signed attestations. +That is a larger contract than a read-only provider, and it earns its own +**profile** layered on the `contextgraph/1` family rather than bloating the core +every provider must implement. It is also the concrete path to GOVERNANCE freeze +**criterion 1** (two independent implementations): the reference host + crates on +one side, a genuine third-party CEP on the other. -This profile is also the concrete path to GOVERNANCE freeze **criterion 1** (two -independent implementations): the reference host + crates on one side, a genuine -third-party CEP on the other. +## 2. Profile identifier and discovery (resolves the identifier + handshake [OPEN]) -## Relationship to the core protocol +| # | Requirement | +|---|---| +| **LP-ID1** | A CEP **MUST** be a conformant `contextgraph/1.0` provider first: green on `contextgraph-conformance` for its declared read capabilities (SPEC.md §12). The exchange operations are **additive**, gated behind capability advertisement. | +| **LP-ID2** | The profile identifier is **`contextgraph/lifecycle/1.0-draft`** (ADR 0007 §5; reconciliation row D4). Every record's `schema_version` **MUST** equal this string. The `cgep/*` namespace and the "CGEP" rename are rejected (ADR 0007 §5). | +| **LP-ID3** | A CEP advertises profile support **in the handshake capability document**, under a namespaced `lifecycle` capability block (a member of the provider's advertised capabilities, not a new envelope). A host discovers CEP support by reading that block; its absence means the provider is read-only and the exchange operations **MUST NOT** be sent to it. | +| **LP-ID4** | The core major-family rule (SPEC.md §3.1) and the extensibility rules (SPEC.md §13, U1–U4) apply unchanged. The profile version tracks the core `-draft` freeze but is versioned independently (`lifecycle/1.0`), so the profile may reach `1.x` on its own additive cadence. | -- A CEP **MUST** be a conformant `contextgraph/1.0` provider first: it passes - `contextgraph-conformance` for its declared capability set. The exchange - operations are **additive** on top, gated behind capability advertisement. -- Profile identifier: **[OPEN]** the implementation targets - `cgep/lifecycle/1.0-draft` as a profile version distinct from the wire - `contextgraph/1.0-draft`. Decide whether the profile version rides in the - handshake capability document, a separate profile-version field, or a - namespaced capability — and how a host discovers CEP support. The core - major-family rule (§3.1) and extensibility rules (§13) apply unchanged. +### 2.1 Capability negotiation (resolves D4) -## Operations this profile adds (beyond core `context/query` + `context/verify`) +The `lifecycle` capability block advertises, at minimum: the **representations** +served (`full`/`compact`/`reference`); **`known_at`** point-query support; +**resolve** support; the **record kinds** served (a subset of the 12 in §4); the +**operations** offered (§6); **payload and batch limits** for append/get; +**retention classes** honored; the **consent class**; and the provider's +**unknown-field behavior** (which **MUST** be U1 ignore-on-read for the interop +path, per SPEC.md §13). A host **MUST NOT** send an operation, representation, or +record kind the provider did not advertise; a provider asked for an unadvertised +one replies `error` with the matching code from §8. + +## 3. Canonical hashing — RFC 8785 JCS (resolves the JCS [OPEN]) + +Records are **content-addressed**. `record_hash` is the anchor of a record's +identity, of idempotency replay, and of attestation. + +| # | Requirement | +|---|---| +| **LH1** | `record_hash` **MUST** be `sha256:<64 lowercase hex>` (SPEC.md §6.2 grammar) over the **RFC 8785 (JCS)** canonicalization of the record **with its own `record_hash` member removed from the preimage**. A record never hashes over its own hash. | +| **LH2** | Canonicalization is **RFC 8785** exactly: object members sorted by code point, minimal separators, no insignificant whitespace, and the RFC 8785 **number policy** (the ECMAScript `Number.prototype.toString` shortest round-trip form — e.g. `0.9`, not `0.90`; integers with no decimal point). Two implementations that agree on the bytes agree on the hash. | +| **LH3** | The **detached** attestation (`RecordAttestation`, §7) is **never** part of the record or its `record_hash` preimage. Re-signing or key rotation therefore never perturbs a record's content-addressed identity. | +| **LH4** | The reference implementation **MAY** additionally compute a `command_hash` over `(record_hash + requested_retention + behavior-changing options)` for idempotency keying (§5); that hash is a provider-ledger concern, not part of the record wire shape. | + +The canonical JCS/`record_hash` **golden vectors** are the interop spine and live +in this repo (§9). The reference Rust `serde_json_canonicalizer` and a +`json.dumps(sort_keys=True, separators=(",",":"))` Python canonicalizer both +reproduce the vectors' hashes byte-for-byte; a fully worked example is in +[`tests/fixtures/README.md`](../../tests/fixtures/README.md). + +## 4. The `ContextRecord` (resolves D1) + +Every record is one immutable JSON object: a **common envelope** plus a **flat, +`record_kind`-discriminated body** (snake_case, one flat object — the +discriminant `record_kind` sits at the same level as the body's fields, exactly +like the envelope `type` on the wire). The 12 portable kinds (row D1): +`observation`, `knowledge`, `memory`, `directive`, `record_proposal`, +`evidence`, `artifact_contract`, `contract_validation`, `outcome_assessment`, +`promotion_event`, `context_use`, `context_use_feedback`. + +**Common envelope:** `schema_version`, `record_id`, `lineage_id`, `record_kind`, +`record_status`, `scope`, `sharing_scope`, `sensitivity`, `observed_at`, +`valid_from`, `confidence`, `origin`, `evidence_links`, `record_links`, +`record_hash`, `provenance`, `extensions`. + +| # | Requirement | +|---|---| +| **LR1** | A record **MUST** carry the required envelope members: `schema_version`, `record_id`, `lineage_id`, `record_kind`, `record_status`, `scope`, `sharing_scope`, `observed_at`, `origin`, `record_hash`, `provenance`. The rest are optional. | +| **LR2** | Records are **immutable**. A correction is a **new** record with a **new** `record_id` sharing the earlier record's `lineage_id`; a record is never mutated in place. | +| **LR3** | `record_status` is exactly three values — **`active` \| `retracted` \| `archived`** (row B5). `superseded` is **not** a status: supersession is **derived** from a later record on the same `lineage_id`, never stored. A host **MAY** keep richer internal statuses; the wire status is these three. | +| **LR4** | Temporal members (`observed_at`, `valid_from`) **MUST** match the protocol timestamp profile (SPEC.md §6.1/F4). `observed_at` is when the provider learned the record; `valid_from` bounds when its assertion was true in the world. | +| **LR5** | `confidence`, when present, **MUST** be in `[0, 1]`. | +| **LR6** | `record_kind` is **closed within `lifecycle/1.0`**; a new kind is a `lifecycle/1.x` addition. A receiver **MUST NOT** reject a record solely for carrying an unrecognised member (SPEC.md §13 U1); the strict JSON Schema is an authoring lint, not the interop contract. | +| **LR7** | `extensions` members and any vendor-specific `record_links.rel` **MUST** be namespaced (`vendor:name`, SPEC.md §13 U3) so a vendor field can never collide with a member this profile defines or later reserves. | + +### 4.1 Directive records (resolves B3/B5) + +A `directive` may exist as one immutable, provenance-bearing record kind that a +provider stores and serves. Carrying a directive record is **not** a frame +instructing a model: the host still decides whether any directive is admitted, +enforced, or authorized (ADR 0007 §4). + +| # | Requirement | +|---|---| +| **LD1** | The **portable** `directive_kind` taxonomy is exactly **`preference` \| `rule` \| `constraint` \| `procedure`** (four kinds; ADR 0007 §4, row B3). `memory` and `fact` are **not** directive kinds — `memory` is its own record kind, `fact` is a `knowledge_kind`. The six-kind taxonomy in the superseded drafts is a host-runtime convenience, not a wire contract. | +| **LD2** | A `constraint` directive **MUST** carry `constraint_effect`, one of **`require` \| `forbid`** — **never `allow`**. Authorization stays host-side (ADR 0007 §3): a stored constraint is a value, not a grant. | +| **LD3** | `enforcement` is `advisory` \| `blocking`; absent ⇒ `advisory`. `blocking` is a **recorded intent**, not an enforcement grant — the host decides whether to enforce. | +| **LD4** | A `procedure` directive carries ordered `procedure_steps`. `promotion_stage`/`promotion_status` and pruning thresholds are **host** concerns and **MUST NOT** appear on the portable directive record (row B4, the build prompt's own rule). | + +### 4.2 Records the protocol carries but does not execute (resolves D6/D7) + +The protocol carries these record **schemas**; their **execution**, **judging**, +and **promotion decisions** are host concerns and stay out (ADR 0007 §3/§4). +This is the **schema-vs-execution split**: the wire moves the record, the host +acts on it. + +| # | Requirement | +|---|---| +| **LX1** | `artifact_contract` carries named `requirements` (an open `requirement_kind` vocabulary; the reference validator recognises ten kinds). A `command` requirement **MUST** carry an `execution_approval_ref` — a pointer to an out-of-band approval, **not** an authorization to execute. Contract **execution** is host-side (row D6). | +| **LX2** | `contract_validation` records the **result** of validating a contract (`outcome: pass \| fail \| inconclusive`, optional per-requirement results). The act of validating and any semantic **judging** is host-side. | +| **LX3** | `outcome_assessment`, `promotion_event`, and `record_proposal` record **decisions the host already made**, as immutable events. **When** to promote (thresholds, policy, precedence) stays host-side (row D7); the protocol records the decision after the host makes it. `context/propose`, `context/promote`, and `context/validate` operations are **rejected** as protocol surface (ADR 0007 §3, row E4). | +| **LX4** | `context_use` and `context_use_feedback` overlap the core **usage-report (U1)** surface (SPEC.md §7.3). They are the **durable-record** projection of that signal; a CEP that also emits usage reports **MUST** keep the two reconcilable (the `context_use` booleans `selected`/`rendered`/`cited` carry the same meaning as attribution, SPEC.md §14 A2). | + +## 5. Scope, sharing, idempotency, retention, identity + +### 5.1 Portable scope (resolves E3) + +| # | Requirement | +|---|---| +| **LS1** | The portable `scope` is the **7-key** object `{user_id, organization_id, repository_id, workspace_id, environment_id, session_id, task_id}`. Every key is optional; the **present keys are conjunctive (AND)**. | +| **LS2** | `sharing_scope` is one of **`user` \| `repository` \| `workspace` \| `organization`**, **conjunctive** with `scope`: it widens visibility within the scope keys present, it does not replace them. | +| **LS3** | `tenant_id` and `project_id` are **dropped** from the portable core (rows E2/E3): there is no cross-provider registry contract for them yet. A host **MAY** key on them internally; they **MUST NOT** appear in the portable `scope`. | + +### 5.2 Idempotency, retention, identity, authorization + +| # | Requirement | +|---|---| +| **LO-ID1** | Idempotency is keyed by `UNIQUE(authority_id, client_id, operation, idempotency_key)`. Same key + same command hash ⇒ replay the receipt as `duplicate`; same key + different hash ⇒ `idempotency_conflict`; an expired key ⇒ `idempotency_expired`; an existing `record_id` + different content ⇒ `record_identity_conflict`. **Never** silent re-execution. | +| **LO-ID2** | A provider that cannot honor a `requested_retention` **MUST** reject with `retention_rejected` **before** persistence — never silently shorten or lengthen. Accepted retention is recorded and enforced. | +| **LO-ID3** | The authenticated principal is resolved by the transport/auth layer; request-supplied identity labels **never** substitute. Sharing-scope authorization is enforced **before persistence and on every read** (`scope_denied`/`sharing_denied`). | +| **LO-ID4** | **Capability support never implies consent.** `consent_required` is a live error path even when a capability is advertised (cf. SPEC.md §4 C-series). Consent policy is sourced from the transport/host consent layer, not from capability advertisement. | + +## 6. Operations (resolves D2/D5 and the `context/resolve` home) + +Beyond core `context/query` + `context/verify`, this profile adds three +operations, each advertised in the `lifecycle` capability block (§2.1): | Op | Purpose | Core issue it realizes | | -- | ------- | ---------------------- | -| `context/records/append` | Durable, idempotent, batched write of context records with optional retention request; returns a receipt (`accepted`/`duplicate`/`rejected`). | #5 (write path) | +| `context/records/append` | Durable, idempotent, batched write of records with an optional retention request; returns a receipt (`accepted`/`duplicate`/`rejected`). | #5 (write path) | | `context/records/get` | Exact retrieval by record identity for the authorized principal. | #5 | -| `context/resolve` | Return the full source content of a `compact`/`reference` frame's `content_ref`, verifying `canonical_content_hash` before returning. | #50 / SPEC.md §6.4.1, [docs/sketches/resolve.md](../sketches/resolve.md) | -| *(deferred)* change feed / subscribe | Push staleness/invalidation. | #6 ([docs/sketches/push-invalidation.md](../sketches/push-invalidation.md)) | - -## Contract surface a CEP profile must pin (skeleton — details **[OPEN]**) - -1. **Canonical hashing.** Records are content-addressed. The reference - implementation uses RFC 8785 JCS with `record_hash` omitted from its own - preimage, and a separate `command_hash` over `(record_hash + requested - retention + behavior-changing options)`. **[OPEN]** adopt JCS normatively and - ship golden vectors (see Cross-repo fixtures below), including a - number/integer policy. -2. **Idempotency.** `UNIQUE(authority_id, client_id, operation, idempotency_key)`. - Same key + same command hash ⇒ replay the receipt as `duplicate`; same key + - different hash ⇒ `idempotency_conflict`; expired ⇒ `idempotency_expired`; - existing record id + different hash ⇒ `record_identity_conflict`. Never - silent re-execution. -3. **Retention.** A provider that cannot honor a `requested_retention` **MUST** - reject (`retention_rejected`) before persistence — never silently shorten or - lengthen. Accepted retention is recorded and enforced. -4. **Identity & authorization.** The authenticated principal is resolved by the - transport/auth layer; request-supplied identity labels never substitute. - Sharing-scope authorization (user / repository / workspace / organization) is - enforced before persistence and on every read. **Capability support never - implies consent** (`consent_required` is a live error path). -5. **Attestation.** Append and publication receipts carry a detached ed25519 - attestation (`signed_record_hash`, `key_id`, `algorithm`, `attester_id`, - `signature`, `issued_at`) as ledger metadata, never inside the record hash. - Key rotation via key-id validity windows. -6. **Error vocabulary.** The reference implementation names ~24 typed codes - (`unsupported_capability` … `partial_failure`). Per core X1/§13 U2, the CEP - error vocabulary is **open and namespaced** (`cgep:...` or bare within a - reserved profile namespace — **[OPEN]**), and errors carry safe diagnostics - only (no secret leakage, cf. core C8). -7. **Transport & security.** CEP is an HTTP provider, so core C4/C7/C8 bind: a - host treats it as egress, requires TLS for non-loopback, and never logs its - credentials. Auth scheme (bearer / mTLS / OAuth) is **[OPEN]** and coordinates - with issue #13. - -## Conformance - -- **Core:** green on `contextgraph-conformance` for the CEP's declared read - capabilities — a checkable claim, unchanged. -- **Profile:** a CEP-specific suite exercising append/get/resolve idempotency, - hash verification, retention rejection, authorization matrix, and attestation - verification. **[OPEN / blocked]** running the Rust conformance suite against - the HTTP endpoint is gated on the protocol repo shipping the lifecycle - capability; until then the profile suite lives with the implementation and this - repo ships only the shared hash/JCS golden vectors. - -## Cross-repo fixtures - -Golden JCS/`record_hash`/`command_hash` vectors are the interop spine between the -protocol repo, this profile, and downstream implementations. The reference -implementation authors them under its own tree; **[OPEN]** decide the canonical -home (this repo's `tests/` vs the implementation's `fixtures/`) and reconcile to -byte-identical vectors, coordinating with the fixture-regeneration work (issue -#52) so a CEP and the core suite validate against the same bytes. - -## Open decisions rolled up (for the issue-#28 design discussion) - -- **[OPEN]** Profile-version identifier and handshake discovery mechanism. -- **[OPEN]** Whether `context/resolve` is specified *in* this profile or as a - standalone `1.x` core additive minor that the profile references (SPEC.md - §6.4.1 currently reserves it for core `1.x`). -- **[OPEN]** JCS library/number policy; attestation key custody; consent policy - source; get-batch limits (mirrors of the implementation's own open list). -- **[OPEN]** How much of §"Contract surface" is normative in the *protocol* repo - vs owned by the implementation's build prompt (today the build prompt is - normative and this is a summary). +| `context/resolve` | Return the full source content of a `compact`/`reference` frame's `content_ref`, verifying `canonical_content_hash` before returning. | #50 | + +### 6.1 `context/resolve` is a **profile-scoped** operation (explicit decision) + +**Decision.** `context/resolve` is defined **by this profile**, not by the frozen +`contextgraph/1.0` core. + +`SPEC.md` §6.4.1 freezes the `content_ref` handle and the `full`/`compact`/ +`reference` frame shapes but **reserves `context/resolve` for a later additive +minor** — "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)." Issue #50 deferred that exact operation to **#28** +(reconciliation row D3). This profile is where it lands. Within the profile: + +| # | Requirement | +|---|---| +| **LO-R1** | `capabilities.resolve` tightens from the core **forward-declaration** (SPEC.md §6.4.1: "a shape check on the handshake, not an obligation a host can call") into a **callable contract**: a CEP advertising `resolve` **MUST** answer `context/resolve`. This is additive — a `1.0` host never emitted a resolve, so no deployed peer relied on its absence. | +| **LO-R2** | A resolve **MUST** verify the returned content against the `canonical_content_hash` the original `compact`/`reference` frame carried, and refuse to return content that does not match (`content_hash_mismatch`). The same digest-honesty discipline as SPEC.md §6.2/F5. | +| **LO-R3** | `content_ref.provider_id` names the exact provider that must answer; a fan-out host routes the resolve back to that provider. A handle that no longer resolves answers `reference_not_found` or `reference_expired` (§8). | +| **LO-R4** | Resolve rides the **same C-series consent gate** as `query` (SPEC.md §4): it transmits nothing new about the workspace, but may move source content off-machine if the provider is an egress provider. | + +The base-spec `SPEC.md` §6.4.1 wording is unchanged: core 1.0 still ships no +resolve operation, so it remains honest ("ships no capability a host cannot +use"). The operation is exercised **only** inside the profile's capability +envelope, keeping the freeze boundary intact. + +## 7. Provenance and attestation (resolves C5) + +| # | Requirement | +|---|---| +| **LC1** | Every record carries structured `provenance`: `origin_provider_id`, `origin_authority_id?`, `producer_kind`, `producer_ref?`, `derivation_kind?`, `source_refs?`. `producer_kind` and `derivation_kind` are open vocabularies (recommended `human`/`agent`/`tool`/`system` and `summarization`/`inference`/`transformation`/`import`). | +| **LC2** | The envelope `origin` is a coarse class — `observed` \| `derived` \| `declared` \| `imported` — governed by the **origin→derivation validity matrix**: `observed`/`declared` **MUST NOT** carry a `provenance.derivation_kind`; `derived` **MUST** carry one; `imported` **MAY**. | +| **LC3** | A `RecordAttestation` is a **detached** signature over a record's `record_hash`: `{signed_record_hash, key_id, algorithm, attester_id, signature, issued_at}`. It travels as **ledger metadata beside** the record, **never inside** the record or its hash preimage (LH3), so key rotation never perturbs identity. Key rotation is by **key-id validity windows**. The attestation type is shared with issue #12. | + +## 8. Typed error vocabulary (resolves D5) + +Per SPEC.md §10 (X1) and §13 (U3), the error `code` vocabulary is **open and +namespaced**. The codes below are **profile-reserved** (unprefixed ⇒ owned by the +protocol/profile, SPEC.md §13 U3); a **vendor-specific** code **MUST** be +namespaced (`vendor:code`). An unrecognised code **MUST** be treated as +`internal` (SPEC.md §10 X1/X2). Errors carry **safe diagnostics only** — no +secret leakage (SPEC.md §11 C8). + +| code | operation(s) | meaning | host reaction | +| --- | --- | --- | --- | +| `unsupported_capability` | any | the operation/representation/record kind was not advertised | do not retry; renegotiate | +| `unsupported_representation` | append / resolve | a representation the provider did not advertise (SPEC.md §10) | re-request `full` or skip | +| `unsupported_record_kind` | append | a `record_kind` this provider does not serve | narrow or skip | +| `invalid_record` | append | the record failed structural/schema validation | do not retry unchanged | +| `idempotency_conflict` | append | same `idempotency_key`, different command hash | do not retry; the key is spent | +| `idempotency_expired` | append | the idempotency key's window has elapsed | retry with a fresh key | +| `record_identity_conflict` | append | an existing `record_id` was re-submitted with different content | mint a new `record_id` on the same `lineage_id` | +| `retention_rejected` | append | the provider cannot honor `requested_retention` | lower the retention ask or skip | +| `consent_required` | append / get / resolve | capability is advertised but consent is not granted | obtain consent; do not retry blindly | +| `scope_denied` | get / resolve / append | the principal is not authorized for the record's `scope` | do not retry | +| `sharing_denied` | get / resolve | the record's `sharing_scope` excludes the principal | do not retry | +| `reference_not_found` | resolve | the `content_ref` names nothing resolvable | drop; treat contribution as empty | +| `reference_expired` | resolve | the handle's `expires_at` has passed | re-query for a fresh handle | +| `content_hash_mismatch` | resolve | returned content does not match `canonical_content_hash` | reject the content; report | +| `payload_too_large` | append | a record/batch exceeds the advertised payload limit | split and retry | +| `batch_too_large` | append / get | a batch exceeds the advertised batch limit | split and retry | +| `partial_failure` | append (batch) | some records in a batch were rejected; the receipt itemizes each | act per-item on the receipt | +| `unavailable` | any | transient overload / backing store down | retry with backoff | +| `internal` | any | provider fault (and the fallback for any unknown code) | report; count against health | + +## 9. Conformance and fixtures (resolves the fixture-home + conformance [OPEN]) + +| # | Requirement | +|---|---| +| **LF1** | **`tests/fixtures/` is the canonical home** for lifecycle-profile example records and golden JCS/`record_hash` vectors — one fixture per `record_kind`, plus a detached `RecordAttestation` example. Downstream implementations reconcile to these byte vectors (coordinating with the fixture-regeneration work, issue #52). | +| **LF2** | The record schema's `$id` **MUST** name this repository's GitHub-raw URL — the only host that serves these bytes, since this repository deploys no website ([ADR 0008](../adr/0008-deploy-topology-and-advertised-urls.md)), so there is no second copy to keep in sync. `schema/validate-examples.py` validates every fixture against the schema and pins the exact `$id` string, and `.github/scripts/check-deploy-hygiene.py` enforces the host rule repo-wide (the same discipline as the envelope schema). | +| **LF3** | `contextgraph-conformance`'s [`lifecycle_profile_examples`](../../contextgraph-conformance/tests/lifecycle_profile_examples.rs) suite round-trips every fixture through the reference Rust types, checks the profile envelope invariants (LR/LD/LC), and **recomputes `record_hash` as the JCS-sha256 of the hashless record** — so a fixture cannot merely assert a hash it does not satisfy. | +| **LF4** | **Core conformance** is unchanged: a CEP is green on `contextgraph-conformance` for its declared read capabilities (SPEC.md §12). The **live HTTP-endpoint** profile suite (driving append/get/resolve over a real transport) is future work that rides the operation transport bindings (#5/#50/#13); until then this repo ships the record **schema**, the **JCS golden vectors**, and the round-trip/hash conformance above — the checkable, transport-independent core of the profile. | + +## 10. Transport and security + +A CEP is (typically) an HTTP provider, so core C4/C7/C8 bind unchanged: a host +treats it as **egress**, requires **TLS** for non-loopback, and **never logs its +credentials** (SPEC.md §11). The **auth scheme** (bearer / mTLS / OAuth) is the +transport layer's concern and coordinates with issue #13; it is **not** +re-specified here. Identity is resolved by that layer (LO-ID3); request-supplied +identity labels never substitute for it. + +## 11. What this profile does not own + +Observation extraction, confidence formulas and recurrence thresholds, +governance policy, review UI, automatic activation/publication/pruning +decisions, blocking authorization, artifact-contract **execution** and semantic +**judging**, prompt compilation and token budgeting, and product packaging are +**host/product** concerns (ADR 0007 §3). The rule of thumb is unchanged: +**mechanism in the protocol, policy in the host** — the protocol carries a value +or a recorded decision; it never authorizes acting on it. --- -*Reference implementation in progress: Oxagen platform-side Context Exchange -Provider (`packages/context-exchange`, `apps/api/src/routes/cgep/`). This -skeleton summarizes its published spec; the implementation's build prompt is the -current source of truth for wire details until this profile is ratified.* +*The reference implementation (Oxagen's platform-side Context Exchange Provider) +tracks this profile; where its build prompt and this document disagree on a wire +shape, **this document and the schema win**.* diff --git a/docs/protocol-advantages.md b/docs/protocol-advantages.md index 8fe321a..62d74b8 100644 --- a/docs/protocol-advantages.md +++ b/docs/protocol-advantages.md @@ -78,7 +78,7 @@ leaks to a third-party service without consent, until a stale fact sends the agent down a wrong path, until an auditor asks "where did this answer come from?" and there is no trail. -Context Graph Protocol exists to make every one of those questions answerable, not by convention, +CGP exists to make every one of those questions answerable, not by convention, but by **contract** — a wire protocol whose invariants are enforced by the host runtime and verified by a public conformance suite. @@ -91,7 +91,7 @@ runtime and verified by a public conformance suite. | **Provenance** | Every frame carries its full origin chain (URI, range, digest, method, agent) | `ContextFrame.provenance` (`contextgraph-types::frame`) | | **Budget honesty** | A provider's frames never sum above the query's `max_tokens`; a lie is detected and the frames are dropped | `Host::query_one_isolated` budget audit (`contextgraph-host::host`); `frame-validity` conformance check | | **Consent enforcement** | An egress provider is never queried until recorded, named consent exists; the query payload is not transmitted before that | `ConsentStore::permits` (`contextgraph-host::consent`); `Host::query_provider` gate | -| **Conformance verification** | "Context Graph Protocol conformant" is a machine-checked claim, not a self-attestation; the conformance suite is adversarial | `contextgraph-conformance` — 5 checks that deliberately trip each failure mode | +| **Conformance verification** | "CGP conformant" is a machine-checked claim, not a self-attestation; the conformance suite is adversarial | `contextgraph-conformance` — 5 checks that deliberately trip each failure mode | | **Citation guarantees** | Every frame has a non-empty `title` and `citation_label`; raw ids are never the primary identifier | `frame-validity` conformance check; platform-wide convention | | **Version stability** | The protocol evolves within a major family without breaking interop; the draft-to-freeze transition requires no flag day | `versions_compatible` (`contextgraph-host::wire`); major-family matching | | **Temporal validity** | Facts carry `valid_from` / `valid_to` windows; queries can pin retrieval to a point in time via `as_of` | `ContextFrame` temporal fields; `ContextQuery.as_of` (`contextgraph-types`) | @@ -166,7 +166,7 @@ RAG pipeline, the retrieval step and the budget step are decoupled: the retriever returns "the top-K results," and the prompt assembler hopes they fit. When they don't, the assembler either truncates (losing the tail silently) or overflows (sending more tokens than budgeted, inflating cost and latency). -In Context Graph Protocol, the budget is part of the *query contract*, and the provider is +In CGP, the budget is part of the *query contract*, and the provider is responsible for selecting its best frames within that budget — with `truncated: true` and `dropped_estimate` if it had more material than fit. The host never has to guess whether the retrieval step respected the budget; it can verify it @@ -182,7 +182,7 @@ dropped and reported, and the other four providers' frames compose honestly. ## 5. Consent enforcement — data-flow consent is an audit trail -The `DataFlow` struct is the security-critical field in Context Graph Protocol: +The `DataFlow` struct is the security-critical field in CGP: ```rust pub struct DataFlow { @@ -216,7 +216,7 @@ This is enforced structurally: **Why this matters.** In a world where coding agents increasingly integrate with external services — issue trackers, documentation APIs, cloud embedding stores, knowledge graphs — the question "what left my machine?" becomes -critical for enterprise security, compliance, and trust. Context Graph Protocol's consent model +critical for enterprise security, compliance, and trust. CGP's consent model makes this answerable at the protocol level: the consent store is a serde-able audit log that a security team can inspect, and the gate is enforced before data transmission, not after. @@ -232,7 +232,7 @@ the query text itself may contain sensitive information. ## 6. Conformance verification — contracts are machine-checked -"Context Graph Protocol conformant" is not a self-attestation. It is a machine-checked claim, +"CGP conformant" is not a self-attestation. It is a machine-checked claim, defined as **green on `contextgraph-conformance`'s suite for your declared capability set**. The suite is deliberately adversarial: @@ -285,7 +285,7 @@ target id. The convention is consistent across the protocol surface. ## 8. Version stability — evolution without flag days -Context Graph Protocol separates **crate version** (ordinary Cargo semver) from **protocol +CGP separates **crate version** (ordinary Cargo semver) from **protocol version** (the wire-format identity negotiated at handshake). The current protocol version is `contextgraph/1.0-draft`. @@ -302,7 +302,7 @@ new crate major version in lockstep. **Why this matters.** A protocol that requires all participants to upgrade simultaneously is fragile — it creates coordination overhead and incentivizes -freezing the spec to avoid disruption. Context Graph Protocol's major-family model allows +freezing the spec to avoid disruption. CGP's major-family model allows incremental evolution within a family (additive fields, tighter checks) without breaking deployed providers, while reserving the major-version bump for real breaking changes. Early adopters who pin `contextgraph-types = "=0.1.0"` get a @@ -337,7 +337,7 @@ With temporal validity, the host can detect staleness (the frame's `valid_to` is set, or its digest doesn't match the current file) and either refresh or discard it. -This is the property that makes Context Graph Protocol suitable for **long-running, +This is the property that makes CGP suitable for **long-running, multi-session agents**: context accumulated in one session carries temporal metadata that a future session can evaluate for continued relevance. Episodic memory — lessons learned in a prior task — can expire or be superseded, and @@ -363,10 +363,10 @@ Consider what happens if you remove each property in isolation: Budget compositability breaks. → *Unbounded cost.* - **Remove consent enforcement.** Any provider can exfiltrate workspace content - to a remote service. The "no phone-home" guarantee — central to Context Graph Protocol's + to a remote service. The "no phone-home" guarantee — central to CGP's trust model — becomes unenforceable at the protocol level. → *Data leakage.* -- **Remove conformance verification.** "Context Graph Protocol conformant" becomes a +- **Remove conformance verification.** "CGP conformant" becomes a self-attestation. Interoperability degrades to "works with the reference host" rather than "proven against a specification." Third-party adoption requires trust rather than verification. → *Vendor lock-in via ambiguity.* @@ -388,7 +388,7 @@ The properties compose. Provenance without budget honesty means you can trace a frame's origin but not control its cost. Budget honesty without consent means costs are honest but data may leak. Consent without conformance means the gate exists but is not verified. Each property closes a gap that another property -does not address. This is why Context Graph Protocol is specified as an integrated protocol, not +does not address. This is why CGP is specified as an integrated protocol, not a menu of optional features. --- @@ -398,12 +398,12 @@ a menu of optional features. ### vs. Model Context Protocol (MCP) MCP (Anthropic, 2024) defines a protocol for connecting external tools and -resources to LLM-based applications. Context Graph Protocol and MCP are complementary, not +resources to LLM-based applications. CGP and MCP are complementary, not competing: - **MCP** connects *tools* — functions the agent can call (run a query, fetch a resource, execute a command). It is an action protocol. -- **Context Graph Protocol** connects *context* — typed, budgeted, provenance-carrying frames +- **CGP** connects *context* — typed, budgeted, provenance-carrying frames that a host composes into a prompt *before* the model acts. It is a retrieval-evidence protocol. @@ -411,40 +411,40 @@ MCP has no budget-honesty contract (a tool response has no `token_cost` field), no consent-gating for egress (tools are trusted to do what they declare), no provenance chain on responses, and no conformance suite that verifies these properties. These are not deficiencies in MCP — they are scope boundaries. MCP -is designed for tool invocation; Context Graph Protocol is designed for evidence retrieval. An -agent that needs both composes them: Context Graph Protocol providers feed context into the +is designed for tool invocation; CGP is designed for evidence retrieval. An +agent that needs both composes them: CGP providers feed context into the prompt, MCP tools execute actions. -The architectural distinction is that Context Graph Protocol frames are **transported as untrusted +The architectural distinction is that CGP frames are **transported as untrusted data** — a conforming host delimits frame content as quoted material, never as instructions. This is the same security principle that separates email body from email headers: the content of a retrieved frame is data the model reads, not a directive the model executes. MCP tool results are treated similarly by -well-designed hosts, but Context Graph Protocol makes the untrusted-data contract part of the +well-designed hosts, but CGP makes the untrusted-data contract part of the protocol specification rather than leaving it to host implementation. ### vs. ad-hoc RAG pipelines A typical RAG (Retrieval-Augmented Generation) pipeline retrieves chunks from -a vector store and pastes them into the prompt. Compared to Context Graph Protocol: +a vector store and pastes them into the prompt. Compared to CGP: - **No budget contract.** The retriever returns top-K; the prompt assembler - hopes they fit. Context Graph Protocol's `max_tokens` is part of the query and enforced. -- **No provenance.** Chunks carry a source document, at best. Context Graph Protocol frames + hopes they fit. CGP's `max_tokens` is part of the query and enforced. +- **No provenance.** Chunks carry a source document, at best. CGP frames carry URI, range, digest, method, and agent. -- **No consent model.** A cloud embedding API is called without gating. Context Graph Protocol +- **No consent model.** A cloud embedding API is called without gating. CGP gates egress behind recorded, named consent. - **No conformance.** There is no way to verify a RAG pipeline respects any - contract. Context Graph Protocol defines conformance as machine-checked. + contract. CGP defines conformance as machine-checked. - **No temporal validity.** Chunks are current-or-not, with no validity - window. Context Graph Protocol frames carry bi-temporal metadata. + window. CGP frames carry bi-temporal metadata. ### vs. vendor-locked retrieval Some coding agents (Claude Code, Cursor, Windsurf) integrate tightly with a vendor's proprietary retrieval or indexing service. The retrieval is opaque, the provider is the vendor, and the user has no visibility into cost, -provenance, or consent. Context Graph Protocol inverts this: retrieval is an open protocol, the +provenance, or consent. CGP inverts this: retrieval is an open protocol, the provider is pluggable (in-process, stdio, HTTP — any language), and the contracts are public and machine-checked. @@ -452,13 +452,13 @@ contracts are public and machine-checked. ## 12. Grounding in primary research -The design of Context Graph Protocol is grounded in research on retrieval-augmented generation, +The design of CGP is grounded in research on retrieval-augmented generation, context window economics, and software-engineering agent architecture: - **Lost in the Middle** (Liu et al., TACL 2024, [arXiv:2307.03172](https://arxiv.org/abs/2307.03172)) — demonstrates that LLM performance degrades when relevant information is buried in long - contexts. Context Graph Protocol's budget-honesty contract and frame-level relevance scoring + contexts. CGP's budget-honesty contract and frame-level relevance scoring are directly motivated by this: a host that can trust per-frame cost and score can compose a prompt that places the most relevant evidence at the attention surface, rather than stuffing an unaccountable blob. @@ -466,7 +466,7 @@ context window economics, and software-engineering agent architecture: - **Context Rot** (Hong et al., Chroma, 2025, [research.trychroma.com](https://research.trychroma.com/context-rot)) — shows that increasing input tokens degrades LLM performance even when the - additional tokens are relevant. This validates Context Graph Protocol's position that *more + additional tokens are relevant. This validates CGP's position that *more context is not better* — *honest, budgeted, provenance-carrying context* is better. The `max_tokens` contract is not just about cost; it is about preventing context rot. @@ -474,7 +474,7 @@ context window economics, and software-engineering agent architecture: - **Graph RAG** (Edge et al., Microsoft Research, 2024, [arXiv:2404.16130](https://arxiv.org/abs/2404.16130)) — demonstrates that graph-structured retrieval (entity-relationship summarization) outperforms - flat vector search for global questions about a corpus. Context Graph Protocol's `Relation` + flat vector search for global questions about a corpus. CGP's `Relation` type and `FrameKind::Graph` are designed to carry graph-structured context natively — a provider can return frames with typed relations, not just text chunks. @@ -482,21 +482,21 @@ context window economics, and software-engineering agent architecture: - **Repo map with tree-sitter** (Gauthier, Aider, 2023, [aider.chat](https://aider.chat/2023/10/22/repomap.html)) — shows that a tree-sitter-derived repository map (symbols + import edges) gives an agent - structural awareness that grep cannot. Context Graph Protocol's `FrameKind::Symbol` and + structural awareness that grep cannot. CGP's `FrameKind::Symbol` and provenance `method: "tree-sitter-symbol-extraction"` are designed for exactly this kind of structural frame. - **AI Agents That Matter** (Kapoor et al., Princeton, TMLR 2025, [arXiv:2407.01502](https://arxiv.org/abs/2407.01502)) — argues that agent benchmarks must report cost, not just accuracy, because a system that is - more accurate but 10x more expensive is not necessarily better. Context Graph Protocol's + more accurate but 10x more expensive is not necessarily better. CGP's `token_cost` field and budget-honesty contract make cost a first-class, auditable property of every context exchange. - **MemGPT** (Packer et al., UC Berkeley, 2023, [arXiv:2310.08560](https://arxiv.org/abs/2310.08560)) — proposes an operating-system-like memory hierarchy for LLMs (main context vs. external - context). Context Graph Protocol's frame types (`Memory`, `Episode`, `Fact`) and temporal + context). CGP's frame types (`Memory`, `Episode`, `Fact`) and temporal validity windows are the wire-level expression of this hierarchy: different kinds of memory with different lifecycles, all flowing through one typed protocol. @@ -505,7 +505,7 @@ context window economics, and software-engineering agent architecture: ## Summary -The Context Graph Protocol is not a faster retrieval pipeline or a richer +CGP is not a faster retrieval pipeline or a richer embedding model. It is a **trust architecture for context**: a protocol-level guarantee that every frame entering an agent's prompt is traceable to its source (provenance), honest about its cost (budget), gated by recorded consent diff --git a/docs/protocol-surface.md b/docs/protocol-surface.md index bf4e235..86f4fd7 100644 --- a/docs/protocol-surface.md +++ b/docs/protocol-surface.md @@ -7,7 +7,7 @@ > disagreement is a bug worth filing. -This is the normative shape of the Context Graph Protocol as bound to +This is the normative shape of the Context Graph Protocol (CGP) as bound to Rust types by [`contextgraph-types`](https://crates.io/crates/contextgraph-types). Every type below lives in that crate, round-trips through `serde_json`, and *is* the protocol — there is no separate IDL. Field-level doc comments in the crate diff --git a/docs/registry.md b/docs/registry.md index 33cb30d..31ff7f5 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -1,6 +1,7 @@ # Conformance registry -This page lists providers that are **Context Graph Protocol conformant** — green on +This page lists providers that are **Context Graph Protocol (CGP) +conformant** — green on `contextgraph-conformance`'s suite for their declared capability set (see [running-conformance.md](./running-conformance.md)) — with a reproducible, checkable report backing the claim. It exists so "conformant" stays a @@ -16,7 +17,7 @@ is where that count becomes checkable. | Provider | Author | Transport | Declared capabilities | Data flow | Protocol version | Last verified | Report | |---|---|---|---|---|---|---|---| -| [`contextgraph-example-docs`](../contextgraph-conformance/src/bin/contextgraph-example-docs.rs) | Context Graph Protocol maintainers (bundled reference fixture) | stdio | `kinds=[doc, snippet]`, `graph`, `verify`, `correlation`, `embeddings_fingerprint=bge-small-en-v1.5/384/l2` | reads-only, `egress=false` (`local-only`) | `contextgraph/1.0-draft` | 2026-07-29 | 13/13 checks passed — [report](../registry/contextgraph-example-docs.report.json) | +| [`contextgraph-example-docs`](../contextgraph-conformance/src/bin/contextgraph-example-docs.rs) | CGP maintainers (bundled reference fixture) | stdio | `kinds=[doc, snippet]`, `graph`, `verify`, `correlation`, `embeddings_fingerprint=bge-small-en-v1.5/384/l2` | reads-only, `egress=false` (`local-only`) | `contextgraph/1.0-draft` | 2026-07-29 | 13/13 checks passed — [report](../registry/contextgraph-example-docs.report.json) | This founding entry is the reference fixture bundled with `contextgraph-conformance` itself (`SPEC.md` §11 seed providers) — it exists to diff --git a/docs/stability.md b/docs/stability.md index 9caa4dd..bd758cf 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -1,13 +1,13 @@ # Version & stability -Context Graph Protocol has **two independent version axes**, and it's important not to conflate +Context Graph Protocol (CGP) has **two independent version axes**, and it's important not to conflate them: - **The crate version** — `0.1.0` today, `[workspace.package].version` in the workspace root `Cargo.toml`, inherited by `contextgraph-types`, `contextgraph-host`, and `contextgraph-conformance` alike. This is ordinary Rust/Cargo semver. - **The protocol version** — `contextgraph/1.0-draft`, the `PROTOCOL_VERSION` constant - in `contextgraph-types::lib`. This is the wire-format identity two Context Graph Protocol + in `contextgraph-types::lib`. This is the wire-format identity two CGP implementations negotiate at handshake time, independent of what language or crate version either side is written in. @@ -72,7 +72,7 @@ reserved for a genuinely breaking protocol redesign. that point on, the crates follow ordinary semver — a `1.x → 1.y` minor is additive-only, and a wire-breaking protocol change requires both a new protocol major (`contextgraph/2.0`) and a new crate major (`2.0.0`). -- **Conformance is the enforcement mechanism.** "Context Graph Protocol conformant" is defined +- **Conformance is the enforcement mechanism.** "CGP conformant" is defined as green on `contextgraph-conformance`'s suite for your declared capability set (see [running-conformance.md](./running-conformance.md)) — that suite, not a hand-audited checklist, is what a third party checks their implementation diff --git a/schema/contextgraph-lifecycle-record.schema.json b/schema/contextgraph-lifecycle-record.schema.json new file mode 100644 index 0000000..06c7102 --- /dev/null +++ b/schema/contextgraph-lifecycle-record.schema.json @@ -0,0 +1,630 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/contextgraph-lifecycle-record.schema.json", + "title": "Context Graph Protocol lifecycle record", + "description": "One immutable, content-addressed ContextRecord of the Context Exchange Provider profile (contextgraph/lifecycle/1.0-draft, issue #28). A common envelope plus a flat, record_kind-discriminated body \u2014 the append/get/resolve exchange unit, layered on the contextgraph/1 base family, NOT part of the frozen 1.0 core (ADR 0007 \u00a74). See docs/profiles/context-exchange-provider.md.", + "$comment": "AUTHORING-STRICT profile: unevaluatedProperties:false closes the discriminated union so a fixture typo or a field on the wrong record_kind is caught. This strictness is a lint on what you author, NOT the interop contract \u2014 per SPEC.md \u00a713 U1 a receiver on the wire MUST ignore unrecognised members. Detached RecordAttestation ($defs/RecordAttestation) is validated separately; it is never part of a record or its record_hash preimage.", + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/envelopeCommon" + } + ], + "oneOf": [ + { + "properties": { + "record_kind": { + "const": "observation" + }, + "statement": { + "type": "string", + "minLength": 1 + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "record_kind", + "statement" + ] + }, + { + "properties": { + "record_kind": { + "const": "knowledge" + }, + "knowledge_kind": { + "enum": [ + "fact", + "assumption", + "decision" + ] + }, + "statement": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "record_kind", + "knowledge_kind", + "statement" + ] + }, + { + "properties": { + "record_kind": { + "const": "memory" + }, + "statement": { + "type": "string", + "minLength": 1 + }, + "salience": { + "type": "number" + } + }, + "required": [ + "record_kind", + "statement" + ] + }, + { + "properties": { + "record_kind": { + "const": "directive" + }, + "directive_kind": { + "enum": [ + "preference", + "rule", + "constraint", + "procedure" + ] + }, + "statement": { + "type": "string", + "minLength": 1 + }, + "constraint_effect": { + "enum": [ + "require", + "forbid" + ], + "description": "What a constraint does. Only require/forbid \u2014 never allow: authorization stays host-side (ADR 0007 \u00a73)." + }, + "enforcement": { + "enum": [ + "advisory", + "blocking" + ], + "description": "Recorded intent, not an enforcement grant. Absent \u21d2 advisory." + }, + "procedure_steps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ordered steps for a procedure directive." + } + }, + "required": [ + "record_kind", + "directive_kind", + "statement" + ], + "allOf": [ + { + "$comment": "A constraint directive MUST state its effect (reconciliation row B3).", + "if": { + "properties": { + "directive_kind": { + "const": "constraint" + } + } + }, + "then": { + "required": [ + "constraint_effect" + ] + } + } + ] + }, + { + "properties": { + "record_kind": { + "const": "record_proposal" + }, + "proposed_kind": { + "type": "string", + "minLength": 1 + }, + "rationale": { + "type": "string" + } + }, + "required": [ + "record_kind", + "proposed_kind", + "rationale" + ] + }, + { + "properties": { + "record_kind": { + "const": "evidence" + }, + "statement": { + "type": "string", + "minLength": 1 + }, + "evidence_kind": { + "type": "string" + } + }, + "required": [ + "record_kind", + "statement" + ] + }, + { + "properties": { + "record_kind": { + "const": "artifact_contract" + }, + "contract_name": { + "type": "string", + "minLength": 1 + }, + "requirements": { + "type": "array", + "items": { + "$ref": "#/$defs/ContractRequirement" + } + } + }, + "required": [ + "record_kind", + "contract_name" + ] + }, + { + "properties": { + "record_kind": { + "const": "contract_validation" + }, + "contract_ref": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "$ref": "#/$defs/validationOutcome" + }, + "requirement_results": { + "type": "array", + "items": { + "$ref": "#/$defs/RequirementResult" + } + } + }, + "required": [ + "record_kind", + "contract_ref", + "outcome" + ] + }, + { + "properties": { + "record_kind": { + "const": "outcome_assessment" + }, + "subject_ref": { + "type": "string", + "minLength": 1 + }, + "assessment": { + "type": "string" + }, + "rating": { + "type": "number" + } + }, + "required": [ + "record_kind", + "subject_ref", + "assessment" + ] + }, + { + "properties": { + "record_kind": { + "const": "promotion_event" + }, + "subject_ref": { + "type": "string", + "minLength": 1 + }, + "to_status": { + "type": "string", + "minLength": 1 + }, + "from_status": { + "type": "string" + } + }, + "required": [ + "record_kind", + "subject_ref", + "to_status" + ] + }, + { + "properties": { + "record_kind": { + "const": "context_use" + }, + "used_record_ref": { + "type": "string", + "minLength": 1 + }, + "selected": { + "type": "boolean" + }, + "rendered": { + "type": "boolean" + }, + "cited": { + "type": "boolean" + }, + "task_ref": { + "type": "string" + } + }, + "required": [ + "record_kind", + "used_record_ref", + "selected", + "rendered", + "cited" + ] + }, + { + "properties": { + "record_kind": { + "const": "context_use_feedback" + }, + "context_use_ref": { + "type": "string", + "minLength": 1 + }, + "feedback": { + "type": "string" + }, + "rating": { + "type": "number" + } + }, + "required": [ + "record_kind", + "context_use_ref", + "feedback" + ] + } + ], + "unevaluatedProperties": false, + "$defs": { + "recordKind": { + "type": "string", + "enum": [ + "observation", + "knowledge", + "memory", + "directive", + "record_proposal", + "evidence", + "artifact_contract", + "contract_validation", + "outcome_assessment", + "promotion_event", + "context_use", + "context_use_feedback" + ], + "description": "The 12 portable record kinds (reconciliation row D1). Closed within this profile version; a new kind is a lifecycle/1.x addition." + }, + "recordStatus": { + "type": "string", + "enum": [ + "active", + "retracted", + "archived" + ], + "description": "Three-value lifecycle status (reconciliation row B5). 'superseded' is NOT here \u2014 it is derived from a later record on the same lineage_id, never stored." + }, + "sharingScope": { + "type": "string", + "enum": [ + "user", + "repository", + "workspace", + "organization" + ], + "description": "Who the record is shared with (reconciliation row E3). Conjunctive with scope." + }, + "originClass": { + "type": "string", + "enum": [ + "observed", + "derived", + "declared", + "imported" + ], + "description": "Coarse origin class keyed by the origin\u2192derivation validity matrix (reconciliation row C5): observed/declared carry no provenance.derivation_kind; derived requires one." + }, + "validationOutcome": { + "type": "string", + "enum": [ + "pass", + "fail", + "inconclusive" + ] + }, + "timestamp": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$", + "description": "Protocol timestamp: a strict UTC subset of RFC 3339, uppercase T and Z (SPEC.md \u00a76.1/F4)." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "sha256:<64 lowercase hex> (SPEC.md \u00a76.2/F5)." + }, + "RecordScope": { + "type": "object", + "description": "The 7-key portable scope (reconciliation row E3). Every key optional; present keys are conjunctive (AND). tenant_id and project_id are deliberately absent from the portable core (rows E2/E3).", + "properties": { + "user_id": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "repository_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "environment_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "RecordProvenance": { + "type": "object", + "description": "Structured record provenance (reconciliation row C5). Distinct from the frame-layer file/range digest chain.", + "properties": { + "origin_provider_id": { + "type": "string", + "minLength": 1 + }, + "origin_authority_id": { + "type": "string" + }, + "producer_kind": { + "type": "string", + "minLength": 1, + "description": "Open vocabulary; recommended human|agent|tool|system." + }, + "producer_ref": { + "type": "string" + }, + "derivation_kind": { + "type": "string", + "description": "Open vocabulary; recommended summarization|inference|transformation|import. Required for origin=derived; absent for observed/declared." + }, + "source_refs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "origin_provider_id", + "producer_kind" + ], + "additionalProperties": false + }, + "RecordLink": { + "type": "object", + "description": "A typed link to another record. rel is an open, namespaced (SPEC.md \u00a713 U3) vocabulary.", + "properties": { + "rel": { + "type": "string", + "minLength": 1 + }, + "target_record_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "rel", + "target_record_id" + ], + "additionalProperties": false + }, + "ContractRequirement": { + "type": "object", + "description": "One requirement of an artifact contract (reconciliation row D6). requirement_kind is open; a 'command' requirement carries execution_approval_ref \u2014 a pointer to an approval, NEVER an authorization to execute (ADR 0007 \u00a73).", + "properties": { + "requirement_kind": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "execution_approval_ref": { + "type": "string" + } + }, + "required": [ + "requirement_kind" + ], + "additionalProperties": false + }, + "RequirementResult": { + "type": "object", + "properties": { + "requirement_kind": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "$ref": "#/$defs/validationOutcome" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "requirement_kind", + "outcome" + ], + "additionalProperties": false + }, + "RecordAttestation": { + "type": "object", + "description": "A DETACHED attestation over a record's record_hash (reconciliation row C5, shared with issue #12). Never part of a record or its record_hash preimage \u2014 it travels as ledger metadata, so re-signing / key rotation never perturbs the content-addressed identity.", + "properties": { + "signed_record_hash": { + "$ref": "#/$defs/digest" + }, + "key_id": { + "type": "string", + "minLength": 1 + }, + "algorithm": { + "type": "string", + "minLength": 1 + }, + "attester_id": { + "type": "string", + "minLength": 1 + }, + "signature": { + "type": "string", + "minLength": 1 + }, + "issued_at": { + "$ref": "#/$defs/timestamp" + } + }, + "required": [ + "signed_record_hash", + "key_id", + "algorithm", + "attester_id", + "signature", + "issued_at" + ], + "additionalProperties": false + }, + "envelopeCommon": { + "type": "object", + "description": "The common record envelope shared by every record_kind.", + "properties": { + "schema_version": { + "const": "contextgraph/lifecycle/1.0-draft", + "description": "The profile version (ADR 0007 \u00a75, reconciliation row D4)." + }, + "record_id": { + "type": "string", + "minLength": 1 + }, + "lineage_id": { + "type": "string", + "minLength": 1, + "description": "Groups revisions of the same logical item; supersession is derived from this." + }, + "record_kind": { + "$ref": "#/$defs/recordKind" + }, + "record_status": { + "$ref": "#/$defs/recordStatus" + }, + "scope": { + "$ref": "#/$defs/RecordScope" + }, + "sharing_scope": { + "$ref": "#/$defs/sharingScope" + }, + "sensitivity": { + "type": "string", + "description": "Open vocabulary; recommended public|internal|confidential|restricted." + }, + "observed_at": { + "$ref": "#/$defs/timestamp" + }, + "valid_from": { + "$ref": "#/$defs/timestamp" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "origin": { + "$ref": "#/$defs/originClass" + }, + "evidence_links": { + "type": "array", + "items": { + "type": "string" + } + }, + "record_links": { + "type": "array", + "items": { + "$ref": "#/$defs/RecordLink" + } + }, + "record_hash": { + "$ref": "#/$defs/digest", + "description": "sha256: over the RFC 8785 (JCS) canonicalization of this record with record_hash omitted from the preimage (reconciliation row C5)." + }, + "provenance": { + "$ref": "#/$defs/RecordProvenance" + }, + "extensions": { + "type": "object", + "description": "Namespaced extension members (SPEC.md \u00a713 U3). Open object.", + "additionalProperties": true + } + }, + "required": [ + "schema_version", + "record_id", + "lineage_id", + "record_kind", + "record_status", + "scope", + "sharing_scope", + "observed_at", + "origin", + "record_hash", + "provenance" + ] + } + } +} diff --git a/schema/validate-examples.py b/schema/validate-examples.py index 8235917..c0713a2 100755 --- a/schema/validate-examples.py +++ b/schema/validate-examples.py @@ -240,5 +240,60 @@ def _skip_ws(text: str, index: int) -> int: check(f"$id is {expected_id}", SCHEMA.get("$id") == expected_id) +# 6. The Context Exchange Provider lifecycle-record profile (issue #28). +# +# A second schema, a second wire surface: the discriminated `ContextRecord` +# union of `schema/contextgraph-lifecycle-record.schema.json`. It is held to +# the same discipline as the envelope schema — every hand-authored example +# record under `tests/fixtures/` (the canonical fixture home) validates, and +# the `$id` names this repo's GitHub-raw URL — the only host that serves +# these bytes, per ADR 0008. The record fixtures' `record_hash` +# values are not checked here (that is the JCS-sha256 job of +# `contextgraph-conformance`'s `lifecycle_profile_examples` suite); this +# checks STRUCTURE against the schema, the class of error the envelope schema +# also guards. +print("\nValidating lifecycle records against " + "schema/contextgraph-lifecycle-record.schema.json\n") + +RECORD_SCHEMA_SOURCE = ROOT / "schema" / "contextgraph-lifecycle-record.schema.json" +RECORD_SCHEMA = json.loads(RECORD_SCHEMA_SOURCE.read_text()) +ATTESTATION_FIXTURE = "record-attestation.json" + +fixtures_dir = ROOT / "tests" / "fixtures" +record_fixtures = sorted( + p for p in fixtures_dir.glob("*.json") if p.name != ATTESTATION_FIXTURE +) +if not record_fixtures: + check("tests/fixtures holds lifecycle record examples", False) + print(" no record fixtures found — did the fixture home move?") + +for path in record_fixtures: + try: + jsonschema.validate(json.loads(path.read_text()), RECORD_SCHEMA) + except (json.JSONDecodeError, jsonschema.ValidationError) as e: + check(f"tests/fixtures/{path.name}", False) + print(f" {getattr(e, 'message', e)}") + continue + kind = json.loads(path.read_text()).get("record_kind") + check(f"tests/fixtures/{path.name} ({kind})", True) + +# The detached attestation validates against its own $def, never the root record +# schema — it is ledger metadata beside a record, not a record kind. +attestation_path = fixtures_dir / ATTESTATION_FIXTURE +attestation_schema = { + "$schema": RECORD_SCHEMA["$schema"], + "$ref": "#/$defs/RecordAttestation", + "$defs": RECORD_SCHEMA["$defs"], +} +try: + jsonschema.validate(json.loads(attestation_path.read_text()), attestation_schema) + check(f"tests/fixtures/{ATTESTATION_FIXTURE} (RecordAttestation)", True) +except (json.JSONDecodeError, jsonschema.ValidationError) as e: + check(f"tests/fixtures/{ATTESTATION_FIXTURE} (RecordAttestation)", False) + print(f" {getattr(e, 'message', e)}") + +record_expected_id = f"https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/{RECORD_SCHEMA_SOURCE.name}" +check(f"$id is {record_expected_id}", RECORD_SCHEMA.get("$id") == record_expected_id) + print(f"\n{'OK — all examples validate' if failures == 0 else f'{failures} failure(s)'}") sys.exit(1 if failures else 0) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..bad371a --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,77 @@ +# Lifecycle-profile fixtures (canonical home) + +This directory is the **canonical home** for the Context Exchange Provider +profile's example records and golden `record_hash` vectors (issue +[#28](https://github.com/macanderson/context-graph-protocol/issues/28), +requirement `LF1`). It resolves the earlier draft's open question of *which repo +owns the vectors*: they live here, in the protocol repo, so a third-party CEP and +the reference conformance suite validate against the **same bytes**. + +## Layout + +- One fixture per portable `record_kind` (12 of them): + `observation.json`, `knowledge.json`, `memory.json`, `directive.json`, + `record_proposal.json`, `evidence.json`, `artifact_contract.json`, + `contract_validation.json`, `outcome_assessment.json`, `promotion_event.json`, + `context_use.json`, `context_use_feedback.json`. The filename stem **is** the + `record_kind`. +- `record-attestation.json` — a **detached** `RecordAttestation` (it is not a + record kind; it is ledger metadata beside a record, so it is validated against + `#/$defs/RecordAttestation`, never the root record schema). Its + `signed_record_hash` signs `observation.json`'s `record_hash`. + +## What validates these + +- **Structure:** `python3 schema/validate-examples.py` validates every record + fixture against + [`schema/contextgraph-lifecycle-record.schema.json`](../../schema/contextgraph-lifecycle-record.schema.json) + and the attestation against `#/$defs/RecordAttestation`. +- **Round-trip + envelope invariants + hash:** + [`contextgraph-conformance/tests/lifecycle_profile_examples.rs`](../../contextgraph-conformance/tests/lifecycle_profile_examples.rs) + deserializes each fixture through `contextgraph_types::ContextRecord`, checks + the profile invariants, and **recomputes** `record_hash`. + +## Regenerating the hashes + +`record_hash` is content-addressed (profile `LH1`). If you edit a fixture's +content, refresh its hash: + +```sh +REGENERATE_LIFECYCLE_HASHES=1 cargo test -p contextgraph-conformance \ + --test lifecycle_profile_examples +``` + +This rewrites each fixture's `record_hash` (and the attestation's +`signed_record_hash`) in place, preserving field order, then re-run without the +env var to verify. + +## Worked example — how `record_hash` is computed (RFC 8785 JCS) + +`record_hash = "sha256:" + hex(sha256(JCS(record without its record_hash member)))`. + +Take `observation.json`. **Step 1** — remove its own `record_hash` member from +the preimage. **Step 2** — canonicalize the remaining object with **RFC 8785 +(JCS)**: sort object members by code point, minimal separators (`,` and `:`), no +insignificant whitespace, and the RFC 8785 number form (ECMAScript shortest +round-trip — `0.82`, not `0.820`). For `observation.json` that yields exactly +these 637 bytes (one line, shown wrapped here): + +``` +{"confidence":0.82,"lineage_id":"lin_obs_0001","observed_at":"2026-07-29T14:00:00Z","origin":"observed","provenance":{"origin_authority_id":"authority_acme","origin_provider_id":"provider_example","producer_kind":"agent","producer_ref":"agent://trace-miner"},"record_id":"rec_obs_0001","record_kind":"observation","record_status":"active","schema_version":"contextgraph/lifecycle/1.0-draft","scope":{"repository_id":"repo_stella","session_id":"sess_412","workspace_id":"ws_main"},"sensitivity":"internal","sharing_scope":"repository","statement":"the api handler retries three times before surfacing a 502","subject_ref":"trace_run_991"} +``` + +**Step 3** — SHA-256 the UTF-8 of that string and prefix `sha256:`: + +``` +sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc +``` + +which is exactly the `record_hash` stored in `observation.json`. The reference +Rust `serde_json_canonicalizer` and a +`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)` Python +canonicalizer both reproduce these bytes and this hash — that byte-agreement is +the interop guarantee the vectors exist to pin (profile `LH2`). + +> The **detached attestation is never part of the preimage** (profile `LH3`): +> `record_hash` is computed over the record alone, so signing or rotating a key +> never changes a record's identity. diff --git a/tests/fixtures/artifact_contract.json b/tests/fixtures/artifact_contract.json new file mode 100644 index 0000000..d443931 --- /dev/null +++ b/tests/fixtures/artifact_contract.json @@ -0,0 +1,32 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_contract_0001", + "lineage_id": "lin_contract_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "environment_id": "env_ci" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "declared", + "record_hash": "sha256:4d6690fdd5347dfa14b8b5f3875b2487d09e18d70d4aa9401a4a1f5f3b527d40", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "human", + "producer_ref": "user_mac" + }, + "record_kind": "artifact_contract", + "contract_name": "api-handler-acceptance", + "requirements": [ + { + "requirement_kind": "file_exists", + "description": "src/api/handler.rs is present" + }, + { + "requirement_kind": "command", + "description": "cargo test -p api passes", + "execution_approval_ref": "approval_ci_2026_07" + } + ] +} diff --git a/tests/fixtures/context_use.json b/tests/fixtures/context_use.json new file mode 100644 index 0000000..493ba03 --- /dev/null +++ b/tests/fixtures/context_use.json @@ -0,0 +1,26 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_use_0001", + "lineage_id": "lin_use_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "task_id": "task_deploy_fix", + "session_id": "sess_412" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:183ea4a10a56ca024166f81403cd5a84a0ba9a356d7c81f21108bc4e735c5784", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "system", + "producer_ref": "host://composer" + }, + "record_kind": "context_use", + "used_record_ref": "rec_know_0001", + "selected": true, + "rendered": true, + "cited": false, + "task_ref": "task_deploy_fix" +} diff --git a/tests/fixtures/context_use_feedback.json b/tests/fixtures/context_use_feedback.json new file mode 100644 index 0000000..31a6c50 --- /dev/null +++ b/tests/fixtures/context_use_feedback.json @@ -0,0 +1,27 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_fb_0001", + "lineage_id": "lin_fb_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "task_id": "task_deploy_fix" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "declared", + "record_hash": "sha256:96681a4b5b2712ed37c4c72362b008ca6b16535602a72d20fb9045ffde94e915", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "human", + "producer_ref": "user_mac" + }, + "confidence": 0.4, + "extensions": { + "acme:review_channel": "slack:#context-review" + }, + "record_kind": "context_use_feedback", + "context_use_ref": "rec_use_0001", + "feedback": "the fact was selected and rendered but never cited in the final answer", + "rating": 0.2 +} diff --git a/tests/fixtures/contract_validation.json b/tests/fixtures/contract_validation.json new file mode 100644 index 0000000..74ded89 --- /dev/null +++ b/tests/fixtures/contract_validation.json @@ -0,0 +1,42 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_cval_0001", + "lineage_id": "lin_cval_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "environment_id": "env_ci" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:862f66a847ba716c410b11792a98ec483929776cab3c98d071b4a89f8be45312", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "system", + "producer_ref": "ci://runner-7" + }, + "evidence_links": [ + "rec_evid_0001" + ], + "record_links": [ + { + "rel": "validates", + "target_record_id": "rec_contract_0001" + } + ], + "record_kind": "contract_validation", + "contract_ref": "rec_contract_0001", + "outcome": "pass", + "requirement_results": [ + { + "requirement_kind": "file_exists", + "outcome": "pass" + }, + { + "requirement_kind": "command", + "outcome": "pass", + "detail": "42 tests, 0 failures" + } + ] +} diff --git a/tests/fixtures/directive.json b/tests/fixtures/directive.json new file mode 100644 index 0000000..c993094 --- /dev/null +++ b/tests/fixtures/directive.json @@ -0,0 +1,24 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_dir_0001", + "lineage_id": "lin_dir_0001", + "record_status": "active", + "scope": { + "organization_id": "org_acme" + }, + "sharing_scope": "organization", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "declared", + "record_hash": "sha256:0267fa312456074983b526da12682a4b5685f65750081296b42d89399120f4b3", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "human", + "producer_ref": "user_security_lead" + }, + "sensitivity": "confidential", + "record_kind": "directive", + "directive_kind": "constraint", + "statement": "never write credentials or tokens to logs or traces", + "constraint_effect": "forbid", + "enforcement": "blocking" +} diff --git a/tests/fixtures/evidence.json b/tests/fixtures/evidence.json new file mode 100644 index 0000000..2dab3a2 --- /dev/null +++ b/tests/fixtures/evidence.json @@ -0,0 +1,22 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_evid_0001", + "lineage_id": "lin_evid_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "session_id": "sess_412" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:57fcddcd66f85b42a6154d8a3e3e43bdf2e0b12605f3fdb81f7b8b9acd295bdf", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "tool", + "producer_ref": "tool://log-reader" + }, + "record_kind": "evidence", + "statement": "log line 2026-07-29T12:04:11Z shows upstream timeout after 3 retries", + "evidence_kind": "log" +} diff --git a/tests/fixtures/knowledge.json b/tests/fixtures/knowledge.json new file mode 100644 index 0000000..48d682e --- /dev/null +++ b/tests/fixtures/knowledge.json @@ -0,0 +1,23 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_know_0001", + "lineage_id": "lin_know_0001", + "record_status": "active", + "scope": { + "organization_id": "org_acme", + "repository_id": "repo_stella" + }, + "sharing_scope": "organization", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "declared", + "record_hash": "sha256:2d3a4530de7392338a66f321e798e65398c1b51955f622ceceaac24c709212c2", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "human", + "producer_ref": "user_mac" + }, + "confidence": 0.95, + "record_kind": "knowledge", + "knowledge_kind": "fact", + "statement": "the retry ceiling for the deploy pipeline is five attempts" +} diff --git a/tests/fixtures/memory.json b/tests/fixtures/memory.json new file mode 100644 index 0000000..7e9b3b2 --- /dev/null +++ b/tests/fixtures/memory.json @@ -0,0 +1,23 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_mem_0001", + "lineage_id": "lin_mem_0001", + "record_status": "active", + "scope": { + "user_id": "user_mac", + "workspace_id": "ws_main" + }, + "sharing_scope": "user", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:5870e397da8c76a8273b76b136a67b6b8ddff5c9aaaa13dc5b85800a98bbd161", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "agent", + "producer_ref": "agent://coder" + }, + "confidence": 0.7, + "record_kind": "memory", + "statement": "the user prefers terse, review-ready diffs over verbose explanations", + "salience": 0.7 +} diff --git a/tests/fixtures/observation.json b/tests/fixtures/observation.json new file mode 100644 index 0000000..5f9d7c4 --- /dev/null +++ b/tests/fixtures/observation.json @@ -0,0 +1,26 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_obs_0001", + "lineage_id": "lin_obs_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "workspace_id": "ws_main", + "session_id": "sess_412" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "observed", + "record_hash": "sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "agent", + "origin_authority_id": "authority_acme", + "producer_ref": "agent://trace-miner" + }, + "sensitivity": "internal", + "confidence": 0.82, + "record_kind": "observation", + "statement": "the api handler retries three times before surfacing a 502", + "subject_ref": "trace_run_991" +} diff --git a/tests/fixtures/outcome_assessment.json b/tests/fixtures/outcome_assessment.json new file mode 100644 index 0000000..ccf1d80 --- /dev/null +++ b/tests/fixtures/outcome_assessment.json @@ -0,0 +1,28 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_out_0001", + "lineage_id": "lin_out_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "task_id": "task_deploy_fix" + }, + "sharing_scope": "workspace", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "derived", + "record_hash": "sha256:4f66d632c3b820e6d4f467650eae08ebe9ec56b7eec67d128c82b2fda51eb02e", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "agent", + "producer_ref": "agent://judge", + "derivation_kind": "inference", + "source_refs": [ + "rec_cval_0001" + ] + }, + "confidence": 0.75, + "record_kind": "outcome_assessment", + "subject_ref": "task_deploy_fix", + "assessment": "the retry ceiling change resolved the intermittent 502s", + "rating": 0.8 +} diff --git a/tests/fixtures/promotion_event.json b/tests/fixtures/promotion_event.json new file mode 100644 index 0000000..23cc080 --- /dev/null +++ b/tests/fixtures/promotion_event.json @@ -0,0 +1,29 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_promo_0001", + "lineage_id": "lin_promo_0001", + "record_status": "active", + "scope": { + "organization_id": "org_acme", + "repository_id": "repo_stella" + }, + "sharing_scope": "organization", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "declared", + "record_hash": "sha256:81e644153f47d2c7d4b268118bef27f119e47265e2fd0a1cdba849450d84b90c", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "human", + "producer_ref": "user_security_lead" + }, + "record_links": [ + { + "rel": "promotes", + "target_record_id": "rec_dir_0001" + } + ], + "record_kind": "promotion_event", + "subject_ref": "rec_dir_0001", + "to_status": "active", + "from_status": "proposed" +} diff --git a/tests/fixtures/record-attestation.json b/tests/fixtures/record-attestation.json new file mode 100644 index 0000000..2fd3932 --- /dev/null +++ b/tests/fixtures/record-attestation.json @@ -0,0 +1,8 @@ +{ + "signed_record_hash": "sha256:b45eebfdfe7e6e5056bf25d84864cf9acd731eef120a1f6de129fb788c3b34dc", + "key_id": "cep-signing-key-2026-07", + "algorithm": "ed25519", + "attester_id": "provider_example", + "signature": "3045022100c0ffee02207a1753754b6e334dc7e782562dfe52fb54eb67a66d01ecffe499", + "issued_at": "2026-07-29T14:00:05Z" +} diff --git a/tests/fixtures/record_proposal.json b/tests/fixtures/record_proposal.json new file mode 100644 index 0000000..6ddd8c2 --- /dev/null +++ b/tests/fixtures/record_proposal.json @@ -0,0 +1,36 @@ +{ + "schema_version": "contextgraph/lifecycle/1.0-draft", + "record_id": "rec_prop_0001", + "lineage_id": "lin_prop_0001", + "record_status": "active", + "scope": { + "repository_id": "repo_stella", + "workspace_id": "ws_main" + }, + "sharing_scope": "repository", + "observed_at": "2026-07-29T14:00:00Z", + "origin": "derived", + "record_hash": "sha256:d14da312af7734f26b51f7e0ea44310c6bca92a0997b9106198ffe597e4351d2", + "provenance": { + "origin_provider_id": "provider_example", + "producer_kind": "agent", + "producer_ref": "agent://promoter", + "derivation_kind": "inference", + "source_refs": [ + "rec_obs_0001" + ] + }, + "confidence": 0.6, + "evidence_links": [ + "rec_obs_0001" + ], + "record_links": [ + { + "rel": "refines", + "target_record_id": "rec_dir_0001" + } + ], + "record_kind": "record_proposal", + "proposed_kind": "directive", + "rationale": "the same retry-then-502 observation recurred across three sessions" +}