Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.

feat(sdk): enforce WellKnownManifest.ver in the endpoint resolvers - #48

Merged
noxlesh merged 2 commits into
mainfrom
sdk/manifest-version-enforcement
Sep 4, 2026
Merged

noxlesh merged 2 commits into
mainfrom
sdk/manifest-version-enforcement

Conversation

@noxlesh

@noxlesh noxlesh commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What

WellKnownManifest.ver has stated a consumer-side rule since the manifest was unified, and no SDK applied it: the Go, Python and TypeScript endpoint resolvers dropped ver unread, so a manifest at any version, or with none, was accepted.

This PR states the rule in full and enforces it on the endpoint face in all three languages:

  • Rule. Read ver before any other member. Accept a recognised MAJOR whatever the MINOR. Refuse an unrecognised MAJOR, a value that is not MAJOR.MINOR, and an absent ver. A refusal is a verdict: never cached, classified not-sent by the client tier.
  • One owner. helpers.WellKnownManifestVersion (WellKnownManifestVersion in Python/TS), pinned to the wire-constants corpus beside ProtocolVersion and deliberately not derived from it. The coupling runs one way: a manifest layout change bumps both numbers, a protocol change alone bumps only ProtocolVersion.
  • Scope. Endpoint resolver only. The well-known key resolver reads a JWKS document that carries no manifest version and is unchanged.
  • Guards. New corpus manifest-version-vectors.json replayed by all three languages (pure rule + real resolver against a stub origin). New conformance/manifest_version_rule_test.go pins the proto comment to the corpus in both directions. ver_field_contract_test.go now reads the manifest's expected value from its own vector entry, so the guard stops coupling the two namespaces.
  • Docs. Proto field comment and header, the two manifest reference pages, proto-ramp.mdx, both changelogs.

Compatibility

A manifest served without ver was accepted before and is refused now. The field has been required by the wire shape since the unified manifest was introduced, so a conforming producer already stamps it. No wire change.

Verification

  • scripts/ci-local.sh green (buf 1.66.1, the pinned version; 1.71.0 produces a different descriptor.binpb).
  • sdk/python/tests: 1634 passed. sdk/ts: tsc strict + 1535 vitest passed. Website builds.

Please merge with a merge commit rather than a squash: the reference implementation is being re-pinned to ad9d688 and that SHA must stay reachable.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UUwf5FonFW8pAjkyD5raNu

noxlesh and others added 2 commits September 4, 2026 11:53
…nd give the manifest version its own constant

The manifest's ver has stated a consumer-side rule since the manifest was unified,
and no SDK applied it: the Go, Python and TypeScript endpoint resolvers decoded
the endpoint and dropped ver unread, so a manifest at any version, or with no
version at all, was accepted. The reference implementation's schema validator was
the only enforcement anywhere, and it enforced the wrong shape — an exact match
on "1.0", which refuses the additive minor revisions the rule exists to admit.

The rule is now stated in full on the field and enforced where the document is
read. A consumer reads ver before any other member, accepts a recognised MAJOR
whatever the MINOR, and refuses an unrecognised MAJOR, a value that is not
MAJOR.MINOR, and an absent ver. Absent is refused rather than tolerated: the field
is required by the wire shape, the document sits at a fixed unversioned path, and
it is read before any signature is checked, so a layout the reader cannot classify
must not supply the endpoint a signed call is then sent to. The gate runs on the
endpoint face only — the well-known key resolver reads a JWKS document that
carries no manifest version — and a refusal is a verdict: never cached, and
classified not-sent by the client tier like the other resolver verdicts.

The value gets one owner. helpers.WellKnownManifestVersion (WellKnownManifestVersion
in Python and TypeScript) is pinned to the wire-constants corpus beside
ProtocolVersion and deliberately not derived from it. The two are separate
namespaces and the coupling runs one way: a manifest layout change bumps both
numbers, a protocol change that leaves the manifest untouched bumps only
ProtocolVersion, so a reader that parses only manifests upgrades when the manifest
changes and at no other time. The proto header and the manifest field now say so.

Three guards hold the three halves together. A new corpus,
manifest-version-vectors.json, is emitted from the real Go rule and replayed by
all three languages, through the pure check and through the real endpoint
resolver against a stub origin. A new conformance guard pins the field comment to
every clause the corpus exercises, in both directions. And the existing ver
contract guard now reads the manifest's expected value from the
WellKnownManifestVersion vector entry rather than from ProtocolVersion, so the
guard itself no longer couples the two namespaces.

Every fixture that serves a manifest to the real resolver now carries ver, which
is the compatibility note for the change: a manifest served without ver was
accepted before and is refused now. A conforming producer already stamps it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUwf5FonFW8pAjkyD5raNu
…a verdict, and the key-face exemption pinned by a test

Two review rounds of the manifest-version gate found the same shape three
times: a decision recorded as prose where a test should have held it.

The key-face exemption was one. The endpoint resolvers gate on ver and the
well-known key resolvers do not, because a key document is a plain JWK Set
that carries no manifest version — but no test in any language served a key
document with a ver the endpoint face would refuse and checked that keys still
resolve, so moving the gate into the shared fetch passed every test. Each
language now has that test. The comments that still described the pre-branch
shape are corrected with it: wellKnownDoc no longer claims a key set at proto
field 5 (there is none) or one body serving both faces, and the key resolver
no longer says it reads ramp.json keys.

A non-string ver was the second. Go decoded ver into a string, so a document
carrying `"ver": 1` failed the decode and surfaced as a transport error the
client tier retried; Python and TypeScript treat any non-string as absent and
return the final verdict. Go now holds ver raw and reads it through
manifestVer, which yields the absent value for anything that is not a JSON
string, so the three languages return the same verdict and the client tier
classifies it not-sent everywhere. A resolver-level test in each language
serves a number, a null and an object and asserts the verdict — and, in Go,
that it is reported as absent rather than spelled from the raw bytes.

The not-sent classification in Python and TypeScript was the third: Go had a
taxonomy test for the new verdict and the two ports did not, so reverting
either arm left every test green. Both now have one, in the shape of the
existing injected-resolver tests beside them.

Go declared the sentinel twice — helpers.ErrManifestVersionRefused and a
separate resolvers.ErrManifestVersionRefused, joined by a double wrap — and
the client route matched only the second, so an injected resolver wrapping
the helper sentinel was retried forever. The resolvers name is now a re-export
of the helper sentinel, the shape ErrUnknownKey already uses, and the wrap is
single. Both symbol-map rows stay, as they do for ErrUnknownKey.

Smaller things the same rounds caught. The refused ver is clipped to 64
characters before it is echoed into the error message, in all three
languages: the body is read up to 1 MiB and a refusal is never cached, so an
unclipped echo let a hostile origin size every error. The rationale paragraph
that had been copied into seven source sites now lives on WellKnownManifest.ver
in the proto, and each SDK site says only what it does and points there. The
"read the same by coincidence, not by rule" sentence beside the one-way
coupling rule is reworded so the two no longer read as a contradiction. A
stale Python cross-reference, a "two sentinels" count in the Go README, and
"seven wire constants" in two replay tests are corrected. The conformance
ver-contract guard reads the wire-constants vector once through one
parameterised lookup instead of two copies of the reader, the Python and
TypeScript manifest builders take a ver parameter so the parity tests no
longer hand-build the document, and a tautological isinstance assertion after
a pytest.raises is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mc78kH8LccesLjakv77nmK
@noxlesh
noxlesh merged commit 74211d2 into main Sep 4, 2026
4 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant