Skip to content

ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest - #106

Merged
macanderson merged 2 commits into
mainfrom
fix/ci-sdk-guards-b726a4bc
Aug 30, 2026
Merged

ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest#106
macanderson merged 2 commits into
mainfrom
fix/ci-sdk-guards-b726a4bc

Conversation

@macanderson

@macanderson macanderson commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Pull request

Summary

Two guards over sdk/: CI now typechecks the two SDKs that carry type
annotations, and a new offline check catches a version pin that has drifted
from the manifest it names.

Closes #94, Closes #98

What changed

Typechecking (#94)

  • sdk (typescript) typechecksnpm ci && npx tsc --noEmit in
    sdk/typescript/, in a job that pulls no Rust toolchain. npm ci so the
    compiler comes from package-lock.json (typescript 5.9.3) rather than from
    whatever ^5.9.0 resolves to today.
  • sdk (python) typechecksmypy --strict --python-version 3.10 over
    contextgraph_sdk and examples, with mypy pinned for the same reason.

The Python half was not skipped, and here is the reasoning. The Python SDK
ships py.typed. That file is a promise to every downstream typechecker that
the package's annotations are meant to be believed, and nothing in this repo
had ever tested it. --strict because a py.typed package that leaks Any
across its own boundary keeps the promise to its callers and not to itself. It
is clean on this tree as written — no code changes were needed.

One correction to #94's premise, stated because the issue's framing is what
sized the work.
"Nothing in CI appears to run tsc --noEmit" is right about
--noEmit and not quite right about typechecking: sdk (typescript) is a conformant implementation runs npm run build, which is tsc, and tsc
exits non-zero on a type error, so that job would have failed. The coverage was
incidental rather than absent. It is still worth its own job: it arrives only
after a full cargo build --workspace --bins that has nothing to do with the
question, and it holds only while the build script stays tsc. The new job
asks the question directly and in about a minute.

Version-pin drift (#98)

  • .github/scripts/check-sdk-version-pins.py — stdlib only, offline, no
    registry call. Same shape as check-deploy-hygiene.py: a check(label, ok)
    ledger, PASS/FAIL per line, a remedy under each failure.
  • sdk version pins name a version their manifest ships — the CI job that runs
    it, separate from the scaffold job for the reason the issue gives.
  • docs/adr/0012-sdk-version-pins-share-a-major.md — the rule and why.

The decision the issue asked for: same major, not equality. The pins are
deliberately ranges and differently shaped ones — a caret for npm, a >= floor
for pip. Equality would make every SDK patch release an edit in the scaffolder
that nobody would remember to make, which defeats the range's whole purpose. A
major is the thing a scaffold cannot survive: majors are where the API changes,
and ADR 0011 is the worked example — FrameKind widened in 2.0.0, so a
project scaffolded against a 1.x pin generates code against the vocabulary
that release retired. Same major is the weakest rule that still catches every
break, which is what a guard should be; a stricter one fails on changes that
are correct.

Two things ride along with it, both because the pin rule leans on them:

  • A floor may not run ahead of the manifest. ^2.1.0 against a shipped
    2.0.0 resolves to nothing at all — drift in the other direction, and the
    manifest version is the newest release that can exist, because these
    manifests are what publish-sdks.yml publishes.
  • The SDKs and the crates share one major. The scaffolder's own comment and
    MIGRATION.md §5.4 both assert this, and ^2.0.0 protects a scaffold from
    ADR 0011 only while it holds. Its practical effect: a major bump is one
    commit across four files rather than four commits with a drift window in
    between.

The other manifests the issue named. sdk/go carries no package version —
a Go module is versioned by its git tag, not by a field in go.mod, and the
scaffolder emits no Go template, so there is no in-tree pin to compare against;
its ProtocolVersion is a protocol version, a different axis. The
"version": "1.0.0" strings in schema/reference-vectors.ndjson are provider
versions in fixtures and are untouched — rewriting them would change the bytes
the reference vectors pin. Both verdicts are recorded in the script's header
and in the ADR, so the next reader does not re-derive them.

Two drive-by fixes, both one line, both about a name a manifest does not
own:

  • MIGRATION.md §5.4 called the TypeScript package @contextgraph/sdk. The
    published name is @contextgraphprotocol/typescript-sdk.
  • CHANGELOG.md gains the two [Unreleased] entries.

Evidence

Every command below was run in this branch's worktree.

tsc --noEmit — clean, then deliberately broken, then reverted. The break
pushes a string that is not a KnownFrameKind into KNOWN_FRAME_KINDS, the
exact shape of type the PR #87 changes introduced:

### clean:
exit=0
### with a deliberate type error:
src/types.ts(40,3): error TS2322: Type '"definitely-not-a-known-kind"' is not assignable to type 'KnownFrameKind'.
exit=2
### reverted:
exit=0

mypy --strict --python-version 3.10 — same three states, the break being
a function annotated -> int that returns its str argument:

### clean:
Success: no issues found in 7 source files
exit=0
### with a deliberate annotation break:
contextgraph_sdk/budget.py:24: error: Incompatible return value type (got "str", expected "int")  [return-value]
Found 1 error in 1 file (checked 7 source files)
exit=1
### reverted:
Success: no issues found in 7 source files
exit=0

The version-pin guard on this treepython3 .github/scripts/check-sdk-version-pins.py, exit=0:

the scaffolder's default pins agree with the SDK manifests they name
  PASS  DEFAULT_SDK is readable in the scaffolder
  PASS  the TypeScript template takes its SDK dependency from DEFAULT_SDK
  PASS  the Python template takes its SDK dependency from DEFAULT_SDK
  PASS  typescript pin shares the major sdk/typescript/package.json ships
  PASS  typescript pin does not name a version sdk/typescript/package.json has never shipped
  PASS  the Python pin names the package sdk/python publishes
  PASS  python pin shares the major sdk/python/pyproject.toml ships
  PASS  python pin does not name a version sdk/python/pyproject.toml has never shipped

the SDK majors move in lockstep with the crates
  PASS  every versioned manifest states an x.y.z version
  PASS  the SDKs and the crates share one major

OK — no version drift

The guard against the drift #98 actually recordedDEFAULT_SDK.typescript
put back to ^0.1.0, exit=1:

  FAIL  typescript pin shares the major sdk/typescript/package.json ships
        pin '^0.1.0' names major 0; sdk/typescript/package.json ships 2.0.0
        remedy: move the pin in sdk/create-contextgraph-provider/index.js's DEFAULT_SDK onto 2.0.0,
        or explain the split in docs/adr/0012-sdk-version-pins-share-a-major.md.

Reverted, and green again — the last block above is the tree as pushed.

Six more drift shapes, each introduced, run, and reverted. Every one exits
1; the failing line is quoted:

Drift introduced Failing check
Python pin >=1.0.0 against a 2.0.0 manifest python pin shares the major sdk/python/pyproject.toml ships
TS pin ^2.1.0 when only 2.0.0 exists typescript pin does not name a version sdk/typescript/package.json has never shipped
TS template hardcodes ^2.0.0 instead of {{SDK_SPEC}} the TypeScript template takes its SDK dependency from DEFAULT_SDK
sdk/python alone bumped to 3.0.0 both python pin shares the major … and the SDKs and the crates share one major
TS pin <2.0.0 — a ceiling, not a floor typescript pin is a floor this check can read
Python pin renamed to contextgraph>=2.0.0 the Python pin names the package sdk/python publishes

git status --porcelain after the last revert showed only the new files, and
the guard was green again.

Checklist

  • One logical change per PR (smaller lands faster)
  • Gate is green locally — fmt, clippy -D warnings, test
    not run, and deliberately. This PR touches no Rust: the diff is one
    workflow, one Python script, and three markdown files. SCR-001 says build
    only what the change touches, and nothing here compiles. CI runs the Rust
    gate on the push.
  • A witness test is included, or a reason there isn't one is stated below
    — the Evidence section is the witness for both guards: each is shown
    failing on the drift it exists for and passing once it is reverted. There
    is no #[test] because neither guard is Rust; the equivalent proof is the
    before/after run.
  • Docs updated in the same PR if behavior or flags changed (README.md,
    docs/, doc comments, --help text) — ADR 0012, and the guard's own
    header carries the rationale the way check-deploy-hygiene.py does.
  • 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

Protocol-stability impact (if a spec/wire change)

  • Not applicable — no wire or spec change

License

By submitting this pull request, I agree to dual-license this contribution
under MIT OR Apache-2.0, as certified by my DCO sign-off.

… its manifest

Two things the SDK directory shipped on a claim rather than a run.

The TypeScript type changes in PR #87 were never typechecked — no local
toolchain, and `npx tsc` failed in that environment. `sdk (typescript) is a
conformant implementation` does run `tsc` as a side effect of `npm run build`,
so the coverage was not zero, but it arrives only after a full
`cargo build --workspace --bins` and lasts only as long as the build script
stays `tsc`. `sdk (typescript) typechecks` asks the question directly. The
Python SDK ships `py.typed`, which promises downstream typecheckers that its
annotations are meant to be believed, and nothing checked them; `sdk (python)
typechecks` runs `mypy --strict`. Both are clean on this tree.

The scaffolder's `DEFAULT_SDK` table names versions of two packages it does not
own, and it sat at `^0.1.0` against a shipped `1.0.0` for an unknown length of
time. The scaffold job could not have caught it: it overrides both published
pins with local paths so CI never depends on a publish, which is the right call
for that job. `check-sdk-version-pins.py` is the guard that belongs elsewhere —
offline, stdlib only, no registry call.

The rule is same major, not equality, because the pins are deliberately ranges:
a patch release should not need an edit here, and a major is what a scaffold
cannot survive (ADR 0011 widened `FrameKind` in 2.0.0, so a `1.x` pin generates
code against a retired vocabulary). ADR 0012 carries the reasoning, including
why `sdk/go` and `schema/reference-vectors.ndjson` are out of scope.

Closes #94, Closes #98

Signed-off-by: macanderson <mac@oxagen.sh>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 16 hours and 12 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR adds standalone CI jobs to typecheck the TypeScript and Python SDKs, plus an offline standard-library guard that verifies scaffolder pins, template indirection, SDK manifest compatibility, and SDK/crate major-version lockstep; accompanying ADR, changelog, and package-name documentation are updated.

Flow diagram for offline SDK version-pin validation

flowchart TD
    Start[Run check-sdk-version-pins.py] --> Parse[Read DEFAULT_SDK and SDK manifests]
    Parse --> Templates[Verify templates use SDK_SPEC]
    Templates --> Pins[Parse TypeScript and Python dependency floors]
    Pins --> Compatibility{Same major and not ahead?}
    Compatibility -->|No| Fail[Print FAIL and exit 1]
    Compatibility -->|Yes| Lockstep{SDK and crate majors match?}
    Lockstep -->|No| Fail
    Lockstep -->|Yes| Pass[Print OK and exit 0]
Loading

File-Level Changes

Change Details Files
Add direct CI typechecking for both annotated SDKs.
  • Run TypeScript npx tsc --noEmit in an isolated Node job.
  • Run pinned mypy --strict --python-version 3.10 across the Python SDK and examples.
  • Keep the checks independent of the Rust build and document the Python py.typed rationale.
.github/workflows/ci.yml
CHANGELOG.md
Introduce an offline guard for SDK version-pin drift and enforce shared major versions.
  • Parse the scaffolder's DEFAULT_SDK, both SDK manifests, templates, and the Cargo workspace version using only the standard library.
  • Require template dependencies to use {{SDK_SPEC}}, pins to match the published package and share its major, and floors not to exceed the manifest version.
  • Require TypeScript, Python, and workspace crate manifests to use parseable versions with one shared major; emit per-check PASS/FAIL diagnostics and remedies.
  • Run the guard in its own CI job and document the policy, rationale, and intentionally excluded version sources in ADR 0012.
.github/scripts/check-sdk-version-pins.py
.github/workflows/ci.yml
docs/adr/0012-sdk-version-pins-share-a-major.md
Correct SDK naming and record the new CI safeguards in project documentation.
  • Replace the outdated TypeScript package name with @contextgraphprotocol/typescript-sdk.
  • Add unreleased changelog entries for SDK typechecking and version-pin drift detection.
MIGRATION.md
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#94 Add an independent CI job that runs npx tsc --noEmit in sdk/typescript/, and verify that the SDK passes, fails on a deliberate type error, and passes again after reverting it.
#94 Explicitly provide equivalent typechecking coverage for sdk/python/ and verify that it passes, fails on a deliberate annotation error, and passes again after reverting it.
#94 Satisfy the CI/documentation requirements for the new jobs: pin or explain floating tool versions and record the jobs in CHANGELOG.md under [Unreleased]. The changelog requirement is met, but the TypeScript job uses floating dependencies: actions/checkout@v5, actions/setup-node@v4, Node 22, and npm install without a pinned package-manager/dependency resolution. The Python job uses floating Python 3.12 and floating action versions. The PR explains the floating behavior for some choices, but not every version or tool dependency as required by the issue.
#98 Decide and document the version-drift rule for scaffolder SDK pins, including why same-major compatibility is used instead of exact equality and how the other named manifests are treated.
#98 Add an offline guard under .github/scripts/ that checks DEFAULT_SDK against the TypeScript and Python SDK manifests, detects stale or ahead-of-manifest pins, provides actionable remediation output, and records the verdict for sdk/go and reference-vector versions.
#98 Run the guard as a separate CI job rather than in the local-path scaffold job, ensure it passes on the current tree, demonstrate failure and recovery for the recorded TypeScript drift, and document the guard in the changelog.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

`npm ci` rather than `npm install`, so the job runs typescript 5.9.3 as
`package-lock.json` pins it instead of whatever `^5.9.0` resolves to today.
The mypy job beside it is pinned for the same reason and this one was not,
which Sourcery caught against #94's definition of done. The conformance jobs
keep `npm install`: they ask whether the SDK still behaves, not whether one
compiler release still accepts it.

Refs #94

Signed-off-by: macanderson <mac@oxagen.sh>
@macanderson

Copy link
Copy Markdown
Owner Author

Settling the one ❌ — "pin or explain every version the new jobs depend on" (#94)

Sourcery is half right, and the half it is right about is now fixed.

Fixed — the compiler was floating, and it should not have been. The
TypeScript job ran npm install, so it resolved typescript: "^5.9.0" to
whatever npm serves that day. That is exactly the hazard the comment beside the
mypy pin describes ("an unpinned strict typechecker turns each of its releases
into a possible red main on code nobody touched"), and I applied the reasoning
to one job and not the other. c11e8e6 switches it to npm ci, which resolves
sdk/typescript/package-lock.json exactly:

$ npm ci && npx tsc --version && npx tsc --noEmit
added 3 packages in 3s
Version 5.9.3
exit=0

The conformance jobs keep npm install on purpose. They ask whether the SDK
still behaves, not whether one compiler release still accepts it, so a resolved
patch is signal there rather than noise.

Answered — the action tags and runtime versions are the repository's
convention, not an omission in this PR.
actions/checkout@v5,
actions/setup-node@v4, actions/setup-python@v5, node-version: "22" and
python-version: "3.12" are what every one of the twenty other jobs in
ci.yml already uses. Pinning these two jobs to SHAs or to patch versions
would make them the only jobs in the file that do, which trades a small
supply-chain gain for a convention that then holds nowhere else. Whether this
repository should pin actions by SHA is a real question and a repo-wide one —
it belongs in a PR that changes all twenty-two jobs together, not in two of
them.

The runtime version has a second, separate hole that this PR did not open and
does not close: python-version: "3.12" is pinned, and sdk/python's
requires-python says >=3.9, so the three oldest interpreters the package
claims are tested by nothing. That is filed as #107 with a reproduction and two
alternative fixes, and #108 covers the other residue found here.

@macanderson

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Sorry @macanderson, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 15 hours and 57 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@macanderson

Copy link
Copy Markdown
Owner Author

Sourcery's assessment table cannot be refreshed on this head: the re-review request came back you've used your own review budget of 250,000 diff characters for the last 7 days, available again in about 16 hours.

So the standing ❌ row is now stale in the part it is most specific about — it names npm install in the TypeScript job, and c11e8e6 replaced that with npm ci. The rest of the row (action major tags, Node 22, Python 3.12) is answered in the comment above: those are the convention every other job in ci.yml follows, and changing them is a repo-wide PR rather than two jobs going their own way.

macanderson added a commit that referenced this pull request Aug 30, 2026
…ed URL

Both schemas' `$id` moves from
`raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/…`
to `https://contextgraphprotocol.org/schema/v1/…`.

`$id` is the identity a validator resolves and a third party quotes, so this
is protocol-visible rather than a deployment detail. The old URL had three
defects: a code-hosting domain with no stable-content guarantee, not the
project's own name, and a `main` pin — a git branch, so a `1.x` additive
minor silently changed what a cached resolver saw.

ADR 0013 records the decision and why the version segment is the **major
family** rather than the minor. Within `contextgraph/1` evolution is
additive-only, so a consumer holding an older copy is never wrong, only less
complete; per-minor paths would mint a new identity every minor and buy
nothing. `v1` is `contextgraph/1`, not the crate version, which is already
2.x against that same wire.

ADR 0008's rule — advertise only on a host this repo serves — is what permits
the move rather than resisting it. #78 made the apex such a host for
`/schema/`; only 0008's conclusion for the schemas expires, and it is amended
in place rather than left to contradict what ships.

Nothing is asked of implementers. The bytes are identical, the raw URL keeps
returning 200 (guaranteed by never moving `schema/*.schema.json`), and every
`$ref` in both schemas is a same-document pointer with no cross-schema
reference, so resolution is unchanged offline and online.

Identity is checked in two halves, neither weakened for the other:
`validate-examples.py` pins the string and stays offline, because it runs on
every PR and fork against commits whose publish has not happened;
`publish-spec.yml` publishes the identity path, then dereferences it and
fails unless the served body reports that same `$id`. A 200 alone is not
accepted — a static site answers its 404 page with one.

Fixes a live gap in `check-deploy-hygiene.py` found on the way: its URL
pattern required the filename to sit directly under `schema/`, so it matched
no versioned URL at all and would have gone silently blind to the two `$id`s
it exists to police. Its prefix map also needed longest-match, since
`/schema/` and `/schema/v1/` now nest and resolve to different repo paths.

Also repairs three docs that had come to state the opposite of what ships:
CONTRIBUTING.md still said a `contextgraphprotocol.org/schema/…` URL 404s
(stale since #78), `validate-examples.py`'s docstring claimed a byte-identical
served-copy fetch it never performed, and ADR 0008 cited a `SERVED_HOSTS` that
#78 replaced with a prefix map.

The ADR is numbered 0013 rather than 0012: PR #106 was opened first and holds
0012, and because the two filenames differ git would have merged both cleanly
into a tree with two ADR 0012s and no CI able to see it. Numbers are being
allocated centrally until a guard exists — that guard, and the GUIDE's ADR
index skipping 0009-0011, are #129.

Closes #79, Closes #58

Signed-off-by: macanderson <mac@oxagen.sh>
@macanderson
macanderson merged commit 284efa6 into main Aug 30, 2026
27 checks passed
@macanderson
macanderson deleted the fix/ci-sdk-guards-b726a4bc branch August 30, 2026 04:15
macanderson added a commit that referenced this pull request Aug 30, 2026
…the ADR to 0017

The import is used only by the signing and verifying code, so a default
build — every CI job except the new feature matrix — failed
`-D warnings` on `unused_imports`. Gated to `record-attestation`, with
the one ungated doc link that named the type rewritten as an explicit
path so it still resolves with the import absent.

The local check that reported this clean was `rg -c '^(error|warning)'`
over cargo's output. Cargo colourises when it thinks it is talking to a
terminal, so the escape sits before the word and `^error` matches
nothing — a filter that cannot see the errors, reported as silence. The
loop also mis-quoted `--features X` as one argument, so three of the
five combinations never ran at all. Re-verified by exit code.

ADR 0012 renumbered to 0017: PR #106 adds a differently-named
docs/adr/0012-*.md, and two files with different names merge cleanly
into a tree holding two ADR 0012s with nothing to catch it. Numbers are
now allocated centrally. docs/GUIDE.md's decision log gains the entry,
along with 0009, 0010 and 0011, which had been missing since they landed
— adding a row to an index while leaving it knowingly incomplete is not
a fix.

Refs #96

Signed-off-by: macanderson <mac@oxagen.sh>
macanderson pushed a commit that referenced this pull request Aug 30, 2026
#106 landed `mypy --strict` over the Python SDK while this branch was open,
and a bare `dict` in a signature fails `--strict`: the package ships
`py.typed`, so an unparameterized mapping leaks `Any` across the exact
boundary that promise covers.

`LinkLike` and `FrameLike` name the two shapes the attestation surface accepts
— the typed one and any decoded JSON mapping — so a caller reads what is
allowed instead of inferring it, and the aliases carry the absent-vs-empty
rule the encoding depends on.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>
macanderson added a commit that referenced this pull request Aug 30, 2026
…ed URL

Both schemas' `$id` moves from
`raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/…`
to `https://contextgraphprotocol.org/schema/v1/…`.

`$id` is the identity a validator resolves and a third party quotes, so this
is protocol-visible rather than a deployment detail. The old URL had three
defects: a code-hosting domain with no stable-content guarantee, not the
project's own name, and a `main` pin — a git branch, so a `1.x` additive
minor silently changed what a cached resolver saw.

ADR 0013 records the decision and why the version segment is the **major
family** rather than the minor. Within `contextgraph/1` evolution is
additive-only, so a consumer holding an older copy is never wrong, only less
complete; per-minor paths would mint a new identity every minor and buy
nothing. `v1` is `contextgraph/1`, not the crate version, which is already
2.x against that same wire.

ADR 0008's rule — advertise only on a host this repo serves — is what permits
the move rather than resisting it. #78 made the apex such a host for
`/schema/`; only 0008's conclusion for the schemas expires, and it is amended
in place rather than left to contradict what ships.

Nothing is asked of implementers. The bytes are identical, the raw URL keeps
returning 200 (guaranteed by never moving `schema/*.schema.json`), and every
`$ref` in both schemas is a same-document pointer with no cross-schema
reference, so resolution is unchanged offline and online.

Identity is checked in two halves, neither weakened for the other:
`validate-examples.py` pins the string and stays offline, because it runs on
every PR and fork against commits whose publish has not happened;
`publish-spec.yml` publishes the identity path, then dereferences it and
fails unless the served body reports that same `$id`. A 200 alone is not
accepted — a static site answers its 404 page with one.

Fixes a live gap in `check-deploy-hygiene.py` found on the way: its URL
pattern required the filename to sit directly under `schema/`, so it matched
no versioned URL at all and would have gone silently blind to the two `$id`s
it exists to police. Its prefix map also needed longest-match, since
`/schema/` and `/schema/v1/` now nest and resolve to different repo paths.

Also repairs three docs that had come to state the opposite of what ships:
CONTRIBUTING.md still said a `contextgraphprotocol.org/schema/…` URL 404s
(stale since #78), `validate-examples.py`'s docstring claimed a byte-identical
served-copy fetch it never performed, and ADR 0008 cited a `SERVED_HOSTS` that

The ADR is numbered 0013 rather than 0012: PR #106 was opened first and holds
0012, and because the two filenames differ git would have merged both cleanly
into a tree with two ADR 0012s and no CI able to see it. Numbers are being
allocated centrally until a guard exists — that guard, and the GUIDE's ADR
index skipping 0009-0011, are #129.

Closes #79, Closes #58

Signed-off-by: macanderson <mac@oxagen.sh>
macanderson added a commit that referenced this pull request Aug 30, 2026
… Go SDKs (#131)

* test(contextgraph-types): publish the attestation vectors an SDK port can actually fail

The published set could not separate a correct port from an incorrect one.
Every string in it was ASCII, so a length prefix counting UTF-16 code units
or code points computed the same bytes; the only multi-leaf Merkle vector had
four leaves, where RFC 6962's split and the duplicate-the-last-leaf shortcut
agree; and there was no signature and no inclusion proof at all, so the two
halves of section 6.5.4 had no oracle.

Adds a link whose fields are multi-byte UTF-8 ending in an astral-plane
character (24 UTF-8 bytes reading as 17 either other way), one-, three- and
seven-leaf roots, a seven-leaf inclusion proof, a fixed Ed25519 key with the
signature it produces, and the verdict vocabulary. No existing value changes.

Mirrors every value into tests/vectors/attestation-vectors.json, which the
TypeScript, Python and Go suites read, and asserts here that the mirror agrees
— a digest transcribed into four languages is four things that can drift.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* feat(sdk-typescript): port provenance attestation, reconciled against the published vectors

The SPEC.md 6.5 constructions in TypeScript: the length-prefixed link
encoding, the source-first chain fold, the frame commitment, the RFC 6962 root
and inclusion proofs, and strict Ed25519 verification with the seven named
verdicts.

Every length prefix is measured off the bytes TextEncoder produced, never off
String.prototype.length. With the published unicode vector wired in, swapping
the two turns the suite red on a 0x11 where the vector says 0x18 — which is
exactly the divergence an ASCII-only vector set could not see.

Node's Ed25519 (OpenSSL) accepts a small-order public key, which 6.5.4 says a
strict verifier should not, so verifyCommitment rejects the eight small-order
encodings and any key whose y is not reduced before OpenSSL ever sees it.

The new script compiles test/attest.test.ts and runs it against
tests/vectors/attestation-vectors.json under node --test.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* feat(sdk-python): port provenance attestation, reconciled against the published vectors

The same constructions in Python, with the length prefix taken off
s.encode("utf-8") rather than len(s) — len counts code points, so the
published unicode vector separates the two.

Ed25519 verification needed a decision: the standard library has none, and the
SDK promises zero dependencies. contextgraph_sdk._ed25519 is a self-contained
RFC 8032 verifier — verification only, never signing — matching dalek's
verify_strict on all four counts: the cofactorless equation, a reduced S,
canonical encodings, and small-order rejection. It is checked against RFC 8032
7.1's own vectors, against this repository's dalek-produced signature, and
differentially against the cryptography package wherever that is installed.

Because it has real field arithmetic, the Python suite is also what proves the
small-order table the TypeScript and Go ports carry: it recomputes 8P =
identity for every entry rather than trusting the list.

Runs on a bare interpreter with python3 -m unittest discover -s tests.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* test(vectors): publish the small-order key set a strict verifier declines

SPEC.md 6.5.4 says a verifier should reject small-order public keys and
non-canonical encodings, and named neither set. The Rust reference gets both
from ed25519_dalek::verify_strict; a port on Node's OpenSSL or Go's
crypto/ed25519 gets neither, because both accept a small-order key.

So the set is published rather than left to each port to rediscover: the eight
canonical encodings of a point P with 8P = identity, plus the two
non-canonical y values a verifier that reduces mod p would misread as y = 0
and y = 1. Labelled as verifier guidance rather than a wire vector, because it
constrains what a verifier accepts and not what anything encodes.

The Python suite recomputes 8P = identity for every entry from its own field
arithmetic, so the table is checked rather than trusted.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* feat(sdk-go): port provenance attestation, and run all four suites in CI

Go is the one language of the three where the native string length is already
a UTF-8 byte count, so the port is short — but it has its own trap the others
do not: contextgraph.Provenance carries its optional fields as string with
omitempty and cannot tell an absent URI from a present empty one, which is
exactly the distinction the SPEC.md 6.5.1 presence byte makes normative.
attest.Link takes pointers, and LinkFromProvenance states the collapse it
performs rather than hiding it.

Go's crypto/ed25519 accepts a small-order public key, as Node's OpenSSL does,
so VerifyCommitment declines the published small-order set and any key whose y
is not reduced before the standard library sees them.

CI gains one step per SDK job plus, on the Rust side, the one that turned out
to matter most: the attestation feature is off by default and no workspace
member enables it, so the existing workspace-wide run had never compiled
contextgraph_types::attest or its vectors. The oracle three ports now
reconcile against was itself unrun.

Closes #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* fix(sdk): accept hex in exactly one spelling across all three ports

SPEC.md's digest grammar is 64 lowercase hex characters, and
contextgraph_types::is_well_formed_digest enforces it. The ports were
inconsistent with each other: TypeScript rejected uppercase, Python's
bytes.fromhex accepted it and also skipped whitespace between byte pairs, and
Go's encoding/hex accepted it. Three implementations now agree, and each has a
named test.

The reference itself is the remaining outlier — attest::from_hex accepts A-F,
so an uppercase signed_commitment verifies in Rust and is malformed_commitment
in all three SDKs. Filed as #145 rather than changed here, because it is a
behaviour change to a published Rust function and belongs in its own diff.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

* fix(sdk-python): type the attestation surface for the strict typechecker

#106 landed `mypy --strict` over the Python SDK while this branch was open,
and a bare `dict` in a signature fails `--strict`: the package ships
`py.typed`, so an unparameterized mapping leaks `Any` across the exact
boundary that promise covers.

`LinkLike` and `FrameLike` name the two shapes the attestation surface accepts
— the typed one and any decoded JSON mapping — so a caller reads what is
allowed instead of inferring it, and the aliases carry the absent-vs-empty
rule the encoding depends on.

Refs #93

Signed-off-by: Mac Anderson <ops@oxagen.sh>

---------

Signed-off-by: Mac Anderson <ops@oxagen.sh>
Co-authored-by: Mac Anderson <ops@oxagen.sh>
macanderson added a commit that referenced this pull request Aug 30, 2026
…ed URL (#109)

Both schemas' `$id` moves from
`raw.githubusercontent.com/macanderson/context-graph-protocol/main/schema/…`
to `https://contextgraphprotocol.org/schema/v1/…`.

`$id` is the identity a validator resolves and a third party quotes, so this
is protocol-visible rather than a deployment detail. The old URL had three
defects: a code-hosting domain with no stable-content guarantee, not the
project's own name, and a `main` pin — a git branch, so a `1.x` additive
minor silently changed what a cached resolver saw.

ADR 0013 records the decision and why the version segment is the **major
family** rather than the minor. Within `contextgraph/1` evolution is
additive-only, so a consumer holding an older copy is never wrong, only less
complete; per-minor paths would mint a new identity every minor and buy
nothing. `v1` is `contextgraph/1`, not the crate version, which is already
2.x against that same wire.

ADR 0008's rule — advertise only on a host this repo serves — is what permits
the move rather than resisting it. #78 made the apex such a host for
`/schema/`; only 0008's conclusion for the schemas expires, and it is amended
in place rather than left to contradict what ships.

Nothing is asked of implementers. The bytes are identical, the raw URL keeps
returning 200 (guaranteed by never moving `schema/*.schema.json`), and every
`$ref` in both schemas is a same-document pointer with no cross-schema
reference, so resolution is unchanged offline and online.

Identity is checked in two halves, neither weakened for the other:
`validate-examples.py` pins the string and stays offline, because it runs on
every PR and fork against commits whose publish has not happened;
`publish-spec.yml` publishes the identity path, then dereferences it and
fails unless the served body reports that same `$id`. A 200 alone is not
accepted — a static site answers its 404 page with one.

Fixes a live gap in `check-deploy-hygiene.py` found on the way: its URL
pattern required the filename to sit directly under `schema/`, so it matched
no versioned URL at all and would have gone silently blind to the two `$id`s
it exists to police. Its prefix map also needed longest-match, since
`/schema/` and `/schema/v1/` now nest and resolve to different repo paths.

Also repairs three docs that had come to state the opposite of what ships:
CONTRIBUTING.md still said a `contextgraphprotocol.org/schema/…` URL 404s
(stale since #78), `validate-examples.py`'s docstring claimed a byte-identical
served-copy fetch it never performed, and ADR 0008 cited a `SERVED_HOSTS` that

The ADR is numbered 0013 rather than 0012: PR #106 was opened first and holds
0012, and because the two filenames differ git would have merged both cleanly
into a tree with two ADR 0012s and no CI able to see it. Numbers are being
allocated centrally until a guard exists — that guard, and the GUIDE's ADR
index skipping 0009-0011, are #129.

Closes #79, Closes #58

Signed-off-by: macanderson <mac@oxagen.sh>
macanderson added a commit that referenced this pull request Aug 30, 2026
…#114)

* feat(contextgraph-types): implement record_hash and RecordAttestation

The lifecycle profile has always defined `record_hash` as the sha256 over
the RFC 8785 (JCS) canonicalization of a record with its own `record_hash`
removed (LH1), and `RecordAttestation` as a detached Ed25519 signature over
it (LC3). Both were prose and a struct. The only hashing code in the
workspace was a private helper inside the conformance suite, so the suite
proved the fixtures agreed with the suite; and the attestation fixture
carried 49 bytes of DER-shaped filler where a signature belongs, with no
key published, so no implementation could reproduce or refute it.

`contextgraph_types::record_attest` makes the rule callable, behind two new
off-by-default features. `record-hash` adds RFC 8785 canonicalization
(delegated to serde_json_canonicalizer, whose numbers route through ryu-js
— JCS number serialization is ECMAScript Number::toString, and its exponent
thresholds are where reimplementations diverge in silence).
`record-attestation` adds Ed25519 on top. A frame-only consumer pays for
neither, and the crate's zero-dependency default is unchanged.

The signed message is domain-separated: "contextgraph/attest/1/record"
followed by the digest's 32 raw bytes. A frame commitment is domain-bound
by construction; a record_hash is a plain SHA-256 over a JSON document that
any number of unrelated systems also compute, so signing it raw would let
one signature mean whatever the presenter says it means. Verification
recomputes the record's hash rather than reading the stored member, so
editing a record and rewriting its hash to match a stolen signature is
caught as a mismatch instead of passing.

Evidence: the canonicalizer is checked against RFC 8785's own vectors —
§3.2.4's byte listing, §3.2.3's sorting data, and Appendix B's IEEE 754
number table. The twelve fixtures' hashes are unchanged, which is what
shows this reproduces the existing rule rather than redefining it.
tests/fixtures/ now publishes the canonical preimage text of every fixture,
a real signature, and the test key that produced it; the conformance suite
recomputes all of it through the library. schema/validate-examples.py
checks the vectors from Python, without a JCS library, so an implementer
who has neither Rust nor a canonicalizer can still rely on them.

A CI job builds and runs each feature combination — until now every feature
this crate has was off by default and no job turned any of them on, so the
attestation code added in #87 compiled nowhere in CI.

Also corrects LH2, which said JCS sorts members by code point. RFC 8785
§3.2.3 sorts by UTF-16 code unit, and the two orders differ for a
supplementary character.

Closes #96

Signed-off-by: macanderson <mac@oxagen.sh>

* fix(contextgraph-types): gate the RecordAttestation import, renumber the ADR to 0017

The import is used only by the signing and verifying code, so a default
build — every CI job except the new feature matrix — failed
`-D warnings` on `unused_imports`. Gated to `record-attestation`, with
the one ungated doc link that named the type rewritten as an explicit
path so it still resolves with the import absent.

The local check that reported this clean was `rg -c '^(error|warning)'`
over cargo's output. Cargo colourises when it thinks it is talking to a
terminal, so the escape sits before the word and `^error` matches
nothing — a filter that cannot see the errors, reported as silence. The
loop also mis-quoted `--features X` as one argument, so three of the
five combinations never ran at all. Re-verified by exit code.

ADR 0012 renumbered to 0017: PR #106 adds a differently-named
docs/adr/0012-*.md, and two files with different names merge cleanly
into a tree holding two ADR 0012s with nothing to catch it. Numbers are
now allocated centrally. docs/GUIDE.md's decision log gains the entry,
along with 0009, 0010 and 0011, which had been missing since they landed
— adding a row to an index while leaving it knowingly incomplete is not
a fix.

Refs #96

Signed-off-by: macanderson <mac@oxagen.sh>

* docs(guide): complete the ADR decision log

The index stopped at 0008; 0009, 0010, 0011 and 0012 had each landed
without a row. Adding only 0017 would have left a table that jumps from
0008 to 0017 and still misses four decisions, so all five are in.

Refs #96

Signed-off-by: macanderson <mac@oxagen.sh>

* fix(contextgraph-types): use as_chunks for the fixed-width hex pairs

Rust 1.98's clippy adds `chunks_exact_to_as_chunks`, warn-by-default, so
`-D warnings` turned red on every hex decoder here — including
`attest.rs`'s `from_hex`, which predates this branch. CI pins
`dtolnay/rust-toolchain@stable`, so the toolchain moved under a tree
nobody had changed; the pre-existing site is fixed here because the job
cannot go green while it stands.

`as_chunks::<2>()` is also the better shape: the length check above each
loop already rules out a remainder, and a fixed-size chunk lets the
compiler see both indexes are in bounds.

The two copies of the hex decode in the conformance suite collapse into
one `hex32` helper that also checks the length it assumes.

A note on how the earlier local run missed this: cargo replays a cached
clippy result for an unchanged crate, so a `clippy` that had passed
before the lint existed kept reporting success. /tmp/verify.sh now
touches every source first.

Refs #96

Signed-off-by: macanderson <mac@oxagen.sh>

---------

Signed-off-by: macanderson <mac@oxagen.sh>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing catches version drift between an SDK manifest and the scaffolder that pins it Typecheck the TypeScript SDK in CI

1 participant