From 2f62cfd8cefb993957a142f4ae98f18c1600de37 Mon Sep 17 00:00:00 2001 From: macanderson Date: Wed, 29 Jul 2026 15:39:01 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20backlog=20sweep=20wave=201=20?= =?UTF-8?q?=E2=80=94=20registry,=20release/SDK=20prep,=20canary,=20schema?= =?UTF-8?q?=20$id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five file-disjoint backlog issues, all additive (no wire/Rust-logic change): - #20 Conformance registry page + reproducible-report seed + badge + PR submission checklist. Seed report is a verified 12/12 capture of `contextgraph-inspect stdio --json` against the bundled example provider. - #16 Tag-triggered, environment-gated crates.io release.yml + a credential-free `publish-dry-run` CI job + crates.io/docs.rs badges. Version cut and the crates-io environment/secret remain the owner's decision. - #59 sdk/PUBLISHING.md + tag-gated publish-sdks.yml; PyPI/Go publishes and the Go tag remain human-only. npm already live via #46. - #29 downstream-canary.yml builds stella's contextgraph-* consumers against HEAD (advisory); oxagen-canary activates once OXAGEN_PLATFORM_TOKEN is wired. - #58 schema $id repointed to the GitHub-raw URL that resolves today (interim until #57's Vercel relink); schema validate-examples.py green, mirror byte-identical. Closes #20, #29, #58 Refs #16, #59 (publish/tag/secret steps are human-only) Claude-Session: https://claude.ai/code/session_01Co9faUWdYC1SPqrof7njyD --- .github/PULL_REQUEST_TEMPLATE.md | 12 + .github/scripts/downstream-canary-stella.sh | 121 +++++++++ .github/scripts/wait-for-crate.sh | 57 +++++ .github/workflows/ci.yml | 19 ++ .github/workflows/downstream-canary.yml | 126 ++++++++++ .github/workflows/publish-sdks.yml | 181 ++++++++++++++ .github/workflows/release.yml | 90 +++++++ CHANGELOG.md | 40 +++ PUBLISHING.md | 41 ++- README.md | 8 + contextgraph-conformance/README.md | 3 + contextgraph-host/README.md | 3 + contextgraph-types/README.md | 3 + docs/adaptive-context-reconciliation.md | 8 +- docs/adr/0007-protocol-product-boundary.md | 3 +- docs/implementing-a-provider.md | 11 + docs/index.md | 3 + docs/registry.md | 76 ++++++ schema/contextgraph-envelope.schema.json | 2 +- schema/validate-examples.py | 28 ++- sdk/PUBLISHING.md | 236 ++++++++++++++++++ sdk/README.md | 6 + sdk/go/README.md | 6 + sdk/python/README.md | 5 + site/content/docs/implementing-a-provider.mdx | 11 + site/content/docs/index.mdx | 3 + site/content/docs/meta.json | 1 + site/content/docs/registry.mdx | 79 ++++++ site/public/badges/conformant.svg | 23 ++ .../contextgraph-example-docs.report.json | 65 +++++ .../schema/contextgraph-envelope.schema.json | 2 +- 31 files changed, 1257 insertions(+), 15 deletions(-) create mode 100755 .github/scripts/downstream-canary-stella.sh create mode 100755 .github/scripts/wait-for-crate.sh create mode 100644 .github/workflows/downstream-canary.yml create mode 100644 .github/workflows/publish-sdks.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/registry.md create mode 100644 sdk/PUBLISHING.md create mode 100644 site/content/docs/registry.mdx create mode 100644 site/public/badges/conformant.svg create mode 100644 site/public/registry/contextgraph-example-docs.report.json diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b642d6a..3863cd9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,6 +20,18 @@ - [ ] All commits signed off (`git commit -s`, DCO) - [ ] `CHANGELOG.md` updated under `[Unreleased]` if user-visible +## Registry submission (only if adding a row to `docs/registry.md`) + +- [ ] Not applicable — this PR does not add/change a conformance registry entry +- [ ] The exact, reproducible `contextgraph-inspect ... --json` invocation used + to produce the listed report is included below (no self-attested + listings — a maintainer must be able to re-run it and get the same + result) +- [ ] Every check in the linked report is `pass` or `skip`, none `fail` + + + ## Protocol-stability impact (if a spec/wire change) - [ ] Not applicable — no wire or spec change diff --git a/.github/scripts/downstream-canary-stella.sh b/.github/scripts/downstream-canary-stella.sh new file mode 100755 index 0000000..86eb275 --- /dev/null +++ b/.github/scripts/downstream-canary-stella.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Downstream canary (issue #29): build stella against THIS repo's HEAD. +# +# stella consumes contextgraph-types::ContextFrame (and, transitively, +# contextgraph-host / contextgraph-trace / contextgraph-conformance) as a +# pinned git dependency. That pin only moves when a human bumps it, so a +# breaking change here can sit unnoticed until someone does. This script +# closes that gap: it patches a stella checkout to build against a *local* +# CGP checkout (this repo, at whatever ref is checked out — HEAD in CI) via +# Cargo's `[patch]` table, then builds and tests every stella crate that +# actually depends on a contextgraph-* crate. +# +# This is the code-side half of the #27 boundary enforcement (see +# docs/adaptive-context-reconciliation.md and docs/adr/0007-protocol-product- +# boundary.md); the docs-side half is stella's own `normative-home` workflow +# (stella PR #500), which checks the *pointer* rather than the *build*. +# +# Usage (matches the .github/scripts/conformance-*.sh convention — env vars, +# no flags, safe to run twice): +# CGP_DIR=/path/to/context-graph-protocol \ +# STELLA_DIR=/path/to/stella \ +# .github/scripts/downstream-canary-stella.sh +# +# Deliberately advisory (see the calling workflow's continue-on-error): a +# real break here is exactly the kind of pre-freeze signal issue #29 wants, +# but a canary that could fail *this* repo's own required checks would just +# get muted, which defeats the point. +# +# Grep, not rg; find, not fd — this script has to run unmodified on GitHub's +# stock ubuntu-latest runner and on a contributor's machine with no extra +# tools installed, so it only uses what a bare POSIX + coreutils + cargo +# environment already guarantees. +set -euo pipefail + +CGP_DIR="${CGP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +STELLA_DIR="${STELLA_DIR:-}" + +if [[ -z "$STELLA_DIR" ]]; then + echo "::error::STELLA_DIR is not set — point it at a checkout of macanderson/stella" + exit 1 +fi + +CGP_DIR="$(cd "$CGP_DIR" && pwd)" +STELLA_DIR="$(cd "$STELLA_DIR" && pwd)" +STELLA_MANIFEST="$STELLA_DIR/Cargo.toml" +CGP_GIT_SOURCE="https://github.com/macanderson/context-graph-protocol" +SENTINEL="# --- downstream-canary-stella.sh: local CGP patch (do not commit) ---" + +if [[ ! -f "$STELLA_MANIFEST" ]]; then + echo "::error::$STELLA_MANIFEST not found — is STELLA_DIR a stella checkout?" + exit 1 +fi + +# Discover the contextgraph-* crates this checkout actually ships, from their +# own `[package] name`, rather than hardcoding the list — so a rename or a +# split crate is picked up automatically instead of silently going unpatched. +crates=() +for manifest in "$CGP_DIR"/contextgraph-*/Cargo.toml; do + [[ -f "$manifest" ]] || continue + name=$(grep -m1 '^name = ' "$manifest" | cut -d'"' -f2) + [[ -n "$name" ]] && crates+=("$name") +done + +if [[ "${#crates[@]}" -eq 0 ]]; then + echo "::error::no contextgraph-*/Cargo.toml found under $CGP_DIR" + exit 1 +fi + +echo "CGP crates available to patch in: ${crates[*]}" + +if grep -qF "$SENTINEL" "$STELLA_MANIFEST"; then + echo "stella's Cargo.toml already carries the local-CGP patch — leaving it as-is." +else + echo "Patching $STELLA_MANIFEST to pin contextgraph-* at $CGP_DIR (local checkout)" + { + echo "" + echo "$SENTINEL" + echo "[patch.\"$CGP_GIT_SOURCE\"]" + for crate in "${crates[@]}"; do + printf '%s = { path = "%s/%s" }\n' "$crate" "$CGP_DIR" "$crate" + done + } >>"$STELLA_MANIFEST" +fi + +echo "--- patched Cargo.toml tail ---" +tail -n "$(( ${#crates[@]} + 3 ))" "$STELLA_MANIFEST" +echo "-------------------------------" + +# Discover which stella crates depend on a contextgraph-* crate at all, from +# their manifests, rather than hardcoding stella-graph/stella-context/ +# stella-cli — so the canary keeps tracking the real dependency edge as +# stella's own crate graph changes. +dependents=() +while IFS= read -r manifest; do + dependents+=("$(basename "$(dirname "$manifest")")") +done < <(cd "$STELLA_DIR" && find . -mindepth 2 -maxdepth 2 -name Cargo.toml \ + -exec grep -lE '^contextgraph-[a-z-]+ = ' {} \; | sort -u) + +if [[ "${#dependents[@]}" -eq 0 ]]; then + echo "::error::no stella crate depends on contextgraph-* — is STELLA_DIR stale, or did the dependency move?" + exit 1 +fi + +echo "stella crates depending on contextgraph-*: ${dependents[*]}" + +package_args=() +for pkg in "${dependents[@]}"; do + package_args+=(-p "$pkg") +done + +cd "$STELLA_DIR" +echo "--- cargo build (${dependents[*]}) against local CGP checkout ---" +cargo build "${package_args[@]}" + +if [[ "${DOWNSTREAM_CANARY_BUILD_ONLY:-0}" == "1" ]]; then + echo "DOWNSTREAM_CANARY_BUILD_ONLY=1 — skipping cargo test." + exit 0 +fi + +echo "--- cargo test (${dependents[*]}) against local CGP checkout ---" +cargo test "${package_args[@]}" diff --git a/.github/scripts/wait-for-crate.sh b/.github/scripts/wait-for-crate.sh new file mode 100755 index 0000000..0d96aea --- /dev/null +++ b/.github/scripts/wait-for-crate.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Poll the crates.io sparse index until a just-published crate version is +# visible, so the next `cargo publish` in the dependency chain (which +# resolves its path dependency's version requirement against the registry, +# not the local path — see PUBLISHING.md) doesn't race the CDN. Usually +# resolves in seconds; PUBLISHING.md notes it can occasionally take a minute +# or two. +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "usage: $0 [max-attempts] [sleep-seconds]" >&2 + exit 2 +fi + +crate="$1" +version="$2" +max_attempts="${3:-30}" +sleep_seconds="${4:-10}" + +# Sparse index path convention: https://doc.rust-lang.org/cargo/reference/registry-index.html#index-files +lower=$(printf '%s' "$crate" | tr '[:upper:]' '[:lower:]') +len=${#lower} +if [[ $len -eq 1 ]]; then + path="1/$lower" +elif [[ $len -eq 2 ]]; then + path="2/$lower" +elif [[ $len -eq 3 ]]; then + path="3/${lower:0:1}/$lower" +else + path="${lower:0:2}/${lower:2:2}/$lower" +fi + +url="https://index.crates.io/$path" + +for attempt in $(seq 1 "$max_attempts"); do + if curl -fsSL "$url" 2>/dev/null | python3 -c " +import json, sys + +target = '$version' +for line in sys.stdin: + line = line.strip() + if not line: + continue + entry = json.loads(line) + if entry.get('vers') == target: + sys.exit(0) +sys.exit(1) +"; then + echo "$crate $version is live on the sparse index." + exit 0 + fi + echo "Attempt $attempt/$max_attempts: $crate $version not yet visible on the sparse index, waiting ${sleep_seconds}s..." + sleep "$sleep_seconds" +done + +echo "::error::$crate $version did not appear on the sparse index after $((max_attempts * sleep_seconds))s" +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770999b..50735fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,25 @@ jobs: - run: pip install jsonschema - run: python3 schema/validate-examples.py + publish-dry-run: + name: contextgraph-types packages cleanly (crates.io dry run) + runs-on: ubuntu-latest + # Cheap, credential-free proof that the first crate in the publish chain + # (see PUBLISHING.md) still packages, resolves, and compiles in isolation. + # `--dry-run` never authenticates and never uploads — it aborts right + # before that step. Verified: `cargo publish --dry-run -p contextgraph-types` + # needs no `cargo login` and no CARGO_REGISTRY_TOKEN. Scoped to + # contextgraph-types only because it's the one crate in the chain with no + # unpublished workspace-internal dependency to resolve — contextgraph-host + # and contextgraph-conformance can't dry-run until contextgraph-types is + # actually live on crates.io (see PUBLISHING.md's note on why local + # pre-publish verification is asymmetric). + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish --dry-run -p contextgraph-types + site: name: docs site builds runs-on: ubuntu-latest diff --git a/.github/workflows/downstream-canary.yml b/.github/workflows/downstream-canary.yml new file mode 100644 index 0000000..8d66249 --- /dev/null +++ b/.github/workflows/downstream-canary.yml @@ -0,0 +1,126 @@ +name: Downstream Canary + +# The code-side half of the #27 boundary enforcement (see +# docs/adaptive-context-reconciliation.md's "Enforcement" section and ADR +# 0007's Consequences). Downstream docs now hold only a pinned pointer to +# this repo for frame/wire semantics — the risk that remains is a code/type +# break in contextgraph-* that the pinned `rev` in a downstream Cargo.toml +# doesn't surface until a human bumps it. This workflow builds the known +# downstream consumer (stella) against THIS repo's HEAD so that break is +# visible before the freeze, not after. +# +# Deliberately advisory, not a required check: this repo's own gate must stay +# green on this repo's own guarantees, not on a downstream project's +# unrelated churn. `continue-on-error` + an explicit ::warning:: keeps the +# signal visible without letting a foreign repo block a merge here. + +on: + schedule: + # Daily, off the hour, so it doesn't line up with everyone else's cron. + - cron: "17 6 * * *" + workflow_dispatch: {} + pull_request: + # Only when a PR could plausibly move the thing this canary watches — + # the wire-level crates themselves, or the canary's own definition. + paths: + - "contextgraph-types/**" + - "contextgraph-host/**" + - "contextgraph-trace/**" + - "contextgraph-conformance/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/downstream-canary.yml" + - ".github/scripts/downstream-canary-stella.sh" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + stella-canary: + name: stella builds against CGP HEAD (advisory) + runs-on: ubuntu-latest + steps: + - name: Checkout context-graph-protocol (this repo, HEAD) + uses: actions/checkout@v5 + with: + path: cgp + + - name: Checkout stella (public) + uses: actions/checkout@v5 + with: + repository: macanderson/stella + path: stella + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + cgp + stella + + - name: Build + test stella's contextgraph-* consumers against local HEAD + id: build + continue-on-error: true + env: + CGP_DIR: ${{ github.workspace }}/cgp + STELLA_DIR: ${{ github.workspace }}/stella + run: ./cgp/.github/scripts/downstream-canary-stella.sh + + - name: Flag the break (advisory — does not fail the job) + if: steps.build.outcome == 'failure' + run: | + echo "::warning title=downstream canary::stella no longer builds against context-graph-protocol HEAD (${{ github.sha }}) — a breaking change to contextgraph-types::ContextFrame or another wire type likely needs a coordinated stella update before the next freeze/tag." + { + echo "### :warning: Downstream canary: \`stella\` failed" + echo + echo "stella (macanderson/stella) no longer builds/tests against this repo's HEAD (\`${{ github.sha }}\`). See the \`build\` step log above for the compiler error." + } >> "$GITHUB_STEP_SUMMARY" + + oxagen-canary: + name: oxagen conformance fixtures pinned to CGP HEAD (advisory, deferred) + runs-on: ubuntu-latest + steps: + # oxagen-platform is private, so reading it at all needs a token with + # cross-org repo access — that token does not exist yet + # (OXAGEN_PLATFORM_TOKEN). Wiring it is the deferred human step this + # job is waiting on; until then it degrades to a no-op notice instead + # of a red (or silently absent) job. + - name: Check for cross-org access + id: gate + env: + HAS_TOKEN: ${{ secrets.OXAGEN_PLATFORM_TOKEN != '' }} + run: | + echo "has_token=$HAS_TOKEN" >> "$GITHUB_OUTPUT" + if [[ "$HAS_TOKEN" != "true" ]]; then + echo "::notice title=downstream canary::oxagen-canary is a no-op — OXAGEN_PLATFORM_TOKEN is not set, so this repo cannot check out the private macanderson/oxagen-platform to validate its pinned CGP conformance fixtures. Wiring that token (a fine-grained PAT with read access to that repo) is the deferred human step; see docs/adaptive-context-reconciliation.md." + fi + + - name: Checkout oxagen-platform (private, cross-org) + if: steps.gate.outputs.has_token == 'true' + uses: actions/checkout@v5 + with: + repository: macanderson/oxagen-platform + token: ${{ secrets.OXAGEN_PLATFORM_TOKEN }} + path: oxagen + sparse-checkout: | + docs/specs/adaptive-context + + - name: oxagen's CGP fixtures, pinned against this HEAD (deferred to #28) + if: steps.gate.outputs.has_token == 'true' + continue-on-error: true + run: | + # oxagen-platform's own spec (docs/specs/adaptive-context/spec.md + # §3, "Out (deferred, with owners)") explicitly defers running the + # Rust contextgraph-conformance suite against its HTTP endpoint + # until this repo ships the lifecycle capability (issue #28). Until + # #28 lands there is no wire surface on the oxagen side for this + # job to build or test against — so, with access wired, this step + # only asserts the pinned fixtures directory that #28 will exercise + # is still where the spec says it is, as a placeholder that turns + # into a real conformance run once #28 ships. + test -d oxagen/docs/specs/adaptive-context + echo "::notice title=downstream canary::oxagen cross-org access is wired, but the real fixture-vs-HEAD conformance run stays deferred until #28 (lifecycle capability) ships — see docs/specs/adaptive-context/spec.md §3 in oxagen-platform." diff --git a/.github/workflows/publish-sdks.yml b/.github/workflows/publish-sdks.yml new file mode 100644 index 0000000..8c8f1d8 --- /dev/null +++ b/.github/workflows/publish-sdks.yml @@ -0,0 +1,181 @@ +name: Publish SDKs + +# Companion to `ci.yml`, scoped to the one-way registry actions `ci.yml` +# deliberately never runs: `twine upload` and `npm publish`. See +# `sdk/PUBLISHING.md` for the full checklist and prerequisites this workflow +# automates the mechanical half of. +# +# Safety property this file is required to hold (see sdk/PUBLISHING.md and +# the repo's contribution rules): a tag push alone can never publish +# anything. Concretely: +# +# - The `verify-*` jobs run on a matching tag push. They build, lint, and +# run the relevant SDK's example provider through the same +# `conformance-external.sh` oracle CI uses on every PR. No secrets are +# read and nothing leaves the runner. +# - The `publish-*` jobs run ONLY on a manual `workflow_dispatch`, never on +# a tag push (see each job's `if:`). A maintainer must explicitly choose +# the ref and the target, after the verify job for that ref is green. +# - `publish-*` jobs are additionally scoped to the `publish-sdks` +# GitHub Environment. Configure required reviewers on that environment +# (Settings -> Environments) before the token secrets below are added, so +# the manual dispatch itself needs a second approval. +# - Each `publish-*` job hard-fails before touching the registry if its +# credential secret is unset, rather than silently skipping (a skip could +# be mistaken for "already published"). +# +# There is no `publish-go` job. Go modules don't have an upload step: the +# `sdk/go/vX.Y.Z` tag itself is the publish, and cutting/pushing that tag is +# explicitly a human-only action (see sdk/PUBLISHING.md) that no workflow +# here performs. `verify-go` exists so a tag push still produces the same +# build+conformance proof the other two SDKs get. + +on: + push: + tags: + - "npm-v*" + - "pypi-v*" + - "sdk/go/v*" + workflow_dispatch: + inputs: + target: + description: "Registry to publish to (publish-* jobs only; verify-* jobs always run)" + required: true + type: choice + options: + - npm + - pypi + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify-npm: + name: verify (npm) — build + conformance, no credentials + if: startsWith(github.ref, 'refs/tags/npm-v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --bins + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Build the TypeScript SDK + working-directory: sdk/typescript + run: | + npm install + npm run build + - name: Inspect exactly what `npm publish` would upload + working-directory: sdk/typescript + run: npm pack --dry-run + - name: Example provider passes the conformance suite + run: ./.github/scripts/conformance-external.sh -- node sdk/typescript/dist/examples/example-docs.js + + verify-pypi: + name: verify (pypi) — build + conformance, no credentials + if: startsWith(github.ref, 'refs/tags/pypi-v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --bins + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - name: Build sdist + wheel + working-directory: sdk/python + run: python -m build + - name: Validate package metadata (no upload) + working-directory: sdk/python + run: twine check dist/* + - name: Example provider passes the conformance suite + run: ./.github/scripts/conformance-external.sh -- python3 sdk/python/examples/example_docs.py + + verify-go: + name: verify (go) — build + conformance, no credentials, no tag + if: startsWith(github.ref, 'refs/tags/sdk/go/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --bins + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Vet and build the Go SDK + working-directory: sdk/go + run: | + go vet ./... + go build -o "$GITHUB_WORKSPACE/cg-go-example" ./examples/example-docs + - name: Example provider passes the conformance suite + run: ./.github/scripts/conformance-external.sh -- ./cg-go-example + + publish-npm: + name: publish (npm) — manual dispatch only + needs: verify-npm + if: github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'npm' + environment: publish-sdks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + - name: Refuse to publish without a token + env: + TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::NPM_TOKEN secret is not set. Add it in Settings -> Secrets and variables -> Actions -> Environment secrets (publish-sdks) before re-running this job. Refusing to publish without it." + exit 1 + fi + - name: Build + working-directory: sdk/typescript + run: | + npm install + npm run build + - name: npm publish --access public + working-directory: sdk/typescript + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public + + publish-pypi: + name: publish (pypi) — manual dispatch only + needs: verify-pypi + if: github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'pypi' + environment: publish-sdks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - name: Refuse to publish without a token + env: + TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::PYPI_API_TOKEN secret is not set. Add it in Settings -> Secrets and variables -> Actions -> Environment secrets (publish-sdks) before re-running this job — or switch this job to PyPI Trusted Publishing (OIDC) per sdk/PUBLISHING.md. Refusing to publish without it." + exit 1 + fi + - name: Build sdist + wheel + working-directory: sdk/python + run: python -m build + - name: twine upload + working-directory: sdk/python + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cd8602f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +# Publishes the three publishable Context Graph Protocol crates to crates.io, +# in dependency order (see PUBLISHING.md): contextgraph-types -> contextgraph-host +# -> contextgraph-conformance. `contextgraph-trace` is deliberately excluded — it +# inherits the workspace's `publish = false` default (see its Cargo.toml). +# +# This workflow is inert by construction. A `contextgraph-v*` tag push alone +# can never publish anything: +# 1. `preflight` runs unconditionally (no environment, no secrets) and only +# proves contextgraph-types still packages — it cannot publish anything. +# 2. The `publish` job targets the `crates-io` GitHub Environment, which +# must exist and have required reviewers configured — the job pauses +# there for a human "Approve and deploy" click before a single step +# inside it runs. +# 3. `CARGO_REGISTRY_TOKEN` must exist as a secret scoped to that same +# environment. No secret, no publish, regardless of approval. +# +# Neither of those exists yet as of writing (issue #16 is prep only — no real +# publish, no tag, no environment/secret setup). Standing up both is a +# one-time, human, repo-Settings action; see PUBLISHING.md. +# +# A failure partway through (e.g. contextgraph-types publishes but +# contextgraph-host's index-propagation wait times out) is not auto-retried: +# re-running this workflow would try to re-publish an already-live version, +# which crates.io rejects outright. Finish the remaining crates manually +# following PUBLISHING.md's sequence instead — this is the same one-way-door +# constraint that file documents for a by-hand release. + +on: + push: + tags: + - "contextgraph-v*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Unconditional, credential-free, no environment gate: proves the leaf + # crate still packages and compiles in isolation *before* a human is asked + # to spend an approval click on the job below. Same check CI already runs + # on every PR (see the `publish-dry-run` job in ci.yml) — repeated here + # because a tag can in principle point at a commit CI never ran against. + preflight: + name: preflight (dry-run, no credentials, no approval needed) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - run: cargo publish --dry-run -p contextgraph-types + + publish: + name: publish crates.io (contextgraph-types -> contextgraph-host -> contextgraph-conformance) + needs: preflight + runs-on: ubuntu-latest + environment: crates-io + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + + - name: Publish contextgraph-types + run: cargo publish -p contextgraph-types --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - name: Wait for contextgraph-types to propagate to the sparse index + run: ./.github/scripts/wait-for-crate.sh contextgraph-types "${GITHUB_REF_NAME#contextgraph-v}" + + # contextgraph-host depends on contextgraph-types via a path dep with a + # ">=0.1.0" version requirement (see contextgraph-host/Cargo.toml) — + # crates.io strips the path and resolves the version req against the + # registry, so this step fails fast if the wait above returned too + # early. + - name: Publish contextgraph-host + run: cargo publish -p contextgraph-host --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - name: Wait for contextgraph-host to propagate to the sparse index + run: ./.github/scripts/wait-for-crate.sh contextgraph-host "${GITHUB_REF_NAME#contextgraph-v}" + + - name: Publish contextgraph-conformance + run: cargo publish -p contextgraph-conformance --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f156b..0d394b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,46 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 ## [Unreleased] ### Added +- **Conformance registry + provider badge** (`site/content/docs/registry.mdx`, + `docs/registry.md`, #20) — a page listing providers that are green on + `contextgraph-conformance`'s suite, each backed by a reproducible + `contextgraph-inspect --json` report (not a self-attested claim), seeded with + the bundled `contextgraph-example-docs` reference fixture and its captured + 12/12 report. This is where the governance "two independent implementations" + freeze criterion becomes checkable. Adds a static `conformant.svg` badge and a + PR-template submission checklist requiring the exact reproducing invocation. +- **Release prep** (`.github/workflows/release.yml`, #16) — a tag-triggered + (`contextgraph-v*`) workflow that publishes `contextgraph-types` → + `contextgraph-host` → `contextgraph-conformance` to crates.io in dependency + order, polling the sparse index between publishes + (`.github/scripts/wait-for-crate.sh`). A tag push alone can never publish: an + unconditional credential-free `publish-dry-run` CI job packages + `contextgraph-types` on every PR, and the real publish is gated behind a + `crates-io` GitHub Environment requiring reviewer approval. Adds crates.io + + docs.rs badges to the root and per-crate READMEs (they read "not found" until + the first real publish). Version cut and the environment/secret are the + owner's call (see #16). +- **SDK publish prep** (`sdk/PUBLISHING.md`, `.github/workflows/publish-sdks.yml`, + #59) — a per-registry release checklist (npm already live via #46; PyPI and Go + pending) plus a tag-gated, secret-guarded publish workflow. The TypeScript SDK + is published to npm as `@contextgraphprotocol/typescript-sdk` 0.1.0; the PyPI + (`contextgraph-sdk`) and Go module publishes stay human-only (registry upload + and an addressable git tag). SDK READMEs now say "not yet published" so the + install snippets aren't misleading. +- **Downstream canary CI** (`.github/workflows/downstream-canary.yml`, #29) — + the code-side half of the #27 boundary. Builds stella's `contextgraph-*` + consumers (`stella-graph`, `stella-context`, `stella-cli`) against this repo's + HEAD via a local `[patch]` override (`.github/scripts/downstream-canary-stella.sh`), + on a daily schedule, `workflow_dispatch`, and PRs touching the wire crates. + Deliberately advisory (`continue-on-error` + a `::warning::` flag) — a + downstream break is a pre-freeze signal, not a reason to fail this repo's gate + on a foreign project's state. A guarded `oxagen-canary` job activates once a + human wires `OXAGEN_PLATFORM_TOKEN`. +- **Schema `$id` now names a URL that actually resolves** (#58). `$id` pointed at + `contextgraphprotocol.org/schema/…`, which 404s until the Vercel project is + Git-linked to this repo's `site/` (#57); it now names this repo's GitHub-raw + URL, which resolves today regardless of how #57 is decided, as an interim + measure until the domain can serve the file for real. - **`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/PUBLISHING.md b/PUBLISHING.md index 78be507..b3fdf78 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -10,6 +10,37 @@ crates are published independently of any downstream consumer (such as the `Cargo.toml`s). This file exists so the *first* real publish is a checklist, not an improvisation. +## Preferred path: the tag-triggered workflow, not a laptop + +[`.github/workflows/release.yml`](./.github/workflows/release.yml) automates +the exact sequence documented below, so a release is reproducible and doesn't +depend on whoever's laptop has a `cargo login` token on it. Pushing a +`contextgraph-vX.Y.Z` tag is what *starts* it — it does not publish anything +by itself: + +1. The workflow's `publish` job targets the `crates-io` GitHub Environment. If + that environment has required reviewers configured (Settings → + Environments), the job pauses there until a human clicks "Approve and + deploy." No approval, no publish. +2. It then runs `cargo publish` for each crate in dependency order, polling + the sparse index between publishes (`.github/scripts/wait-for-crate.sh`) + so the next crate's registry resolution never races the CDN — the same + "wait for the index" step called out by hand below, just automated. +3. `CARGO_REGISTRY_TOKEN` must exist as a secret scoped to that same + environment, holding a crates.io API token as described in "One-time + prerequisites" below. + +Both the `crates-io` environment and its secret are one-time, human, +repo-Settings setup — **neither exists yet** as of this writing. Until they +do, the workflow exists but cannot run: a tag push just sits there with the +job queued for an environment that has no approver configured, which is a +safe failure mode, not a silent one. + +The manual sequence in "The publish sequence" below remains the documented +reference for exactly what that workflow executes step-by-step, and is the +fallback if a release needs manual intervention partway through (see "This is +a one-way door"). + ## Why the order matters ``` @@ -51,6 +82,11 @@ This is also why local pre-publish verification is asymmetric: 2. `cargo login ` locally, using a crates.io API token scoped to `publish-new` + `publish-update` (crates.io Account Settings → API Tokens). Do not commit this token; it's not an env var this repo reads. + For the tag-triggered workflow instead of a laptop, the same kind of + token is stored as the `CARGO_REGISTRY_TOKEN` secret on a `crates-io` + GitHub Environment (Settings → Environments → New environment → add + required reviewers, then add the secret scoped to it) rather than run + through `cargo login` anywhere. 3. Confirm the crate names are still unclaimed: check `https://crates.io/crates/contextgraph-types`, `.../contextgraph-host`, `.../contextgraph-conformance` — a 404 on each means the name is free. (As of writing, all three are @@ -121,7 +157,10 @@ on crates.io before the next goes up. *published* crates, not just the workspace. - Tag the release in this repo for traceability, e.g. `contextgraph-v0.1.0`. Use the `contextgraph-` tag prefix so the crate release train never collides with a - downstream consumer's own version tags in the tag namespace. + downstream consumer's own version tags in the tag namespace. **If publishing + by hand, this happens last** — after the fact, for traceability. If using + `release.yml` instead, the order inverts: pushing this same tag is what + starts the workflow, so it happens *first*, before any crate is live. ## This is a one-way door diff --git a/README.md b/README.md index ba3a1f5..4ee6c83 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # Context Graph Protocol (draft v0.1.0) +[![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) + +> These badges read "not found" until the crates are actually published +> (tracked by [#16](https://github.com/macanderson/context-graph-protocol/issues/16)) — +> expected today, and the acceptance signal once a real release ships. + https://contextgraphprotocol.org **The canonical architecture for building context graphs that agents use to reason over.** diff --git a/contextgraph-conformance/README.md b/contextgraph-conformance/README.md index 3048948..b1a0dfb 100644 --- a/contextgraph-conformance/README.md +++ b/contextgraph-conformance/README.md @@ -1,5 +1,8 @@ # contextgraph-conformance +[![crates.io](https://img.shields.io/crates/v/contextgraph-conformance.svg)](https://crates.io/crates/contextgraph-conformance) +[![docs.rs](https://img.shields.io/docsrs/contextgraph-conformance)](https://docs.rs/contextgraph-conformance) + The public conformance suite for the **Context Graph Protocol**, plus `contextgraph-inspect` — an interactive Context Graph Protocol prober analogous to MCP's inspector. diff --git a/contextgraph-host/README.md b/contextgraph-host/README.md index 35e8393..79a0c9c 100644 --- a/contextgraph-host/README.md +++ b/contextgraph-host/README.md @@ -1,5 +1,8 @@ # contextgraph-host +[![crates.io](https://img.shields.io/crates/v/contextgraph-host.svg)](https://crates.io/crates/contextgraph-host) +[![docs.rs](https://img.shields.io/docsrs/contextgraph-host)](https://docs.rs/contextgraph-host) + The host runtime for the **Context Graph Protocol**: provider discovery, stdio + streamable-HTTP transports, capability negotiation, budget-honest fan-out routing, and egress consent gating. diff --git a/contextgraph-types/README.md b/contextgraph-types/README.md index 661ddd9..553ca49 100644 --- a/contextgraph-types/README.md +++ b/contextgraph-types/README.md @@ -1,5 +1,8 @@ # contextgraph-types +[![crates.io](https://img.shields.io/crates/v/contextgraph-types.svg)](https://crates.io/crates/contextgraph-types) +[![docs.rs](https://img.shields.io/docsrs/contextgraph-types)](https://docs.rs/contextgraph-types) + The wire types for the **Context Graph Protocol**: context frames, queries, capabilities, and provenance. diff --git a/docs/adaptive-context-reconciliation.md b/docs/adaptive-context-reconciliation.md index a1cf2bd..fb0f0f2 100644 --- a/docs/adaptive-context-reconciliation.md +++ b/docs/adaptive-context-reconciliation.md @@ -134,5 +134,9 @@ used where they fit — see the disposition summary. The structural guarantee is that the normative frame text lives in exactly one place (`SPEC.md` + schema); downstream docs hold only a pinned pointer (`NORMATIVE-HOME:` header naming this repo + the pinned rev they consume). The -downstream **canary CI** (issue #29) builds stella and the oxagen copy against -this repo's HEAD, catching code-level drift before the freeze. +downstream **canary CI** (issue #29, implemented as +[`.github/workflows/downstream-canary.yml`](../.github/workflows/downstream-canary.yml)) +builds stella and the oxagen copy against this repo's HEAD, catching +code-level drift before the freeze. It is advisory (`continue-on-error`), not +a required check — a break there is a signal to act on, not a reason to block +an unrelated PR to this repo. diff --git a/docs/adr/0007-protocol-product-boundary.md b/docs/adr/0007-protocol-product-boundary.md index 8ee0e11..71f4cc7 100644 --- a/docs/adr/0007-protocol-product-boundary.md +++ b/docs/adr/0007-protocol-product-boundary.md @@ -148,7 +148,8 @@ the #28 profile alone. - **Re-drift is structurally prevented, not linted.** With the normative frame text living in exactly one place and the downstream docs holding only a pinned pointer, there is nothing left to drift. The downstream canary - (issue [#29](https://github.com/macanderson/context-graph-protocol/issues/29)) + (issue [#29](https://github.com/macanderson/context-graph-protocol/issues/29), + implemented as [`.github/workflows/downstream-canary.yml`](../../.github/workflows/downstream-canary.yml)) guards the *code* side by building stella and the oxagen copy against this repo's HEAD. diff --git a/docs/implementing-a-provider.md b/docs/implementing-a-provider.md index 8916f7c..ad66e20 100644 --- a/docs/implementing-a-provider.md +++ b/docs/implementing-a-provider.md @@ -186,3 +186,14 @@ optional test query, and shows you the frames it got back with their scores and token costs — a fast human-readable feedback loop before you run the scripted conformance suite. See [running-conformance.md](./running-conformance.md) for that next step. + +### Getting listed once you're green + +Once `contextgraph-inspect ... --json` reports every check `pass` (or `skip`, +never `fail`), your provider is eligible for the +[**conformance registry**](./registry.md) — a table of conformant providers +with a reproducible report backing each claim, plus the +`![CGP conformant](https://cgp.oxagen.sh/badges/conformant.svg)` badge you can +put in your own README once listed. Listing is a pull request, not a +self-attested form: see [registry.md](./registry.md#how-to-get-listed) for +exactly what to include. diff --git a/docs/index.md b/docs/index.md index 608aaee..b972552 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,9 @@ Reference documentation for the **Context Graph Protocol** crates: - [**Running conformance**](./running-conformance.md) — how to prove your provider (or host) is Context Graph Protocol 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, + 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 `contextgraph/1.0-draft` to `contextgraph/1.0`. diff --git a/docs/registry.md b/docs/registry.md new file mode 100644 index 0000000..7c79230 --- /dev/null +++ b/docs/registry.md @@ -0,0 +1,76 @@ +# Conformance registry + +This page lists providers that are **Context Graph Protocol 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 +verifiable fact about a specific build, not a badge anyone can paste in. + +Listings here are also load-bearing for governance: the freeze from +`contextgraph/1.0-draft` to `contextgraph/1.0` requires **at least two +independent implementations** passing the suite +([GOVERNANCE.md](../GOVERNANCE.md#the-path-to-contextgraph10)). This registry +is where that count becomes checkable. + +## Conformant providers + +| 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 | 12/12 checks passed — [report](../site/public/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 +prove the table and the submission flow work end to end. Third-party +providers land the same way, via the PR flow below. + +The listed report is a byte-for-byte capture of: + +```bash +cargo install contextgraph-conformance +cargo build -p contextgraph-conformance --bin contextgraph-example-docs +contextgraph-inspect stdio --json -- ./target/debug/contextgraph-example-docs +``` + +(Run from a checkout of this repository, since `contextgraph-example-docs` is +a dev-only fixture binary, not something published to crates.io — see the +`publish = true` override note in `contextgraph-conformance/Cargo.toml`.) + +## How to get listed + +There is no submission form and no self-attestation — a listing is a pull +request that a maintainer can independently re-run. + +1. **Run the suite against your provider** with `contextgraph-inspect ... --json` + (see [running-conformance.md](./running-conformance.md)) and confirm every + check is `pass` (a `skip` is fine — e.g. `malformed-input-tolerance` on an + HTTP or in-process target — a `fail` is not). +2. **Open a pull request** adding one row to the table above and, if it's + convenient to share, the JSON report file it links to. State the exact + command you ran — the PR template has a **Registry submission** checklist + item for this; a listing with no reproducible command attached will not be + merged. +3. **Add the badge** (optional, see below) to your own README once the PR + merges. + +A maintainer re-runs the check before merging. A listing that stops passing — +because the provider regressed or the protocol moved — gets a follow-up PR to +fix it or remove the row; this registry is a live claim, not a one-time +certificate. + +## The badge + +Once your provider has a merged row in the table above, you can put this in +your own README: + +```md +![CGP conformant](https://cgp.oxagen.sh/badges/conformant.svg) +``` + +which renders as: + +![CGP conformant](../site/public/badges/conformant.svg) + +The badge is a static, hand-authored asset — not a live third-party redirect — +so it never depends on this site's uptime and never phones home. It names the +protocol family the badge claims (`contextgraph/1.0-draft`), not a specific +provider version; the row in this table is what backs the specific claim. diff --git a/schema/contextgraph-envelope.schema.json b/schema/contextgraph-envelope.schema.json index 488739a..7fdd2f2 100644 --- a/schema/contextgraph-envelope.schema.json +++ b/schema/contextgraph-envelope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://contextgraphprotocol.org/schema/contextgraph-envelope.schema.json", + "$id": "https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/contextgraph-envelope.schema.json", "title": "Context Graph Protocol envelope", "description": "A single Context Graph Protocol message \u2014 the unit exchanged on the wire (one object per NDJSON line over stdio, one request/response body over streamable HTTP). An envelope is an internally-tagged enum: the `type` field selects the variant and sits at the same level as the payload fields. Validate one message at a time against this root schema; validate individual payloads against the entries in `$defs`.", "$comment": "This schema is the AUTHORING-STRICT profile: `additionalProperties: false` throughout, so it catches typos in fixtures and reference messages. It is NOT the interop contract. Per SPEC.md \u00a713 U1, a receiver on the wire MUST ignore unrecognised members rather than reject the message \u2014 that is what lets a contextgraph/1.x minor add optional fields a 1.0 peer harmlessly drops. Do not use this schema to reject a peer's live message solely for carrying unknown members; use it to lint what you author.", diff --git a/schema/validate-examples.py b/schema/validate-examples.py index 3e11a23..9662689 100755 --- a/schema/validate-examples.py +++ b/schema/validate-examples.py @@ -211,18 +211,28 @@ def _skip_ws(text: str, index: int) -> int: # 5. The schema's `$id` must dereference to this exact schema. # # `$id` is the schema's public identity — the URL third parties resolve and -# quote. It pointed at `context-graph-protocol.org`, a hyphenated host that -# was never registered and returned a DNS failure, so every consumer that -# tried to fetch it got nothing. Now it names the live domain, and the site -# serves the file from `site/public/schema/`. +# quote. It first pointed at `context-graph-protocol.org`, a hyphenated host +# that was never registered and returned a DNS failure, so every consumer +# that tried to fetch it got nothing (issue #58). Swapping in the live +# apex, `contextgraphprotocol.org`, does not fix it either: the domain +# resolves, but `site/` does not currently own that Vercel project's Git +# deploy and does not serve anything under `/schema/` there — see #57, +# which is tracking the deploy-topology fix. Pointing `$id` at that host +# before #57 lands would trade one unreachable URL for another. # -# A served copy can drift from the source of truth, which would be worse than -# a 404: a stale schema that still resolves is one that silently validates -# the wrong thing. So the copy is asserted byte-identical here rather than -# trusted to be refreshed by hand. +# So this is an interim measure: `$id` names this repo's GitHub-raw URL, +# which resolves today regardless of how #57 is decided. Once #57 lands and +# `contextgraphprotocol.org/schema/...` actually serves this file, `$id` +# should move there and this comment should say so. +# +# The `site/public/schema/` mirror is kept byte-identical to the source +# below not because it is what makes `$id` dereferenceable — it isn't, per +# the above — but because it is the copy the (currently topology-broken) +# site would serve, and a stale copy sitting there would silently diverge +# from the source of truth the moment #57 does land and starts serving it. SCHEMA_SOURCE = ROOT / "schema" / "contextgraph-envelope.schema.json" SCHEMA_SERVED = ROOT / "site" / "public" / "schema" / "contextgraph-envelope.schema.json" -expected_id = f"https://contextgraphprotocol.org/schema/{SCHEMA_SOURCE.name}" +expected_id = f"https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/{SCHEMA_SOURCE.name}" check(f"$id is {expected_id}", SCHEMA.get("$id") == expected_id) diff --git a/sdk/PUBLISHING.md b/sdk/PUBLISHING.md new file mode 100644 index 0000000..5f9fafa --- /dev/null +++ b/sdk/PUBLISHING.md @@ -0,0 +1,236 @@ +# Publishing the Context Graph Protocol SDKs + +This documents the release process for the three provider SDKs — `sdk/typescript`, +`sdk/python`, `sdk/go` — to their respective package registries. Each SDK is an +**independent implementation** of the same wire contract (that's the point — +see [`sdk/README.md`](./README.md)), so unlike the workspace crates +([`../PUBLISHING.md`](../PUBLISHING.md)) there is no dependency order between +them: any SDK can publish without the others being live. What they share is a +target version (`0.1.0` for the first release of each) and the same bar — +green on `.github/scripts/conformance-external.sh` — before anything goes out. + +| SDK | Registry | Status | +| --- | --- | --- | +| TypeScript | npm, `@contextgraphprotocol/typescript-sdk` | ✅ published (PR #46) | +| Python | PyPI, `contextgraph-sdk` | ⬜ not yet published | +| Go | Go module proxy, `.../sdk/go/contextgraph` | ⬜ not yet published (tag-gated, see below) | + +**Nobody has run the PyPI or Go publish steps yet.** This file exists so the +*first* real publish of each is a checklist, not an improvisation — exactly +the role [`../PUBLISHING.md`](../PUBLISHING.md) plays for the crates. + +## npm (already live — for the next bump) + +The TypeScript SDK's first publish already happened (PR #46), so this is the +one registry where the "one-time prerequisites" are already satisfied for this +maintainer account. Recorded here so a *second* release doesn't require +relearning it: + +1. An npm account with 2FA enabled and publish access to the + `@contextgraphprotocol` org scope. +2. `npm login` locally (or an automation token for CI — see the workflow + below). +3. Bump `version` in `sdk/typescript/package.json`, then from `sdk/typescript`: + ```bash + npm install + npm run build + npm pack --dry-run # inspect the tarball contents before anything uploads + npm publish --access public + ``` + `files` in `package.json` is already scoped to `["dist/src", "README.md"]`, + so `npm pack --dry-run` is the cheap way to confirm a source-map or stray + test file hasn't crept into what ships. + +## PyPI + +### One-time prerequisites + +1. A PyPI account with 2FA enabled. +2. Either: + - An API token scoped to the `contextgraph-sdk` project (PyPI Account + Settings → API tokens — a *project-scoped* token is only available after + the first upload; the **first** publish necessarily uses an + account-scoped token, which should be rotated to a project-scoped one + immediately after), or + - PyPI **Trusted Publishing** (OIDC from GitHub Actions, no stored secret + at all) configured against this repository and the `publish-sdks.yml` + workflow below — the preferred long-term setup, but it can only be + configured for a project that already exists on PyPI, so it too follows + the first manual publish rather than replacing it. +3. Confirm the name is still unclaimed: + `https://pypi.org/pypi/contextgraph-sdk/json` — a 404 means free. (Checked + 2026-07-29: 404, unclaimed.) + +### The publish sequence + +Run from `sdk/python`: + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install build twine + +# Build sdist + wheel into dist/ +python -m build + +# Validate metadata/README rendering with no network call and no upload — +# this is the pre-publish proof that belongs in a PR or a dry run. +twine check dist/* + +# The real, one-way upload. +twine upload dist/* +``` + +`twine check` catches the two most common first-publish failures (malformed +`long_description`/README rendering, missing/invalid classifiers) before +anything reaches the index. It does **not** catch a name collision or a +duplicate version — PyPI itself rejects those at upload time, and rejects +re-uploading an existing version outright (no overwrite, ever; see below). + +### Post-publish verification + +In a scratch directory *outside* this workspace: + +```bash +python3 -m venv /tmp/cgp-sdk-smoke && source /tmp/cgp-sdk-smoke/bin/activate +pip install contextgraph-sdk +python3 -c "import contextgraph_sdk; print(contextgraph_sdk.__file__)" +``` + +Then, from the repository root (with `cargo build --workspace --bins` run +once so the conformance binary exists), prove the *installed* package — not +the in-tree copy — still passes conformance by pointing the example provider's +shebang at the scratch venv's interpreter, or simpler, copy +`sdk/python/examples/example_docs.py` into the scratch dir and run it with the +scratch venv's `python3` (the example only imports `contextgraph_sdk`, so it +is agnostic to where that package physically resolves from): + +```bash +./.github/scripts/conformance-external.sh -- /tmp/cgp-sdk-smoke/bin/python3 /tmp/example_docs.py +``` + +A green run here is the acceptance criterion from #59: "`pip install +contextgraph-sdk` ... can build/run the example provider," checked against the +*published* package, not the workspace checkout. + +## Go + +Go modules don't have an upload step — **the tag is the publish.** Once a +tag matching the module's path exists on the public GitHub remote, the +module is immediately `go get`-able; there is no registry account, no token, +and no separate "release" action beyond `git tag` + `git push --tags`. + +### Why the tag has to be `sdk/go/vX.Y.Z`, not `vX.Y.Z` + +This repository is a Rust workspace with no root `go.mod` — `sdk/go/go.mod` +is a **nested module** whose module path is +`github.com/macanderson/context-graph-protocol/sdk/go`. Go's [multi-module +repository convention](https://go.dev/ref/mod#vcs-version) requires a nested +module's tags to be prefixed with its subdirectory path relative to the repo +root, so the first tag is: + +``` +sdk/go/v0.1.0 +``` + +A bare `v0.1.0` tag would be ignored by the `sdk/go` module entirely (that +tag pattern is reserved for a module living at the repo root, which doesn't +exist here) — it's an easy mistake to make once and then have to explain why +`go get ...@v0.1.0` 404s while `go get ...@sdk/go/v0.1.0` works. + +Note this is a distinct tag from the general repo release-tagging tracked in +#30 (a root-level `v0.0.2` for downstream git-pins to the Rust crates) — the +Go SDK's tag is independent of whatever prefix or cadence that one settles +on, but #30 is the first real tag this repository will have cut since the +pre-rename `ocp-v0.1.0`, so treat it as the dry run for the mechanics +(annotated tag, changelog cross-reference, pushing tags at all) that this +tag then repeats. + +### The publish sequence + +```bash +# From the repo root, after confirming sdk/go/go.mod's version is 0.1.0-ready +# (no in-flight breaking changes) and CI is green on the commit being tagged: +git tag -a sdk/go/v0.1.0 -m "sdk/go v0.1.0" +git push origin sdk/go/v0.1.0 +``` + +This is the one command sequence in this document that is *also* explicitly +out of scope for any agent to run unattended (see "one-way door" below) — +unlike npm/PyPI where a stray dry-run is harmless, `git push` of a tag is +itself the irreversible act for Go. + +### Pseudo-versions in the meantime + +Until the tag exists, `sdk/go` is still technically fetchable by an exact +commit, via Go's **pseudo-version** mechanism — `go get +github.com/macanderson/context-graph-protocol/sdk/go/contextgraph@` +resolves to a synthetic version string like `v0.0.0--`. This +is why `go vet` / `go build` against `sdk/go` works fine in CI and for anyone +pinning a commit today (see #30's note on stella/oxagen currently doing the +equivalent for the Rust crates) — what a tag adds is a stable, human-readable +version number and `@latest` resolution, not fetchability itself. + +### Post-publish verification + +```bash +mkdir -p /tmp/cgp-go-smoke && cd /tmp/cgp-go-smoke +go mod init cgp-go-smoke +go get github.com/macanderson/context-graph-protocol/sdk/go/contextgraph@v0.1.0 +``` + +A resolving `go.sum` entry (rather than a "module not found" or "no matching +versions" error) is the acceptance criterion. Then copy +`sdk/go/examples/example-docs` into the scratch module (updating its import +path to the now-external `contextgraph` package), `go build` it, and run: + +```bash +./.github/scripts/conformance-external.sh -- ./cgp-go-smoke-example +``` + +from the repository root, proving the externally-resolved module still +produces a conformant provider. + +The Go module proxy (`proxy.golang.org`) also caches the first successful +fetch of a version forever, recorded in the public checksum database +(`sum.golang.org`) — so the first `go get` after the tag is pushed is worth +doing deliberately (e.g. from this verification step) rather than leaving it +to whoever happens to try first. + +## After publishing + +- **Record the version in `../CHANGELOG.md`** under `[Unreleased]`, same as a + crate release — see the entry this issue (#59) already added as the + template. +- **Update the status table at the top of this file and in + [`sdk/README.md`](./README.md)** from ⬜ to ✅, and drop the "not yet + published" notes from `sdk/python/README.md` / `sdk/go/README.md`. +- **Verify the full acceptance bar from #59 end to end**: all three of + `npm install @contextgraphprotocol/typescript-sdk`, `pip install + contextgraph-sdk`, and `go get .../sdk/go/contextgraph@v0.1.0` resolve from + a clean environment, and each SDK's example provider passes + `conformance-external.sh` when run from the installed package, not the + in-tree copy. + +## This is a one-way door + +- **npm**: `npm unpublish` exists but is aggressively restricted (72-hour + window, blocked entirely if any other package depends on the version) and + is an anti-pattern for a public SDK regardless of policy — treat a bad + publish as needing a corrected patch version, never a retraction. +- **PyPI**: uploads cannot be overwritten or deleted. A version can only be + *yanked* via the web UI (equivalent to `cargo yank` — hidden from new + installs' default resolution, but still explicitly installable via `pip + install contextgraph-sdk==`, so existing lockfiles that + already pinned it keep working). Same rule: fix forward with a new version. +- **Go**: a pushed tag is technically deletable + (`git push --delete origin sdk/go/v0.1.0`), but once `proxy.golang.org` / + `sum.golang.org` have cached and checksummed it — which can happen within + seconds of the tag existing, by anyone's `go get`, not just this + maintainer's — the module version is permanently retrievable from the + proxy regardless of what happens to the tag in this repository. Treat the + tag push as **more** irreversible than the other two registries, not less. + +This is exactly why every command above that touches a real registry or the +real tag namespace is separated from its dry-run/verification counterpart, +and why no agent or script should run `twine upload`, `npm publish`, or +`git push` of a release tag without a human deliberately choosing to. diff --git a/sdk/README.md b/sdk/README.md index d968b62..552e5e9 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -27,3 +27,9 @@ cargo build --workspace --bins The companion `conformance-red.sh` proves the *suite* catches cheaters using the Rust fixture, so an SDK provider only has to be honest, not reimplement the misbehaviour modes. + +Conformant is a separate axis from **published**: see +[`PUBLISHING.md`](./PUBLISHING.md) for each SDK's registry status and the +release checklist. As of this writing only the TypeScript SDK is on a real +registry (npm); Python and Go are conformant but not yet installable outside +a checkout. diff --git a/sdk/go/README.md b/sdk/go/README.md index 3e2bddc..0f4e4df 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -11,6 +11,12 @@ the same conformance suite that judges the Rust reference provider. ## Install +> **Not yet published.** Go modules publish by tag, and that tag +> (`sdk/go/v0.1.0`) has not been cut yet, so the command below does not +> resolve — see [`sdk/PUBLISHING.md`](../PUBLISHING.md) for the publish +> checklist and current status. Until then, `require` a pseudo-version +> pinned to a commit SHA, or vendor from a checkout. + ```sh go get github.com/macanderson/context-graph-protocol/sdk/go/contextgraph ``` diff --git a/sdk/python/README.md b/sdk/python/README.md index b00e1fc..17defda 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -10,6 +10,11 @@ passes the same conformance suite that judges the Rust reference provider. ## Install +> **Not yet published to PyPI.** The command below does not resolve yet — see +> [`sdk/PUBLISHING.md`](../PUBLISHING.md) for the publish checklist and +> current status. Until then, install from a checkout: `pip install -e +> sdk/python` from the repository root. + ```sh pip install contextgraph-sdk ``` diff --git a/site/content/docs/implementing-a-provider.mdx b/site/content/docs/implementing-a-provider.mdx index c349b32..dc5fc81 100644 --- a/site/content/docs/implementing-a-provider.mdx +++ b/site/content/docs/implementing-a-provider.mdx @@ -175,3 +175,14 @@ optional test query, and shows you the frames it got back with their scores and token costs — a fast human-readable feedback loop before you run the scripted conformance suite. See [running-conformance.md](./running-conformance) for that next step. + +### Getting listed once you're green + +Once `contextgraph-inspect ... --json` reports every check `pass` (or `skip`, +never `fail`), your provider is eligible for the +[**conformance registry**](./registry) — a table of conformant providers with +a reproducible report backing each claim, plus the +`![CGP conformant](https://cgp.oxagen.sh/badges/conformant.svg)` badge you can +put in your own README once listed. Listing is a pull request, not a +self-attested form: see [registry.md](./registry#how-to-get-listed) for +exactly what to include. diff --git a/site/content/docs/index.mdx b/site/content/docs/index.mdx index edbd7af..0eaffd1 100644 --- a/site/content/docs/index.mdx +++ b/site/content/docs/index.mdx @@ -27,6 +27,9 @@ Reference documentation for the **Context Graph Protocol** crates: - [**Running conformance**](./running-conformance) — how to prove your provider (or host) is Context Graph Protocol conformant, via the `contextgraph-inspect` CLI or the `contextgraph-conformance` library. Start here to *verify* what you built. +- [**Conformance registry**](./registry) — providers that are Context Graph + Protocol conformant today, with a reproducible report backing each claim, + and how to get your own provider listed. - [**Stability**](./stability) — the crate-semver vs. protocol-version relationship, and what changes (and doesn't) as the protocol moves from `contextgraph/1.0-draft` to `contextgraph/1.0`. diff --git a/site/content/docs/meta.json b/site/content/docs/meta.json index 60c676e..a31bd2b 100644 --- a/site/content/docs/meta.json +++ b/site/content/docs/meta.json @@ -7,6 +7,7 @@ "protocol-advantages", "implementing-a-provider", "running-conformance", + "registry", "stability", "governance", "contributing", diff --git a/site/content/docs/registry.mdx b/site/content/docs/registry.mdx new file mode 100644 index 0000000..bf9e523 --- /dev/null +++ b/site/content/docs/registry.mdx @@ -0,0 +1,79 @@ +--- +title: "Conformance registry" +description: "Providers that are Context Graph Protocol conformant, with a reproducible report backing each claim, and how to get your own provider listed." +--- + +This page lists providers that are **Context Graph Protocol conformant** — green on +`contextgraph-conformance`'s suite for their declared capability set (see +[running-conformance.md](./running-conformance)) — with a reproducible, +checkable report backing the claim. It exists so "conformant" stays a +verifiable fact about a specific build, not a badge anyone can paste in. + +Listings here are also load-bearing for governance: the freeze from +`contextgraph/1.0-draft` to `contextgraph/1.0` requires **at least two +independent implementations** passing the suite +([governance.md](./governance#the-path-to-contextgraph10)). This registry is +where that count becomes checkable. + +## Conformant providers + +| 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 | 12/12 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 +prove the table and the submission flow work end to end. Third-party +providers land the same way, via the PR flow below. + +The listed report is a byte-for-byte capture of: + +```bash +cargo install contextgraph-conformance +cargo build -p contextgraph-conformance --bin contextgraph-example-docs +contextgraph-inspect stdio --json -- ./target/debug/contextgraph-example-docs +``` + +(Run from a checkout of this repository, since `contextgraph-example-docs` is +a dev-only fixture binary, not something published to crates.io — see the +`publish = true` override note in `contextgraph-conformance/Cargo.toml`.) + +## How to get listed + +There is no submission form and no self-attestation — a listing is a pull +request that a maintainer can independently re-run. + +1. **Run the suite against your provider** with `contextgraph-inspect ... --json` + (see [running-conformance.md](./running-conformance)) and confirm every + check is `pass` (a `skip` is fine — e.g. `malformed-input-tolerance` on an + HTTP or in-process target — a `fail` is not). +2. **Open a pull request** adding one row to the table above and, if it's + convenient to share, the JSON report file it links to. State the exact + command you ran — the PR template has a **Registry submission** checklist + item for this; a listing with no reproducible command attached will not be + merged. +3. **Add the badge** (optional, see below) to your own README once the PR + merges. + +A maintainer re-runs the check before merging. A listing that stops passing — +because the provider regressed or the protocol moved — gets a follow-up PR to +fix it or remove the row; this registry is a live claim, not a one-time +certificate. + +## The badge + +Once your provider has a merged row in the table above, you can put this in +your own README: + +```md +![CGP conformant](https://cgp.oxagen.sh/badges/conformant.svg) +``` + +which renders as: + +![CGP conformant](/badges/conformant.svg) + +The badge is a static, hand-authored asset — not a live third-party redirect — +so it never depends on this site's uptime and never phones home. It names the +protocol family the badge claims (`contextgraph/1.0-draft`), not a specific +provider version; the row in this table is what backs the specific claim. diff --git a/site/public/badges/conformant.svg b/site/public/badges/conformant.svg new file mode 100644 index 0000000..d77adb4 --- /dev/null +++ b/site/public/badges/conformant.svg @@ -0,0 +1,23 @@ + + CGP conformant: contextgraph/1.0-draft + + + + + + + + + + + + + + CGP conformant + CGP conformant + + + contextgraph/1.0-draft + contextgraph/1.0-draft + + diff --git a/site/public/registry/contextgraph-example-docs.report.json b/site/public/registry/contextgraph-example-docs.report.json new file mode 100644 index 0000000..0324d5f --- /dev/null +++ b/site/public/registry/contextgraph-example-docs.report.json @@ -0,0 +1,65 @@ +{ + "target": "stdio: ./target/debug/contextgraph-example-docs", + "checks": [ + { + "name": "handshake", + "status": "pass", + "evidence": "provider 'contextgraph-example-docs' v0.1.0 — data-flow reads=true writes=false egress=false; query kinds=[\"doc\", \"snippet\"], graph=true" + }, + { + "name": "consent-scope", + "status": "pass", + "evidence": "declared egress scopes [\"local-only\"] are well-formed and consistent with egress=false" + }, + { + "name": "frame-validity", + "status": "pass", + "evidence": "2 frame(s) — scores in [0,1], titles, citation labels, honest representations, RFC 3339 timestamps, well-formed digests, labelled and targeted relations" + }, + { + "name": "verify-honesty", + "status": "pass", + "evidence": "provider verified 2 unchanged frame(s) `valid` and all 2 mutated digest(s) `stale`, carrying no frame bodies" + }, + { + "name": "budget-honesty", + "status": "pass", + "evidence": "2 frame(s), 41 tokens within the 4096 budget; every declared cost matches its canonical count" + }, + { + "name": "as-of-temporal", + "status": "pass", + "evidence": "as_of=2026-07-01T00:00:00Z: none of the 1 returned frame(s) is dated after the pin" + }, + { + "name": "kinds-filter", + "status": "pass", + "evidence": "kinds=[doc]: all 1 returned frame(s) are of the requested kind (§Q1)" + }, + { + "name": "anchor-relevance", + "status": "pass", + "evidence": "anchored on `symbol:///docs/getting-started.md#overview`: provider returned 1 anchored frame(s) and ranked it first" + }, + { + "name": "shutdown-clean", + "status": "pass", + "evidence": "provider acknowledged shutdown and tore down cleanly" + }, + { + "name": "malformed-input-tolerance", + "status": "pass", + "evidence": "provider errored cleanly on malformed input and stayed alive: line was not a valid CGP envelope" + }, + { + "name": "embedding-fingerprint", + "status": "pass", + "evidence": "provider declares bge-small-en-v1.5/384/l2 (384-dim) and rejected a 1-dim embedding with `bad_request` (§E1)" + }, + { + "name": "correlation", + "status": "pass", + "evidence": "provider declares correlation and echoed the request id verbatim on its `frames` reply (§H4)" + } + ] +} diff --git a/site/public/schema/contextgraph-envelope.schema.json b/site/public/schema/contextgraph-envelope.schema.json index 488739a..7fdd2f2 100644 --- a/site/public/schema/contextgraph-envelope.schema.json +++ b/site/public/schema/contextgraph-envelope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://contextgraphprotocol.org/schema/contextgraph-envelope.schema.json", + "$id": "https://raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/contextgraph-envelope.schema.json", "title": "Context Graph Protocol envelope", "description": "A single Context Graph Protocol message \u2014 the unit exchanged on the wire (one object per NDJSON line over stdio, one request/response body over streamable HTTP). An envelope is an internally-tagged enum: the `type` field selects the variant and sits at the same level as the payload fields. Validate one message at a time against this root schema; validate individual payloads against the entries in `$defs`.", "$comment": "This schema is the AUTHORING-STRICT profile: `additionalProperties: false` throughout, so it catches typos in fixtures and reference messages. It is NOT the interop contract. Per SPEC.md \u00a713 U1, a receiver on the wire MUST ignore unrecognised members rather than reject the message \u2014 that is what lets a contextgraph/1.x minor add optional fields a 1.0 peer harmlessly drops. Do not use this schema to reject a peer's live message solely for carrying unknown members; use it to lint what you author.", From 52988bccccbc8b2b15ccfa09923e00b8dd284b10 Mon Sep 17 00:00:00 2001 From: macanderson Date: Wed, 29 Jul 2026 15:46:15 -0700 Subject: [PATCH 2/5] docs(spec): add normative Usage reports section, fix tokenizer_ref comment (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining #49 "survivors": - SPEC.md gains a normative §7.3 "Usage reports" (UR1): a host MUST be able to produce a usage report whose budget_consumed equals the summed token_cost of served frames, referencing them by FrameId — backed by the existing, tested contextgraph-host::FanOut::usage_report. Resolves the "U1" anchor collision with §13's ignore-unknown-members rule by labelling this UR1 across SPEC.md, docs/context-reuse.md, and docs/protocol-surface.md, and repointing §14's A1 cross-reference at §7.3. - Reword the schema canonical_token_cost $comment so tokenizer_ref pairs only with canonical_token_cost (the exact-count companion), never the byte-formula token_cost (§B3/§7.2) — resolving #50's tokenizer residual. Source and site schema copies stay byte-identical. schema/validate-examples.py green. Closes #49 Refs #50 Claude-Session: https://claude.ai/code/session_01Co9faUWdYC1SPqrof7njyD --- SPEC.md | 22 ++++++++++++++++++- docs/context-reuse.md | 2 +- docs/protocol-surface.md | 2 +- schema/contextgraph-envelope.schema.json | 2 +- .../schema/contextgraph-envelope.schema.json | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 73a9aeb..ee68610 100644 --- a/SPEC.md +++ b/SPEC.md @@ -427,6 +427,26 @@ budget. refinement — an optional handshake tokenizer id plus an optional exact count. It does not disturb the floor established here.)* +### 7.3 Usage reports + +Budget honesty (B1–B4) stops at the individual frame. A host that meters context +into a billing system — the usage-events → warehouse → invoice loop platforms +reselling agents run — needs the per-request roll-up, and every host inventing +that shape independently leaves context cost unauditable one level up from the +wire. A **usage report** is that roll-up: a host-side artifact, not a wire +envelope, whose total is pinned to the same byte-exact `token_cost` (B3) the +frames already carry, so the number a customer is billed is the number the +frames actually cost. + +| # | Requirement | Verified by | +| - | ----------- | ----------- | +| **UR1** | A host **MUST** be able to produce a usage report for any query it executed, whose `budget_consumed` equals the summed `token_cost` of the served frames it reports. The report **MUST** reference those frames by their `FrameId` (§6.3), so a billed total is walkable back to the exact `(provider id, frame id, content_digest)` triples behind it. | `contextgraph-host::FanOut::usage_report` | + +The full report shape and its warehouse/billing metering path are described in +the companion [`docs/context-reuse.md` §2](./docs/context-reuse.md). `UR1` is a +distinct rule from the extensibility `U1` of §13 (ignore-unknown-members); the +two share no anchor. + --- ## 8. Graph @@ -664,7 +684,7 @@ wrong one ("this frame was never cited" — it cost four). | # | Requirement | Verified by | | - | ----------- | ----------- | -| **A1** | A frame's attribution handle **is** its `FrameId` (§6.3) — the same `(provider id, frame id, content_digest)` triple used for composition, dedup, usage reports (§U1), and `verify` (§9). An implementation **MUST NOT** mint a separate attribution id. | `contextgraph-types::attribution` | +| **A1** | A frame's attribution handle **is** its `FrameId` (§6.3) — the same `(provider id, frame id, content_digest)` triple used for composition, dedup, usage reports (§7.3, UR1), and `verify` (§9). An implementation **MUST NOT** mint a separate attribution id. | `contextgraph-types::attribution` | | **A2** | A host reporting attribution **MUST** report `selected`, `rendered`, and `cited` as independent observations, not a single score. `cited` **MUST** mean the model's output referred to the frame, an observable fact — never an inference that the frame *influenced* the output. | `contextgraph-types::attribution` | | **A3** | An attribution record **MUST** be reconcilable: coherent (`cited` ⇒ `rendered` ⇒ `selected`) and naming a frame the paired usage report actually billed. | `AttributionReport::is_reconcilable` | diff --git a/docs/context-reuse.md b/docs/context-reuse.md index dd860c6..af9e81c 100644 --- a/docs/context-reuse.md +++ b/docs/context-reuse.md @@ -259,7 +259,7 @@ auditable from the wire all the way up to the invoice line. | # | Requirement | Enforced / verified by | | - | ----------- | ---------------------- | -| U1 | A host **MUST** be able to produce a usage report for any query it executed, whose `budget_consumed` equals the summed `token_cost` of the served frames it reports. | `FanOut::usage_report`; `usage_report` conformance case (drives the real fixture, re-sums independently) | +| UR1 | A host **MUST** be able to produce a usage report for any query it executed, whose `budget_consumed` equals the summed `token_cost` of the served frames it reports. | `FanOut::usage_report`; `usage_report` conformance case (drives the real fixture, re-sums independently) | --- diff --git a/docs/protocol-surface.md b/docs/protocol-surface.md index c530b19..d92a381 100644 --- a/docs/protocol-surface.md +++ b/docs/protocol-surface.md @@ -380,7 +380,7 @@ convenience. | - | ----------- | ---------------------- | | D1 | Frames sharing a `FrameId` **MUST** have identical content bytes; changing content **MUST** change `content_digest`. | provider contract; `verify` conformance check | | D2 | A host composing a frame set **MUST** emit frames in canonical `FrameId` order, independent of arrival order, and **MUST NOT** let `score`/`token_cost` affect the rendered bytes. | `contextgraph-host::compose_context` | -| U1 | A host **MUST** be able to produce a usage report for any query it executed, whose consumed total equals the summed `token_cost` of the served frames it reports. | `contextgraph-host::FanOut::usage_report`; `usage-report` conformance check | +| UR1 | A host **MUST** be able to produce a usage report for any query it executed, whose consumed total equals the summed `token_cost` of the served frames it reports. | `contextgraph-host::FanOut::usage_report`; `usage-report` conformance check | | C5 | A provider **MUST** declare its egress scopes (`egress_scopes`) truthfully and consistently with `data_flow.egress`; an off-machine scope alongside `egress: false` is a conformance failure. | `consent-scope` conformance check | | C6 | A host **MUST** reject a frame whose provider declares an egress scope with no live matching [consent receipt](./context-reuse.md#3-consent-scopes-and-receipts), with a typed error, before transmitting the query. | `ConsentStore` scope gate | | V1 | A provider advertising `verify` **MUST** answer honestly by comparing digests: `valid` when the presented digest matches what it currently serves, `stale` when it differs on a frame it still serves. It **MUST NOT** answer `valid` for content bytes it is not serving. | `verify-honesty` conformance check | diff --git a/schema/contextgraph-envelope.schema.json b/schema/contextgraph-envelope.schema.json index 7fdd2f2..63f405c 100644 --- a/schema/contextgraph-envelope.schema.json +++ b/schema/contextgraph-envelope.schema.json @@ -577,7 +577,7 @@ }, "canonical_token_cost": { "$ref": "#/$defs/u32", - "$comment": "Token cost of the complete canonical source content. If token_cost or canonical_token_cost is present, tokenizer_ref should name the tokenizer." + "$comment": "Token cost of the complete canonical source content, produced by an actual tokenizer (unlike the byte-formula token_cost, §B3/§7.2). If canonical_token_cost is present, tokenizer_ref SHOULD name the tokenizer that produced it." }, "tokenizer_ref": { "type": "string", "minLength": 1 }, "valid_from": { diff --git a/site/public/schema/contextgraph-envelope.schema.json b/site/public/schema/contextgraph-envelope.schema.json index 7fdd2f2..63f405c 100644 --- a/site/public/schema/contextgraph-envelope.schema.json +++ b/site/public/schema/contextgraph-envelope.schema.json @@ -577,7 +577,7 @@ }, "canonical_token_cost": { "$ref": "#/$defs/u32", - "$comment": "Token cost of the complete canonical source content. If token_cost or canonical_token_cost is present, tokenizer_ref should name the tokenizer." + "$comment": "Token cost of the complete canonical source content, produced by an actual tokenizer (unlike the byte-formula token_cost, §B3/§7.2). If canonical_token_cost is present, tokenizer_ref SHOULD name the tokenizer that produced it." }, "tokenizer_ref": { "type": "string", "minLength": 1 }, "valid_from": { From d57efcc724b6a64127a67cecef7d10df8da3ae72 Mon Sep 17 00:00:00 2001 From: macanderson Date: Wed, 29 Jul 2026 15:47:58 -0700 Subject: [PATCH 3/5] docs(spec): sketch the deferred context/neighbors 1.x operation (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph itself is already real and witnessed — §8 specifies graph frames, the open `rel` vocabulary, and the G1/G2/G3/G4 checks (G4's anchored predicate and its `anchor-relevance` check landed in #63/#64). The one remaining #7 acceptance box was the design sketch for multi-hop traversal. Adds docs/sketches/context-neighbors.md (a `context/neighbors { uri, rels, depth }` envelope pair as a post-1.0 additive minor, defined so `depth: 1` ≡ the G4 anchored set) following the docs/sketches/resolve.md template, and a §8.3 forward-reference in SPEC.md mirroring the §6.4.1 deferral pattern. No wire change — traversal beyond one hop is explicitly out of scope for the 1.0 freeze. Closes #7 Claude-Session: https://claude.ai/code/session_01Co9faUWdYC1SPqrof7njyD --- SPEC.md | 16 ++++++ docs/sketches/context-neighbors.md | 89 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 docs/sketches/context-neighbors.md diff --git a/SPEC.md b/SPEC.md index ee68610..aa495b6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -491,6 +491,22 @@ inventing `calls` / `call` / `code.call`: Provider-specific edges belong under their own namespace (`myindex.owns`), which keeps the shared namespace meaningful. +### 8.3 Multi-hop traversal is deferred + +**A wire operation for walking edges beyond one hop is not defined in +`contextgraph/1.0`.** G4 pins the one traversal semantics a suite can witness — +the zero-or-one-hop *anchored* predicate — and stops there. There is no +`neighbors` request in 1.0: a host receives frames with their edges from a +`query` and composes them; it never asks a provider to return a node's +neighborhood to a given depth. Freezing that operation now, with no host +emitting it, would reintroduce the dead-capability surface §8.2 and +[ADR 0004](./docs/adr/0004-dead-capability-surface.md) work to avoid. When a +concrete traversal consumer forces its design it can land as an additive minor, +gated on a new `capabilities.neighbors`, with `depth: 1` defined to return +exactly the G4 anchored set so nothing this freeze witnessed is invalidated. A +design sketch lives under +[`docs/sketches/context-neighbors.md`](./docs/sketches/context-neighbors.md). + --- ## 9. Verification diff --git a/docs/sketches/context-neighbors.md b/docs/sketches/context-neighbors.md new file mode 100644 index 0000000..167d99b --- /dev/null +++ b/docs/sketches/context-neighbors.md @@ -0,0 +1,89 @@ +# Sketch: `context/neighbors` (a post-1.0 additive minor) + +**Status:** not in `contextgraph/1.0`. This sketch keeps the door open so the +graph shapes can freeze now — `relations`, the `rel` vocabulary, and the G4 +*anchored* predicate all travel on the wire today — while the *operation* that +walks those edges beyond one hop lands later without a breaking change. See +[SPEC.md §8](../../SPEC.md) and the G3/G4 rows there. + +## Why it is deferred + +`contextgraph/1.0` freezes what a graph frame **is** (a node with labelled +edges, §8) and pins the one traversal semantics a suite can witness: G4's +*anchored* predicate — a frame is reachable from an anchor URI at zero hops (its +own `uri`) or one hop (any `relations[].target_uri`). That floor is deliberate. +It is decidable by string equality, so `anchor-relevance` can actually catch a +provider that ignores `anchors`, and it is a floor on what must be *found*, not +a ceiling on how far a provider may look internally. + +Multi-hop traversal *as a wire operation* is a different promise. There is no +cross-wire consumer of it in 1.0: the host fans a `query` out, receives frames +with their edges, and composes — it never asks a provider "give me the +neighborhood of this node to depth 3." Freezing a `neighbors` operation now, +with no host emitting it, would reintroduce exactly the dead-capability-surface +anti-pattern [ADR 0004](../adr/0004-dead-capability-surface.md) removed. Better +to ship the honest one-hop floor and add the operation when a concrete traversal +consumer (an agent walking a call graph, a "why is this here" impact query) +forces its design. + +## Shape it would take + +Two envelopes, correlated by `id` like `query`/`frames`: + +```jsonc +// host → provider +{ "type": "neighbors", "id": "n1", + "request": { + "uri": "symbol:///repo/src/host.rs#FanOut::compose", + "rels": ["code.calls", "code.references"], // optional filter; absent ⇒ all + "depth": 2, // hops from the seed node + "budget": 4000 // token budget, as on query + } } + +// provider → host +{ "type": "neighbored", "id": "n1", + "response": { + "seed": "symbol:///repo/src/host.rs#FanOut::compose", + "frames": [ /* ContextFrame[], same shape as `frames` */ ], + "truncated": false, + "dropped_estimate": 0 + } } +``` + +Design constraints it must honor: + +- **Built on G4, not beside it.** `depth: 1` with no `rels` filter **MUST** + return exactly the anchored set G4 already defines for that URI, so the + operation is a strict generalization of the predicate the suite pins in 1.0 — + not a second, subtly different notion of adjacency. +- **Bounded and honest.** `depth` and `budget` are hard caps. A provider that + can't return the full neighborhood within them **MUST** set `truncated: true` + and a `dropped_estimate`, reusing the B4 frame-flood discipline rather than + silently pruning — a traversal that hides what it dropped is a budget liar. +- **Cycle-safe.** Graphs have cycles; a node **MUST NOT** appear twice in + `frames`, and revisiting a node does not spend depth twice. Identity is the + `FrameId` triple (§6.3), so dedup is the same operation the host already does + when composing a fan-out. +- **Verifiable frames.** Returned frames carry `token_cost`, `content_digest`, + and provenance under the same rules as any `query` result (§7, §6.3) — a + neighborhood is not a privileged shape, just a differently-selected one. +- **Capability.** A new `capabilities.neighbors` gates it, and it **MUST** + co-require `capabilities.graph` (a provider with no edges has no neighbors to + walk). Advertising `neighbors` obligates answering it; a 1.0 provider that + declares only `graph` is unaffected because a 1.0 host never sends one. +- **Consent.** A `neighbors` call selects among content the provider already + indexes; like `query` it moves nothing new *about the workspace*, but if the + provider is an egress provider it may move source off-machine, so it rides the + same C-series consent gate as `query`. +- **Errors.** A seed `uri` the provider doesn't know answers `error` with a + `bad_request`-class code; exceeding a provider-internal traversal limit answers + with an `unavailable`-class code (open vocabulary, §10 X1). + +## Migration note + +Because 1.0 hosts never emit `neighbors`, adding these two envelopes is a clean +minor bump: a 1.0 provider that does not implement them is unaffected (it never +receives one), and a 1.x host discovers support through `capabilities.neighbors` +exactly as it discovers `verify` today. The `depth: 1` ≡ G4 identity above means +the freeze's one witnessed traversal rule survives verbatim into the richer +operation, so nothing a 1.0 suite asserted about anchoring is invalidated. From 2d08b4b00cb8b5a75401098bdd93c71cc7c98ba9 Mon Sep 17 00:00:00 2001 From: macanderson Date: Wed, 29 Jul 2026 16:36:19 -0700 Subject: [PATCH 4/5] feat(host): carry structured error codes across the transport boundary (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire already carried `code: Option`; nothing read it. This plumbs it end to end and tightens the conformance floor: - ErrorCode gains `unsupported_representation` (§P5) and `incompatible_version` (§H3), wired through as_str/From<&str>/reaction(). incompatible_version is permanent — a new HostReaction::DropProvider (the request is fine, the provider is unusable; distinct from DoNotRetry/Respawn/ReportAndCount). - HostError::Provider now carries `code`; the four http.rs/stdio.rs error arms pass it through instead of discarding it, so FanOut::failures() surfaces it. - The malformed-input-tolerance conformance check now passes only on a `bad_request` code (was: any Envelope::Error), per SPEC.md R1. A new `--misbehave mislabel-malformed` mode (answers `internal`) exercises the tightened check in conformance-red.sh, with a matching suite test. Gate green: fmt, clippy -D warnings, test --workspace, conformance-green (12/12), conformance-red (all misbehave modes caught). Closes #9 Claude-Session: https://claude.ai/code/session_01Co9faUWdYC1SPqrof7njyD --- .../src/bin/contextgraph-example-docs.rs | 14 +++++- contextgraph-conformance/src/lib.rs | 43 ++++++++++++++++--- .../tests/conformance_suite.rs | 14 ++++++ contextgraph-host/src/error.rs | 19 ++++++-- contextgraph-host/src/host.rs | 2 + contextgraph-host/src/http.rs | 6 ++- contextgraph-host/src/stdio.rs | 6 ++- contextgraph-types/src/error_code.rs | 37 +++++++++++++++- 8 files changed, 124 insertions(+), 17 deletions(-) diff --git a/contextgraph-conformance/src/bin/contextgraph-example-docs.rs b/contextgraph-conformance/src/bin/contextgraph-example-docs.rs index 87986bc..63a218e 100644 --- a/contextgraph-conformance/src/bin/contextgraph-example-docs.rs +++ b/contextgraph-conformance/src/bin/contextgraph-example-docs.rs @@ -49,6 +49,10 @@ enum Misbehave { /// Exit on receiving a malformed line (trips /// `malformed-input-tolerance`). CrashOnGarbage, + /// Stay alive on a malformed line but answer it with `internal` instead of + /// the `bad_request` §R1 recommends — a structured error that is not the + /// right one (trips `malformed-input-tolerance`). + MislabelMalformed, /// Declare a `token_cost` far below the canonical count for the content /// actually served (trips `budget-honesty` §B3). /// @@ -137,11 +141,19 @@ fn main() { if args.misbehave == Some(Misbehave::CrashOnGarbage) { std::process::exit(1); } + // §R1 recommends `bad_request`; `mislabel-malformed` answers + // with `internal` instead, to prove the malformed-input check + // now inspects the *code* rather than passing on any error. + let code = if args.misbehave == Some(Misbehave::MislabelMalformed) { + ErrorCode::Internal + } else { + ErrorCode::BadRequest + }; write_envelope( &mut stdout, &Envelope::Error { id: None, - code: Some(ErrorCode::BadRequest), + code: Some(code), message: "line was not a valid CGP envelope".into(), }, ); diff --git a/contextgraph-conformance/src/lib.rs b/contextgraph-conformance/src/lib.rs index fd5f19f..1bf68c2 100644 --- a/contextgraph-conformance/src/lib.rs +++ b/contextgraph-conformance/src/lib.rs @@ -30,8 +30,10 @@ //! the pinned instant (SPEC.md §6.1). SHOULD-strength and one-sided: a //! provider that returns fewer frames, or none, never fails it. //! - **shutdown-clean** — the provider tears down without error (SPEC.md §3). -//! - **malformed-input-tolerance** — a garbage line is ignored-or-errored, -//! never crashing the host (SPEC.md §10, task deliverable). Wire-level, so it +//! - **malformed-input-tolerance** — a garbage line is ignored, or errored with +//! code `bad_request`, never crashing the host (SPEC.md §R1). Staying alive is +//! the MUST; the structured `bad_request` code is the SHOULD this check now +//! inspects (#9), so an arbitrary error no longer passes. Wire-level, so it //! applies to stdio providers. //! - **embedding-fingerprint** — a provider declaring an //! `embeddings_fingerprint` rejects a query embedding whose length @@ -435,9 +437,13 @@ async fn check_verify_honesty( } /// Wire-level probe: complete the handshake on a fresh connection, inject a -/// malformed line, then send a valid query. A conforming provider ignores or -/// cleanly errors on the garbage and stays alive to answer the query; a -/// provider that dies on one bad line fails (SPEC.md §10). +/// malformed line, then send a valid query. A conforming provider either +/// ignores the garbage and answers the query, or errors on it with code +/// `bad_request` — and stays alive either way (SPEC.md §R1). A provider that +/// dies on one bad line fails; so, now, does one that stays alive but reports an +/// error *other* than `bad_request` — the code is read, not merely the fact of +/// an error (#9), so the check can tell a well-formed rejection from an +/// arbitrary failure. async fn malformed_stdio_probe(program: &str, args: &[String]) -> CheckResult { let mut conn = match RawStdioConnection::spawn(program, args).await { Ok(conn) => conn, @@ -477,9 +483,32 @@ async fn malformed_stdio_probe(program: &str, args: &[String]) -> CheckResult { CHECK_MALFORMED, "provider ignored a malformed line and still answered a valid query", ), - Ok(contextgraph_host::Envelope::Error { message, .. }) => CheckResult::pass( + // §R1's SHOULD: staying alive is the MUST, but a *structured* + // `bad_request` is what lets a host tell "your line was malformed" from + // an arbitrary failure. Inspecting the code (as the §E1 probe does) is + // the whole point of #9 — passing on any error would leave the code + // unread and the distinction unmade. + Ok(contextgraph_host::Envelope::Error { + code: Some(ErrorCode::BadRequest), + message, + .. + }) => CheckResult::pass( + CHECK_MALFORMED, + format!( + "provider errored cleanly on malformed input with `bad_request` and stayed alive: {message}" + ), + ), + // Alive, but the error is not the `bad_request` §R1 recommends (a + // different code, or none at all). The MUST is met; the SHOULD is not, + // and an unstructured failure is exactly what structured codes exist to + // replace — so this is flagged. + Ok(contextgraph_host::Envelope::Error { code, message, .. }) => CheckResult::fail( CHECK_MALFORMED, - format!("provider errored cleanly on malformed input and stayed alive: {message}"), + format!( + "provider stayed alive but answered malformed input with `{}` rather than the `bad_request` §R1 recommends: {message}", + code.map(|c| c.to_string()) + .unwrap_or_else(|| "no code".to_string()) + ), ), Ok(other) => CheckResult::fail( CHECK_MALFORMED, diff --git a/contextgraph-conformance/tests/conformance_suite.rs b/contextgraph-conformance/tests/conformance_suite.rs index 3d6389f..d3306cb 100644 --- a/contextgraph-conformance/tests/conformance_suite.rs +++ b/contextgraph-conformance/tests/conformance_suite.rs @@ -125,6 +125,20 @@ async fn crashing_on_garbage_fails_malformed_input_tolerance() { assert_eq!(status_of(&report, CHECK_MALFORMED), CheckStatus::Fail); } +#[tokio::test] +async fn mislabeling_malformed_input_fails_malformed_input_tolerance() { + // #9: staying alive is the §R1 MUST, but a structured `bad_request` is the + // SHOULD the check now inspects. A provider that answers a malformed line + // with `internal` (or any non-`bad_request` code, or none) is flagged — + // before, passing on "some error" left the code unread. + let report = run_conformance(target(&["--misbehave", "mislabel-malformed"])).await; + assert!(!report.passed()); + assert_eq!(status_of(&report, CHECK_MALFORMED), CheckStatus::Fail); + // The provider did not crash and the handshake was fine — only the SHOULD, + // the specific `bad_request` code, is what failed. + assert_eq!(status_of(&report, CHECK_HANDSHAKE), CheckStatus::Pass); +} + #[tokio::test] async fn an_incompatible_protocol_version_fails_the_handshake() { let report = run_conformance(target(&["--misbehave", "bad-version"])).await; diff --git a/contextgraph-host/src/error.rs b/contextgraph-host/src/error.rs index 243a811..0523ed9 100644 --- a/contextgraph-host/src/error.rs +++ b/contextgraph-host/src/error.rs @@ -6,7 +6,7 @@ //! light (`SPEC.md` §1 — depends only on `contextgraph-types` + transport //! crates). -use contextgraph_types::{DataFlow, EgressScope}; +use contextgraph_types::{DataFlow, EgressScope, ErrorCode}; /// Anything the host runtime can surface while talking to a provider. #[derive(Debug, thiserror::Error)] @@ -44,8 +44,21 @@ pub enum HostError { Timeout { id: String, timeout_ms: u64 }, /// The provider reported an error over the wire (an `error` envelope). - #[error("provider {id} reported an error: {message}")] - Provider { id: String, message: String }, + /// + /// `code` carries the structured [`ErrorCode`] the provider sent (#9) so it + /// survives the transport boundary instead of collapsing to a bare message; + /// a host can then key its reaction ([`ErrorCode::reaction`]) off the code + /// rather than sniffing the free-form string. `None` when the provider + /// declared no code — read it as [`ErrorCode::Internal`] per SPEC.md. + #[error( + "provider {id} reported an error{}: {message}", + .code.as_ref().map(|c| format!(" ({c})")).unwrap_or_default() + )] + Provider { + id: String, + code: Option, + message: String, + }, /// The provider declares `egress` and has no recorded consent, so the /// host refuses to transmit a query to it (`SPEC.md` diff --git a/contextgraph-host/src/host.rs b/contextgraph-host/src/host.rs index f8f03e5..8367945 100644 --- a/contextgraph-host/src/host.rs +++ b/contextgraph-host/src/host.rs @@ -799,6 +799,7 @@ mod tests { }), Behavior::Fail(message) => Err(HostError::Provider { id: self.id.clone(), + code: None, message: message.clone(), }), Behavior::Slow(duration) => { @@ -1275,6 +1276,7 @@ mod tests { if let Some(message) = &self.verify_error { return Err(HostError::Provider { id: self.id.clone(), + code: None, message: message.clone(), }); } diff --git a/contextgraph-host/src/http.rs b/contextgraph-host/src/http.rs index dc631a6..1e9de70 100644 --- a/contextgraph-host/src/http.rs +++ b/contextgraph-host/src/http.rs @@ -166,8 +166,9 @@ impl ContextProvider for HttpProvider { verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?; Ok(result) } - Envelope::Error { message, .. } => Err(HostError::Provider { + Envelope::Error { message, code, .. } => Err(HostError::Provider { id: self.id.clone(), + code, message, }), other => Err(HostError::UnexpectedEnvelope { @@ -190,8 +191,9 @@ impl ContextProvider for HttpProvider { .await?; match reply { Envelope::Verified { response } => Ok(response), - Envelope::Error { message, .. } => Err(HostError::Provider { + Envelope::Error { message, code, .. } => Err(HostError::Provider { id: self.id.clone(), + code, message, }), other => Err(HostError::UnexpectedEnvelope { diff --git a/contextgraph-host/src/stdio.rs b/contextgraph-host/src/stdio.rs index 11182aa..e3dfb63 100644 --- a/contextgraph-host/src/stdio.rs +++ b/contextgraph-host/src/stdio.rs @@ -379,8 +379,9 @@ impl ContextProvider for StdioProvider { verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?; Ok(result) } - Envelope::Error { message, .. } => Err(HostError::Provider { + Envelope::Error { message, code, .. } => Err(HostError::Provider { id: self.id.clone(), + code, message, }), other => Err(HostError::UnexpectedEnvelope { @@ -399,8 +400,9 @@ impl ContextProvider for StdioProvider { .await?; match conn.recv().await? { Envelope::Verified { response } => Ok(response), - Envelope::Error { message, .. } => Err(HostError::Provider { + Envelope::Error { message, code, .. } => Err(HostError::Provider { id: self.id.clone(), + code, message, }), other => Err(HostError::UnexpectedEnvelope { diff --git a/contextgraph-types/src/error_code.rs b/contextgraph-types/src/error_code.rs index 6c0f51d..825c5c7 100644 --- a/contextgraph-types/src/error_code.rs +++ b/contextgraph-types/src/error_code.rs @@ -30,8 +30,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub enum HostReaction { /// The request itself was wrong. Retrying it unchanged will fail again. DoNotRetry, - /// The provider does not serve what was asked for. Narrow the query's - /// `kinds`, or stop querying this provider for them. + /// The provider does not serve exactly what was asked for. Adjust the + /// request — narrow the query's `kinds`, or downgrade + /// `representation_preferences` to `full` — or stop querying this provider + /// for it. NarrowOrSkip, /// No useful frame fits the stated budget. Raise `max_tokens` or skip. RaiseBudgetOrSkip, @@ -39,6 +41,12 @@ pub enum HostReaction { RetryWithBackoff, /// The provider is tearing down. Re-spawn it or drop it from the fan-out. Respawn, + /// The provider is permanently unusable — e.g. a handshake version family + /// that shares no major with the host (`SPEC.md` §H3). Drop it from the + /// fan-out; retrying cannot help, and it is not a health blip to count and + /// keep. Distinct from [`DoNotRetry`](Self::DoNotRetry) (there the *request* + /// was wrong) and [`Respawn`](Self::Respawn) (there a retry could succeed). + DropProvider, /// A provider fault. Report it and count it against the provider's health. ReportAndCount, } @@ -53,6 +61,14 @@ pub enum ErrorCode { BadRequest, /// The requested frame kinds are not served by this provider. UnsupportedKind, + /// The host asked for a representation the provider did not advertise in + /// `capabilities.representations` (`SPEC.md` §P5). The host should + /// re-request `full` or skip the provider. + UnsupportedRepresentation, + /// The handshake version families do not share a major, so the peers cannot + /// interoperate (`SPEC.md` §H3). Permanent — a host **MUST NOT** read it as + /// retryable — so it maps to [`HostReaction::DropProvider`], never a retry. + IncompatibleVersion, /// The budget is too small for any meaningful frame. BudgetUnsatisfiable, /// Transient overload, or a backing store is down. @@ -73,6 +89,8 @@ impl ErrorCode { match self { Self::BadRequest => "bad_request", Self::UnsupportedKind => "unsupported_kind", + Self::UnsupportedRepresentation => "unsupported_representation", + Self::IncompatibleVersion => "incompatible_version", Self::BudgetUnsatisfiable => "budget_unsatisfiable", Self::Unavailable => "unavailable", Self::ShuttingDown => "shutting_down", @@ -87,6 +105,8 @@ impl ErrorCode { match self { Self::BadRequest => HostReaction::DoNotRetry, Self::UnsupportedKind => HostReaction::NarrowOrSkip, + Self::UnsupportedRepresentation => HostReaction::NarrowOrSkip, + Self::IncompatibleVersion => HostReaction::DropProvider, Self::BudgetUnsatisfiable => HostReaction::RaiseBudgetOrSkip, Self::Unavailable => HostReaction::RetryWithBackoff, Self::ShuttingDown => HostReaction::Respawn, @@ -113,6 +133,8 @@ impl From<&str> for ErrorCode { match raw { "bad_request" => Self::BadRequest, "unsupported_kind" => Self::UnsupportedKind, + "unsupported_representation" => Self::UnsupportedRepresentation, + "incompatible_version" => Self::IncompatibleVersion, "budget_unsatisfiable" => Self::BudgetUnsatisfiable, "unavailable" => Self::Unavailable, "shutting_down" => Self::ShuttingDown, @@ -154,6 +176,8 @@ mod tests { let codes = [ ErrorCode::BadRequest, ErrorCode::UnsupportedKind, + ErrorCode::UnsupportedRepresentation, + ErrorCode::IncompatibleVersion, ErrorCode::BudgetUnsatisfiable, ErrorCode::Unavailable, ErrorCode::ShuttingDown, @@ -202,7 +226,16 @@ mod tests { assert!(!ErrorCode::BadRequest.is_retryable()); assert!(!ErrorCode::UnsupportedKind.is_retryable()); + assert!(!ErrorCode::UnsupportedRepresentation.is_retryable()); assert!(!ErrorCode::BudgetUnsatisfiable.is_retryable()); assert!(!ErrorCode::Internal.is_retryable()); + + // §H3: a version-family mismatch is permanent — the host drops the + // provider rather than retrying it. + assert!(!ErrorCode::IncompatibleVersion.is_retryable()); + assert_eq!( + ErrorCode::IncompatibleVersion.reaction(), + HostReaction::DropProvider + ); } } From a84682edfbe2f3ab4997133f532f782b06fba6ca Mon Sep 17 00:00:00 2001 From: macanderson Date: Wed, 29 Jul 2026 16:45:32 -0700 Subject: [PATCH 5/5] feat(host): enforce C7/C8 in the reference HTTP transport (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C7/C8 were specified (§4.2) but listed as a live enforcement gap (§11.1). This implements them in the reference host: - C7 (TLS for non-loopback): HttpProvider refuses a plaintext http:// target to any non-loopback host with HostError::InsecureTransport, BEFORE the client is built or DNS resolves. Loopback (localhost / 127.0.0.0/8 / [::1]) stays exempt so the wiremock suite keeps working. - C8 (credentials never logged): a new Credential type whose Debug AND Display both render only "Credential()" (secret reachable only via a crate-private expose()); attached via reqwest bearer_auth, never a format string. A redaction test asserts no HostError/format string leaks the secret. - connect_with_auth / Host::add_http take an optional Credential (connect stays as a back-compat None wrapper); a 401 surfaces as HostError::Unauthorized. - SPEC.md §11.1 updated: C7/C8 now enforced + unit-tested at the transport-refusal/redaction level; full live-TLS-peer conformance remains the stated next increment (unchanged). Gate green: fmt, clippy -D warnings, test (119 host + 4 new), conformance green/red, schema validate. wiremock was already a dev-dep. Closes #13 Claude-Session: https://claude.ai/code/session_01Co9faUWdYC1SPqrof7njyD --- SPEC.md | 19 +- .../src/bin/contextgraph-inspect.rs | 2 +- contextgraph-conformance/src/lib.rs | 2 +- contextgraph-host/src/error.rs | 17 ++ contextgraph-host/src/host.rs | 9 +- contextgraph-host/src/http.rs | 286 +++++++++++++++++- contextgraph-host/src/lib.rs | 2 +- 7 files changed, 314 insertions(+), 23 deletions(-) diff --git a/SPEC.md b/SPEC.md index aa495b6..3c22a9b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -620,11 +620,20 @@ fence. Run it: `contextgraph-inspect host` (CI: `host-conformance.sh`). What remains genuinely unchecked: -- **C4, C7, C8 — the HTTP transport rules.** Treating every non-loopback - provider as egress (C4), requiring TLS (C7), and never logging credentials - (C8) are properties of the host's HTTP client; exercising them needs a real - non-loopback, TLS network peer the in-process harness cannot stand up. They - remain the host-side harness's next increment. +- **C4, C7, C8 — the HTTP transport rules.** These bind the host's HTTP client. + **C7 (TLS for non-loopback) and C8 (credentials never logged) are now enforced + and unit-tested in the reference host** (issue #13): the transport refuses a + plaintext `http://` connection to a non-loopback provider with a typed + `HostError::InsecureTransport` *before any bytes leave the host*, keeps the + loopback `http://` exception, attaches a bearer credential via reqwest's + `bearer_auth` rather than a format string, and renders every `Credential` as a + fixed `Credential()` placeholder in both `Debug` and `Display` so it + cannot spill into a log or a panic — each covered by a `contextgraph-host` unit + test. What remains genuinely unchecked is full *live-TLS-peer* conformance: + exercising the handshake, TLS negotiation, and credential exchange end-to-end + against a real non-loopback TLS peer — and witnessing C4's treat-as-egress + override over that same peer — needs a network peer the in-process harness + cannot stand up, and stays the host-side harness's next increment. - **R3 breakout-resistance is now escaping, not an unguessable fence.** The reference `compose_context` neutralizes a content-embedded `` token and escapes fence attributes, so content cannot terminate the block that diff --git a/contextgraph-conformance/src/bin/contextgraph-inspect.rs b/contextgraph-conformance/src/bin/contextgraph-inspect.rs index a3bf957..0676e2e 100644 --- a/contextgraph-conformance/src/bin/contextgraph-inspect.rs +++ b/contextgraph-conformance/src/bin/contextgraph-inspect.rs @@ -127,7 +127,7 @@ async fn interactive_probe(descriptor: &Descriptor, query_goal: Option<&str>) { let id = "provider"; let added = match descriptor { Descriptor::Stdio { program, args } => host.add_stdio(id, program, args).await, - Descriptor::Http { url } => host.add_http(id, url.clone()).await, + Descriptor::Http { url } => host.add_http(id, url.clone(), None).await, }; match added { diff --git a/contextgraph-conformance/src/lib.rs b/contextgraph-conformance/src/lib.rs index 1bf68c2..82531d0 100644 --- a/contextgraph-conformance/src/lib.rs +++ b/contextgraph-conformance/src/lib.rs @@ -212,7 +212,7 @@ async fn build_host( } ProviderTarget::Http { url } => { let id = "provider-under-test".to_string(); - host.add_http(id.clone(), url).await?; + host.add_http(id.clone(), url, None).await?; capture_identity(&host, &id)? } ProviderTarget::InProcess(provider) => { diff --git a/contextgraph-host/src/error.rs b/contextgraph-host/src/error.rs index 0523ed9..26b6478 100644 --- a/contextgraph-host/src/error.rs +++ b/contextgraph-host/src/error.rs @@ -33,6 +33,23 @@ pub enum HostError { #[error("transport error talking to provider {id}: {message}")] Transport { id: String, message: String }, + /// The host refused to open a plaintext (`http://`) transport to a + /// non-loopback provider (`SPEC.md` §4.2, **C7**): the query payload — and + /// any bearer credential — would cross the network in cleartext. Raised + /// **before** any bytes are sent, so nothing left the host. The message + /// names only the id and host — never a credential (C8). + #[error( + "refusing an insecure (plaintext http) transport to non-loopback provider {id} at host `{host}`: TLS is required for any non-loopback provider (C7)" + )] + InsecureTransport { id: String, host: String }, + + /// The provider rejected the host's bearer credential (`HTTP 401`). Distinct + /// from a bare [`Transport`](Self::Transport) failure so a host can react to + /// an auth rejection specifically. The message names only the id and the + /// status — never the credential itself (`SPEC.md` §4.2, **C8**). + #[error("provider {id} rejected the host credential (HTTP 401 Unauthorized)")] + Unauthorized { id: String }, + /// The provider's child process closed its stream mid-exchange — it /// crashed. Isolated to this provider; never poisons a `query_all` /// (task deliverable 5). diff --git a/contextgraph-host/src/host.rs b/contextgraph-host/src/host.rs index 8367945..28e41a3 100644 --- a/contextgraph-host/src/host.rs +++ b/contextgraph-host/src/host.rs @@ -82,12 +82,19 @@ impl Host { } /// Connect and register a remote HTTP provider, completing the handshake. + /// + /// `credential` is an optional bearer [`Credential`](crate::http::Credential) + /// attached to every request; pass `None` for an unauthenticated provider. + /// A plaintext (`http://`) transport to a non-loopback provider is refused + /// before any bytes leave the host ([`HostError::InsecureTransport`], C7), + /// and the credential is never logged (C8). pub async fn add_http( &mut self, id: impl Into, url: impl Into, + credential: Option, ) -> Result<(), HostError> { - let provider = crate::http::HttpProvider::connect(id, url).await?; + let provider = crate::http::HttpProvider::connect_with_auth(id, url, credential).await?; self.providers.push(Box::new(provider)); Ok(()) } diff --git a/contextgraph-host/src/http.rs b/contextgraph-host/src/http.rs index 1e9de70..f9a9d68 100644 --- a/contextgraph-host/src/http.rs +++ b/contextgraph-host/src/http.rs @@ -9,6 +9,8 @@ //! its `egress` posture is decided by the URL host and gated through the same //! [`crate::consent`] store at the [`crate::host::Host`] layer. +use std::fmt; +use std::net::IpAddr; use std::time::Duration; use async_trait::async_trait; @@ -26,6 +28,96 @@ use crate::wire::{ /// Total per-request budget for an HTTP exchange (handshake or query). const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +/// A bearer credential a host uses to authenticate to a remote provider. +/// +/// The secret is **never** rendered: both [`Debug`](fmt::Debug) and +/// [`Display`](fmt::Display) print the fixed placeholder `Credential()`, +/// so a credential that reaches a log line, an `{:?}`/`{}` interpolation, or a +/// panic payload cannot spill its bytes (`SPEC.md` §4.2, **C8**). The only way +/// to read the raw value is [`Credential::expose`], a crate-private method used +/// solely to attach the header on the wire — a leak is therefore greppable. +#[derive(Clone)] +pub struct Credential { + /// The bearer token / `Authorization` value. Deliberately unexposed to any + /// formatting impl. + token: String, +} + +impl Credential { + /// Wrap a bearer token. It is attached as `Authorization: Bearer ` + /// on every request this provider sends and is never logged (C8). + pub fn bearer(token: impl Into) -> Self { + Self { + token: token.into(), + } + } + + /// The raw secret — the single, greppable exit point, used only to set the + /// `Authorization` header on the wire. + fn expose(&self) -> &str { + &self.token + } +} + +/// C8: a credential in a `{:?}` rendering (a log line, a panic payload) prints a +/// fixed placeholder, never its bytes. +impl fmt::Debug for Credential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Credential()") + } +} + +/// C8: a credential in a `{}` rendering prints the same fixed placeholder — so +/// even an accidental `Display` interpolation cannot leak the secret. +impl fmt::Display for Credential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Credential()") + } +} + +/// Whether a URL host component names the loopback interface — the one case an +/// unencrypted (`http://`) transport is allowed, because the bytes never leave +/// the machine (`SPEC.md` §4.2, **C7**). Mirrors the `localhost` exception +/// [`verify::file_uri_to_path`](crate::verify) makes for `file://`, widened to +/// the loopback IP ranges: the literal name `localhost`, `127.0.0.0/8`, and +/// `::1`. IPv6 hosts arrive bracketed (`[::1]`) from a URL, so the brackets are +/// stripped before parsing. +fn is_loopback_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + let bare = host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .unwrap_or(host); + // `IpAddr::is_loopback` is exactly `127.0.0.0/8` for v4 and `::1` for v6. + matches!(bare.parse::(), Ok(ip) if ip.is_loopback()) +} + +/// Refuse a plaintext transport to a non-loopback provider **before** any bytes +/// leave the host (`SPEC.md` §4.2, **C7**): an `http://` (not `https://`) URL +/// whose host is not loopback would carry the query payload — and any bearer +/// credential — across the network in cleartext. A loopback `http://` target is +/// allowed (the bytes never leave the machine); every `https://` target is +/// allowed. Called before the client is built or DNS is resolved, so a refusal +/// short-circuits with zero network activity. +fn refuse_insecure_transport(id: &str, url: &str) -> Result<(), HostError> { + let parsed = reqwest::Url::parse(url).map_err(|e| HostError::Transport { + id: id.to_string(), + message: format!("invalid provider url: {e}"), + })?; + if parsed.scheme() == "http" { + let host = parsed.host_str().unwrap_or(""); + if !is_loopback_host(host) { + return Err(HostError::InsecureTransport { + id: id.to_string(), + host: host.to_string(), + }); + } + } + Ok(()) +} + /// A [`ContextProvider`] backed by a remote HTTP endpoint. Handshakes once on /// [`HttpProvider::connect`] and caches the negotiated identity + capabilities. pub struct HttpProvider { @@ -34,15 +126,36 @@ pub struct HttpProvider { client: reqwest::Client, info: ProviderInfo, capabilities: Capabilities, + /// Bearer credential attached to every request, if the provider requires + /// one. Redacted from every rendering (C8). + credential: Option, } impl HttpProvider { - /// Connect to a remote provider: POST a `handshake`, expect a compatible - /// `handshake_ack`, and cache its identity + capabilities. `id` is the - /// host-facing routing/consent key. + /// Connect to a remote provider with no credential — a thin back-compat + /// wrapper over [`connect_with_auth`](Self::connect_with_auth). POST a + /// `handshake`, expect a compatible `handshake_ack`, and cache its identity + /// + capabilities. `id` is the host-facing routing/consent key. pub async fn connect(id: impl Into, url: impl Into) -> Result { + Self::connect_with_auth(id, url, None).await + } + + /// Connect to a remote provider, optionally attaching a bearer + /// [`Credential`] to every request. Enforces transport security before any + /// bytes leave the host: a plaintext (`http://`) transport to a non-loopback + /// provider is refused with [`HostError::InsecureTransport`], so neither the + /// handshake nor a credential ever crosses the network in cleartext + /// (`SPEC.md` §4.2, **C7**). + pub async fn connect_with_auth( + id: impl Into, + url: impl Into, + credential: Option, + ) -> Result { let id = id.into(); let url = url.into(); + // C7 first, before the client is built or DNS is resolved: a refusal + // must short-circuit with zero network activity so no payload leaks. + refuse_insecure_transport(&id, &url)?; let client = reqwest::Client::builder() .timeout(HTTP_TIMEOUT) .build() @@ -58,6 +171,7 @@ impl HttpProvider { protocol_version: PROTOCOL_VERSION.to_string(), }, &id, + credential.as_ref(), ) .await?; @@ -89,6 +203,7 @@ impl HttpProvider { client, info, capabilities, + credential, }) } other => Err(HostError::UnexpectedEnvelope { @@ -103,21 +218,32 @@ impl HttpProvider { /// POST one envelope to the provider URL and decode the response as one /// envelope. A non-2xx status or a non-envelope body is a clean named error, /// never a panic (task deliverable 5). +/// +/// When `credential` is present it is attached as `Authorization: Bearer …` via +/// reqwest's [`bearer_auth`](reqwest::RequestBuilder::bearer_auth) — never a +/// format string that could leak the secret into a log (C8). async fn post_envelope( client: &reqwest::Client, url: &str, env: &Envelope, id: &str, + credential: Option<&Credential>, ) -> Result { - let response = client - .post(url) - .json(env) - .send() - .await - .map_err(|e| HostError::Transport { - id: id.to_string(), - message: e.to_string(), - })?; + let mut request = client.post(url).json(env); + if let Some(credential) = credential { + request = request.bearer_auth(credential.expose()); + } + let response = request.send().await.map_err(|e| HostError::Transport { + id: id.to_string(), + message: e.to_string(), + })?; + + // A rejected credential is its own named error, distinct from any other + // transport failure — and it names only the id + status, never the + // credential (C8). + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(HostError::Unauthorized { id: id.to_string() }); + } if !response.status().is_success() { let status = response.status(); @@ -159,6 +285,7 @@ impl ContextProvider for HttpProvider { query: query.clone(), }, &self.id, + self.credential.as_ref(), ) .await?; match reply { @@ -187,6 +314,7 @@ impl ContextProvider for HttpProvider { request: request.clone(), }, &self.id, + self.credential.as_ref(), ) .await?; match reply { @@ -206,7 +334,14 @@ impl ContextProvider for HttpProvider { async fn shutdown(&self) -> Result<(), HostError> { // Best-effort teardown notice; a remote endpoint is not ours to reap. - let _ = post_envelope(&self.client, &self.url, &Envelope::Shutdown, &self.id).await; + let _ = post_envelope( + &self.client, + &self.url, + &Envelope::Shutdown, + &self.id, + self.credential.as_ref(), + ) + .await; Ok(()) } } @@ -216,7 +351,7 @@ mod tests { use super::*; use contextgraph_types::capability::QueryCapability; use contextgraph_types::{ContextFrame, DataFlow, FrameKind}; - use wiremock::matchers::method; + use wiremock::matchers::{header, method}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn ack_body(version: &str) -> serde_json::Value { @@ -403,4 +538,127 @@ mod tests { "an HTTP provider must always require consent, even claiming egress:false" ); } + + // ---- transport security (§4.2, C7/C8) ---- + + #[tokio::test] + async fn a_plaintext_non_loopback_transport_is_refused_before_any_bytes_leave() { + // C7: an `http://` (not `https://`) URL whose host is not loopback is + // refused BEFORE a client is built or DNS is resolved — the query + // payload and any credential must never cross the network in cleartext. + // The proof it short-circuits is the error *kind*: a real network + // attempt to this host would surface as a `Transport` (connect) error, + // never `InsecureTransport`. + let err = match HttpProvider::connect("remote", "http://example.com:9/cgp").await { + Ok(_) => panic!("a plaintext non-loopback transport must be refused (C7)"), + Err(e) => e, + }; + match err { + HostError::InsecureTransport { id, host } => { + assert_eq!(id, "remote"); + assert_eq!(host, "example.com"); + } + other => panic!("expected InsecureTransport, got {other:?}"), + } + } + + #[tokio::test] + async fn a_plaintext_loopback_transport_is_allowed() { + // The C7 loopback exception: wiremock serves plain `http://` on + // `127.0.0.1`, and the host must NOT refuse it — the bytes never leave + // the machine. This is also what keeps every other wiremock test in this + // module (all on 127.0.0.1) working. + let server = MockServer::start().await; + assert!( + server.uri().starts_with("http://"), + "wiremock serves plaintext http on loopback" + ); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION))) + .mount(&server) + .await; + let provider = HttpProvider::connect("remote", server.uri()) + .await + .expect("a plaintext loopback (127.0.0.1) transport is allowed"); + assert_eq!(provider.info().name, "remote-docs"); + } + + #[tokio::test] + async fn a_supplied_credential_is_attached_as_a_bearer_header() { + const TOKEN: &str = "s3cr3t-bearer-token-value"; + let server = MockServer::start().await; + let auth_value = format!("Bearer {TOKEN}"); + // The mock only matches when the `Authorization` header is present and + // exact. If the header were missing (or mangled), no mock matches, + // wiremock 404s, and the handshake/query below fail — so a green test + // proves the bearer credential was attached on the wire. + Mock::given(method("POST")) + .and(header("authorization", auth_value.as_str())) + .respond_with(|req: &wiremock::Request| { + let body = match serde_json::from_slice::(&req.body) { + Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION), + Ok(Envelope::Query { .. }) => frames_body(), + _ => serde_json::to_value(Envelope::Error { + id: None, + code: None, + message: "unexpected request".into(), + }) + .unwrap(), + }; + ResponseTemplate::new(200).set_body_json(body) + }) + .mount(&server) + .await; + + let provider = HttpProvider::connect_with_auth( + "remote", + server.uri(), + Some(Credential::bearer(TOKEN)), + ) + .await + .expect("handshake carries the bearer credential"); + // The query carries it too — the same header matcher gates its response. + let result = provider.query(&sample_query()).await.expect("query ok"); + assert_eq!(result.frames.len(), 1); + } + + #[test] + fn a_credential_is_redacted_in_every_rendering_and_never_in_an_error() { + // C8: the secret must not appear in any `{:?}`/`{}` rendering — a + // credential that reaches a log line or a panic payload prints a fixed + // placeholder, not its bytes. + const SECRET: &str = "ghp_this_must_never_appear_in_a_log_0xDEADBEEF"; + let credential = Credential::bearer(SECRET); + + let debug = format!("{credential:?}"); + let display = format!("{credential}"); + assert_eq!(debug, "Credential()"); + assert_eq!(display, "Credential()"); + assert!( + !debug.contains(SECRET), + "Debug must not leak the secret (C8)" + ); + assert!( + !display.contains(SECRET), + "Display must not leak the secret (C8)" + ); + // Cloning preserves redaction — a duplicated credential still can't leak. + assert_eq!( + format!("{:?}", credential.clone()), + "Credential()" + ); + + // No `HostError` carries credential material: the auth-related variants + // render only id/host/status, so a secret can never reach a surfaced + // error string (C8). + let insecure = HostError::InsecureTransport { + id: "remote".into(), + host: "example.com".into(), + }; + let unauthorized = HostError::Unauthorized { + id: "remote".into(), + }; + assert!(!insecure.to_string().contains(SECRET)); + assert!(!unauthorized.to_string().contains(SECRET)); + } } diff --git a/contextgraph-host/src/lib.rs b/contextgraph-host/src/lib.rs index 4901d5d..adc9427 100644 --- a/contextgraph-host/src/lib.rs +++ b/contextgraph-host/src/lib.rs @@ -74,7 +74,7 @@ pub use error::HostError; pub use host::{ DropReason, DroppedFrame, FanOut, Host, ProviderOutcome, ProviderResult, VerifyOutcome, }; -pub use http::HttpProvider; +pub use http::{Credential, HttpProvider}; pub use ingest::{ IngestBundle, IngestConfig, IngestProvider, PasteIngest, SegmentKind, SegmentOutcome, SegmentReport, ingest_paste,