Skip to content

feat(evidence): add a relying-party client library with Node and Python bindings - #649

Merged
jeremi merged 67 commits into
mainfrom
feat/evidence-client-library
Aug 6, 2026
Merged

feat(evidence): add a relying-party client library with Node and Python bindings#649
jeremi merged 67 commits into
mainfrom
feat/evidence-client-library

Conversation

@jeremi

@jeremi jeremi commented Aug 5, 2026

Copy link
Copy Markdown
Member

Pull Request

Summary

Adds a relying-party client library for Evidence in Rust, with Node.js and
Python bindings, and extracts the response-verification code the library needs
into its own portable crate.

Five areas, in dependency order:

  1. crates/registry-evidence-verifier, extracted out of
    crates/registry-evidence. It owns the response wire formats, the Evidence
    payload contract, and relying-party verification, so a relying party can
    verify a signed Evidence response without the runtime. It carries no server,
    source access, or service-runtime dependency, enforced by
    products/evidence/scripts/check-verifier-portability.sh. Portable there
    means free of the service runtime, not target independent: the crypto stack
    reaches aws-lc-sys, so a build needs a C toolchain and excludes wasm32.
  2. crates/registry-evidence-client: the SDK. Prepares a fixed request,
    sends it, and verifies the response through the verifier crate. Rust owns the
    transport end to end (reqwest), so both bindings inherit one HTTP, TLS, and
    trust-pinning implementation rather than three. Covers discovery, JWKS
    pinning, one-send enforcement, and bounded response reading.
  3. A PrivateKeyJwt token provider in that same crate: a generic RFC 7523
    private-key-JWT client, proven against a real registry-mint instance rather
    than a stub, so deployments with no identity provider can acquire access
    tokens.
  4. crates/registry-evidence-client-node: napi-rs binding.
  5. crates/registry-evidence-client-py: PyO3 + abi3-py310 + maturin
    binding.

Signed flattened JWS only. SD-JWT VC responses are deliberately out of scope for
this version, as is publishing either binding package (see Notes).

Owning area

This spans more than one owning area, against the usual rule. Adding crates that
the product boundary documents enumerate by name means the crates and those
documents move together: AGENTS.md, products/evidence/*, and the docs-site
spec pages all name the runtime's file set, so leaving them behind would ship a
boundary document that contradicts the tree. The .github and release/
touches are the CI wiring and gate inventory the new crates need. The three
crates/registry-relay files are a consequence of a workspace dependency
change, described under Notes. The fix pass that followed review kept each of
its commits inside one owning area.

Checks

Every gate below ran at the tip of this branch, after the rebase onto main and
after the review fix pass, with its exit code captured.

Gate Result
cargo fmt --check pass
cargo check --locked --workspace --all-targets pass
cargo clippy --locked --workspace --all-targets -- -D warnings pass
cargo test --locked --workspace 3999 passed, 0 failed, 34 ignored
cargo test --locked -p registry-relay --all-features 1677 passed, 0 failed, 10 ignored
cargo test --locked -p registry-evidence-verifier 44 passed, 0 failed, 0 ignored
cargo test --locked -p registry-evidence-client 100 passed, 0 failed, 0 ignored
cargo test --locked -p registry-evidence-client-node 37 passed, 0 failed, 1 ignored
cargo test --locked -p registry-evidence-client-py 38 passed, 0 failed, 1 ignored
cargo deny check advisories, bans, licenses, sources ok
products/evidence/scripts/check-contracts.sh pass, contracts reproduce exactly
products/evidence/scripts/check-source-neutrality.sh pass
products/evidence/scripts/check-verifier-portability.sh pass
python3 .github/scripts/test_ci_changes.py 33 passed
python3 -m unittest release/scripts/test_registry_release.py 71 passed
python3 release/scripts/check-gates-inventory.py pass, 150 gates
REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh pass
python3 -m unittest release/scripts/test_check_release_source_model.py 13 passed
release/scripts/registry-release validate release/manifests/registry-stack-beta-27.yaml pass
npm ci, npm run build:debug, npm test, npm run check:types in the Node crate 21 passed, types clean
extension-module build and python3 -m unittest discover -s tests/python in the Py crate 25 passed
cmp ../../LICENSE LICENSE in both binding crates identical
npm test and npm run check in docs/site 310 passed, check clean

The registry-relay --all-features row is the one that matters for the relay
half of this branch: spdci-api-standards is not in relay's default feature
set, so a default cargo test -p registry-relay compiles the eight new schema
tests to zero tests. In CI they run through the rust-tests relay shard's
--all-features invocation.

Two local-only notes, neither committed anywhere: on macOS
cargo test -p registry-evidence-client-py needs the resolved interpreter's
library directory on DYLD_LIBRARY_PATH, or the test binary aborts at startup
on libpython3.13.dylib before running a test (this is documented in that
crate's README as a build note, without a machine-specific path). Without it,
cargo test --workspace fails at that package with exit 101, which is a
dynamic-linker failure and not a test failure. Linux resolves the library
through the loader cache and needs nothing.

Coverage for the functionality

  • Verified exchange end to end:
    crates/registry-evidence-client/tests/against_a_real_deployment.rs runs the
    real evidence binary and the real mint binary, and covers first-use
    acceptance then pinning, a pinned binding refusing an assertion about another
    subject, a response that cannot verify against another prepared request, a
    request the deployment cannot answer, discovery shapes, and the published key
    set being the deployment's own.
  • Token acquisition against a real authorization server: the same file
    covers an acquired credential completing a verified exchange, a cached
    credential serving every request inside its window, a credential inside the
    refresh margin being replaced, an unregistered client key refused without
    detail, and a tampered credential refused without detail.
  • Refusal paths: a response beyond the configured bound, a response under
    the wrong media type, and a credential without the configured tag are each
    refused, with their own tests.
  • Redaction: each of the three languages has a test asserting that no
    credential, signing key, selector value, or subject binding reaches an error
    message, a Debug rendering, or a log. The Python one drives a canary value
    through six distinct failure arrangements.
  • Binding-specific hazards: the Node crate has a drift test keeping the
    hand-written JS in step with the generated native surface. The Python crate
    has a concurrency test proving two calls overlap rather than serialize, a
    test proving the constructor releases the GIL for its Rust work, and a reload
    test proving the public surface survives re-importing the package.

Generated outputs

Both regenerated by their documented generator commands, never hand-edited, and
confirmed reproducible:

  • crates/registry-evidence-client-node/index.js via npm run build
    (napi build --platform --release), byte-identical across two runs.
  • docs/site/src/data/generated/projects.json via npm run generate, which
    npm run check runs first: the tree stays clean afterwards.

Notes

The review pass this branch already went through

The whole branch was reviewed before this PR was opened, dimension by dimension,
against the frozen Version 1 contract, the Evidence product boundary, the
redaction rules, the binding surfaces, and the relay change. That review
returned no blockers, eighteen findings worth fixing, and nineteen observations.

Nine commits at the tip of this branch close the fixable ones. In order of what
they change: two redaction gaps (subject bindings surviving a Debug rendering,
and base-URL userinfo surviving the client config's Debug); an unusable
asOfMillis reaching the Node error envelope as the wrong kind; four committed
statements about the Python binding's panic surface that omitted the
allocation-failure caveat; the client boundary and the binding check commands
missing from AGENTS.md; the syscall layers the portability gate actually
denies, unnamed in four documents; the client crates missing from the Evidence
gate lists and crate inventories; one incomplete CI job enumeration in the docs
site; and the relay SP DCI response-schema contract, which is the only one of
the nine that changes behavior rather than words.

What the review found and this branch does not fix is in Flagged,
deliberately not fixed here
below, all twenty-nine items, including the ones
that are pre-existing on main.

Security-sensitive review notes

This change touches authentication, assertion verification, and data
minimization, so per AGENTS.md here is what a reviewer should look at
deliberately.

  • The verifier extraction moves verification code without changing it. The
    contract checks confirm the Evidence contracts still reproduce exactly, and
    the portability check confirms the extracted crate pulls in no server, source
    access, or service-runtime dependency. The risk to review is whether any
    verification step was weakened in the move; the runtime's own tests still
    exercise it through the new crate.
  • Trust pinning is the client's job and it does not fall back. The client
    builds reqwest with use_rustls_tls() and, when a caller pins roots, calls
    tls_built_in_root_certs(false), which clears both the webpki and native root
    flags. A pinned client therefore cannot silently accept a platform root.
  • Assertion verification is offline and has no bypass. There is no
    configuration flag that skips signature verification, no "insecure" mode, and
    no path that returns an unverified payload to a caller. The one-send guard
    refuses a second send on the same prepared request without reaching the
    network.
  • The token provider is a generic private-key-JWT client, not a Mint client.
    No Evidence crate depends on Mint at runtime. registry-mint is a
    dev-dependency of crates/registry-evidence-client only, so the integration
    suite can prove the RFC 7523 assertion against a real verifier instead of a
    stub told to return 401. This made one sentence in the root AGENTS.md false
    in the build graph, so that sentence now confines its claim to production and
    states that Evidence test code may drive a real Mint instance. That edit is in
    its own commit and can be reviewed or dropped alone.
  • Credentials are redacted at the boundary. BearerToken wraps a
    Zeroizing<String>, the signed client assertion and the token request body are
    built in Zeroizing buffers, and the Authorization header is assembled in
    one too. The private signing key is the shared PrivateJwk, which zeroizes its
    private members (d, p, q, dp, dq, qi) in a hand-written Drop and
    derives neither Debug nor Serialize, so it cannot be printed or serialized
    at all. Wire types render through a redacted_debug! macro, and no credential,
    key member, selector value, or subject binding is interpolated into an error
    message. Acquisition failures report only the registered error code from the
    authorization server, and unregistered codes collapse to one name rather than
    echoing server text.
  • Two redaction gaps that review found, both closed here. Widening
    ExpectedSubjectDocument into public SDK surface made a pinned subject
    binding renderable by any relying party that formatted a retained policy,
    because the type derived Debug over its binding field. It and
    ExpectedSubject now render the role and elide the binding, matching the
    hand-written Debug that SubjectExpectations already carried, with three
    tests driving a canary binding through the derive path including through the
    policy document that still derives Debug. Separately, the client config's
    hand-written Debug promised to withhold the credential source while
    rendering the base URL verbatim, so a config formatted before
    EvidenceClient::new ran validate() rendered any userinfo password the
    caller had put in it. Nothing shipped logged either value, so both were
    widened latent surface rather than active leaks, and each is a
    data-minimization fix in its own commit.
  • One reachable panic, documented in all three languages. Ulid::new() in
    the private-key-JWT path reaches rand::rng(), which panics on entropy
    failure. It is reachable only in private-key-JWT deployments, its text carries
    no secret, and short of the Python binding's set_attr! assertions, which can
    fire only under allocation failure, a static-bearer deployment has no
    reachable panic at all. Review found that caveat missing from three committed
    statements, which now carry it.
    Request nonces are guarded instead, through getrandom::fill(...). Both
    bindings contain the unwind rather than letting it cross the FFI boundary; the
    Node crate's doc comment records that its synchronous path redacts panic text
    while napi's async path does not, since the rejection is built inside napi and
    this crate cannot change it.
  • No new secret reaches a log, a snapshot, or a command line, and no demo
    credential, token, live response, or demo-subject identifier is committed.

Decisions that want explicit sign-off

  • The Node binding steps outside the workspace's one security lint. The
    workspace lint table holds exactly unsafe_code = "forbid". forbid cannot be
    relaxed by an inner #[allow], including the #[allow(unsafe_code)] that
    napi-derive emits into its generated FFI registration glue, so a crate that
    inherits the table cannot compile against napi-rs at all. That crate therefore
    omits [lints] workspace = true, with a comment giving the reason, and puts
    #![deny(unsafe_code)] at the top of src/lib.rs instead: its own source
    still cannot contain unsafe code, while its dependencies' generated glue can.
    This is genuinely weaker than what every other crate is held to, and the real
    choice is this opt-out or no Node binding. The Python binding needs no such
    override
    : it keeps [lints] workspace = true and compiles and lints clean
    under inherited forbid, verified with and without the extension-module
    feature.
  • Neither binding is distributable. Both carry publish = false, and this
    branch adds no npm publish workflow, no PyPI upload, no release manifest
    entry, and no cross-platform artifact matrix. Building and testing on the CI
    runner is the bar set here. The consequence: an adopter outside this
    repository cannot install either package, so what merges is a working, tested
    binding usable only from a checkout with a Rust toolchain. Distribution
    carries its own decisions (release provenance, a per-platform artifact matrix,
    who owns the npm scope and the PyPI name, whether either belongs in the
    release manifest) and each wants review on its own.
  • products/evidence/IMPLEMENTATION.md's approved prohibition was amended.
    It forbade creating "client, worker, adapter, policy, credential, or
    interoperability crates in version one". This branch first replaced that with a
    prohibition on decomposing the runtime, which review judged weaker than what it
    replaced: dropping client from an enumerative list read as permitting any
    decomposition not literally named. The clause now keeps the original list with
    client in it and states the distinction that makes this branch legal, which
    is that what it forbids is carving a runtime responsibility out into a separate
    crate rather than shipping a relying-party SDK that adds no Evidence semantics
    of its own. It also names registry-evidence-verifier as the one approved
    decomposition of the runtime and declares it closed, so a future reader cannot
    read the extraction as licence for a second one, and its carve-out covers the
    SDK and both bindings instead of a singular "client library". The reasoning for
    amending at all: the prohibition sits in a section enumerating the runtime's own
    files, and the same document already depends on a separate evidencectl crate,
    so separate non-runtime crates were never what it forbade. It is still an
    approved governance statement being edited.

Compatibility and release

  • A workspace jsonschema change reaches relay, in two directions, and review
    proved the first draft of this note wrong.
    The workspace entry disables
    default features, since every consumer compiles schemas it already holds in
    memory and neither the remote-$ref HTTP client nor the bundled CLI parser
    belongs in the dependency graph. Pointing relay's two pins at that entry drops
    resolve-http and resolve-file and adds draft202012. On main the
    optional dependency took default features while only the dev-dependency added
    draft202012, so relay's tests compiled adopter schemas under a different
    draft than the shipped binary did.

    This note previously claimed such a schema "now fails config validation with
    spdci.config.schema_compile_failed", which the shipped configuration guide
    also claimed. Both were wrong: $ref resolution in that compiler is lazy, so
    a schema carrying an external $ref loads and then fails every record at
    request time, and SP DCI generic search, details, and support answer 500 internal.unhandled for any non-empty result. What the pin genuinely changes is
    that no request is made and no file is read at all, where a default-featured
    build resolved a remote $ref while serving a record. The draft202012 half
    is the one that can silently widen what a schema accepts: a schema declaring
    2020-12 compiled under the draft 7 fallback in the shipped binary, which
    asserts format, and now compiles as 2020-12, which treats format as an
    annotation, so an adopter relying on format to constrain a value needs an
    explicit pattern or enum.

    crates/registry-relay/docs/configuration.md now states both halves, the
    draft each schema compiles under, and the silent draft 7 fallback for an
    uncarried draft such as 2019-09. Eight characterization tests in
    crates/registry-relay/tests/spdci_config_validation.rs pin all of it through
    config::load and the response mapper, including that no request reaches a
    mock upstream serving the referenced document, and each was proven
    load-bearing against a throwaway crate that toggles the compiler's features.
    crates/registry-relay/CHANGELOG.md carries the entry. The adapter is built
    only with --features spdci-api-standards, which released images do not carry
    (crates/registry-relay/canonical-release-features.txt), so this reaches an
    adopter who builds it deliberately. Those tests run in CI through the
    rust-tests job's relay shard, which passes --all-features; the default
    cargo test -p registry-relay compiles them to zero tests.

  • crates/registry-relay/Cargo.toml pins reqwest with native-tls, so a
    workspace-scope build compiles both TLS backends. Verified not to weaken the
    client's trust pinning, since use_rustls_tls() sets the backend that
    build() dispatches on. Named because it is a supply-chain surface the
    repository carries, and explicitly out of scope here.

  • cargo deny check passes with one pre-existing yanked-crate warning,
    spin 0.9.8, reached through phonenumber under relay. It predates this
    branch.

  • The public docs-site pages for the client library are a follow-up branch,
    deliberately.
    This change updates the docs site only where an existing page
    enumerates a file set or a CI job that moved (the spec page's verifier
    sentence, the API stability page's job list). It adds no adopter-facing
    tutorial or reference page for the client. Each of the three crates carries its
    own README, and the client's public API is documented at the item level, so
    nothing here is undocumented for someone reading the crate; what is missing is
    the site page an adopter would find first.

  • One version pin cannot inherit from the workspace.
    crates/registry-evidence-client-py/Cargo.toml declares the SDK under a
    renamed package, so its version is a literal that needs bumping by hand on
    every workspace version change.

  • Deferred with reasons rather than dropped, all in the client crate: a
    backward clock jump can extend a cached credential (closing it needs a
    monotonic instant beside the expiry, which interacts with the injectable
    Clock that makes the cache testable); there is no public invalidate() on
    the token provider, so a caller refused by the resource server cannot force
    re-acquisition (the 24-hour clamp on the issuer's stated lifetime bounds the
    worst case); the provider sends no scope, resource, or body client_id,
    and a scope-gated deployment cannot use it yet; and a string-encoded
    expires_in fails deserialization.

Flagged, deliberately not fixed here

Each of these was found while doing this work or during the whole-branch review
that followed it, and each sits outside what this change owns or is small enough
that fixing it would widen the diff more than it would help. All twenty-nine are
listed; none is rolled up.

  1. crates/registry-manifest-cli escapes the workspace's one security lint
    with nothing in its place.
    It carries no [lints] table and no
    unsafe_code declaration in either src/lib.rs or src/main.rs, so unlike
    the Node binding above it is outside unsafe_code = "forbid" with no
    substitute, and nothing in the repository would notice. Pre-existing. The fix
    is one line in that crate's manifest.
  2. wiremock's set_body_string silently overrides an explicit
    Content-Type.
    In wiremock 0.6.5 it sets its own text/plain mime, and
    generate_response inserts that after cloning explicit headers, so it wins
    regardless of call order. Two instances in this branch's crate were found and
    fixed (they had been passing only because the code under test ignored media
    type); the correct construction is set_body_raw(body, "application/json").
    Exactly two files in the workspace call it. The other is
    crates/registry-evidence/tests/source_contracts.rs, whose six calls set no
    explicit content type, so nothing there is misleading today, but all six do
    serve text/plain and would quietly change meaning if Evidence's source
    fetcher ever gained a media-type check. Latent trap, outside this area.
  3. A latent pre-existing failure at
    crates/registry-relay/tests/demo_configs_load.rs
    , gated behind
    #[cfg(all(feature = "spdci-api-standards", not(feature = "standards-cel-mapping")))], so it does not fire in a default build and did
    not fire in any gate run here.
  4. RequestNonce::parse is public API of the client crate with no production
    caller.
    Either it is intended for relying parties who receive a nonce out of
    band, in which case it wants a doc comment saying so, or it is dead public
    surface. This is also why neither binding surfaces NonceError's
    sub-discriminant: NonceError::NotCanonical is constructed only inside
    parse, so the production path can only ever produce NonceError::Entropy,
    and a nonce_kind attribute would be public surface that can hold one value.
    Both bindings carry a comment at the mapping arm recording that.
  5. Nothing catches a README drifting from what the code does, and one already
    had.
    The Node crate's README told callers to JSON.parse(error.message),
    which stopped being true when the JS layer began reconstructing an error with
    prose in message; a caller following it literally would have hit a
    SyntaxError on the ordinary failure path. Fixed here. The crate's drift test
    keeps hand-written JS in step with the generated native surface and does that
    well, but no equivalent mechanism covers prose. Building one is larger than
    this branch should absorb.
  6. tokio in registry-evidence-client's [dependencies] carries the full
    workspace feature set
    when only sync is needed. Left alone on purpose: a
    local pin diverging from the workspace entry is exactly the pattern that
    produced a reqwest TLS feature-unification surprise during this work.
    Trimming it means deciding how this workspace wants per-crate feature
    subsetting to work.
  7. main bumped the crate versions to 0.17.0 but left v0.16.3 URLs in
    docs/site/src/data/projects.yaml, its generated projects.json, and
    products/evidence/CONCEPT.md. Those look like they belong to a release
    process rather than a version bump, so nothing in them was touched here beyond
    the one rename_status line this change owns.
  8. One version pin sits inside a doc comment. The Node crate's
    panic-containment comment cites napi-derive 3.6.2 when describing what the
    generated glue does for synchronous functions. Accurate today, since the
    workspace pins that exact version, and genuinely useful, since the claim is
    version-specific, but it will rot silently at the first bump.
  9. crates/registry-evidence-client/tests/against_a_real_deployment.rs holds
    three concerns in one file
    (verified exchange, token acquisition, deployment
    lifecycle). The in-file duplication was factored out; the split was not,
    because restructuring a test file mid-branch is churn.
  10. products/evidence/IMPLEMENTATION.md keeps a per-file listing, which
    drifts silently: it had already drifted on main in four ways before this
    branch touched it, and this is the fifth correction. Replacing the inventory
    with a description of the shape would end that, but changing what an approved
    document asserts is a governance call.
  11. The BTreeSet uniqueness tests that pin stable kind() names force a new
    match arm through exhaustiveness, but nothing forces a new case into the
    cases array, so a variant added with a duplicated string would pass. This
    holds for every such test in the crate including the pre-existing one, so it
    is a pattern-level observation rather than a regression.
  12. Nothing enforces that a future wire type uses redacted_debug!; today it
    is convention.
  13. No cross-compile CI step for the client crate, and no decision yet on
    whether the client crate should reuse registry-platform-testing rather than
    carrying its own harness helpers, or on how schemars and utoipa should be
    feature-gated in the verifier crate.
  14. One refusal-path test leans on wiremock's set_body_bytes not setting a
    mime
    , which contradicts wiremock's own documentation for that method. The
    assertion holds under either behavior, so it is benign, but it is a test
    quietly relying on a library disagreeing with its docs.
  15. Both drift tests pin shape, not discriminant values.
    __test__/drift.test.js and tests/python/test_drift.py assert method,
    attribute, and exception class names in both directions, never a discriminant
    string. That is the right division of labour, since the vocabulary is pinned
    exhaustively at its source, but nobody should read the drift tests as
    covering it.
  16. SubjectRequest and EvidenceRequestSpec derive Debug
    (crates/registry-evidence-client/src/prepare.rs:51 and :65), so the
    selector profile and the selector field names survive formatting. Selector
    values are redacted, since SelectorValue is in request.rs's
    redacted_debug! list. The inconsistency is that request.rs:157's test
    asserts the selector profile does not survive formatting for
    EvidenceRequestBody, so two types on one path hold the same value to
    different standards.
  17. Six deserialization-error interpolations can echo a caller-supplied
    value
    , three in each binding's convert.rs. They interpolate a
    serde_json::Error from from_value, which embeds the unexpected value for
    scalar mismatches. The affected fields are the expected outputs, the
    assurance profile, and the trusted JWKS, none of which carries a credential,
    selector value, or binding on the intended path. Sibling sites that
    interpolate serialization errors were checked and cleared, as was
    PrivateJwk::parse.
  18. A one-nanosecond rounding reach in the Python binding's seconds-to-instant
    conversion
    (src/convert.rs:161): the fractional part can round to exactly
    1_000_000_000, which chrono accepts as a leap-second nanosecond rather than
    rejecting. At most one nanosecond of skew on a caller-chosen verifyAsOf
    instant, against validity intervals measured in seconds, with no panic path.
  19. The portability gate is a denylist of eleven packages (axum, clap, fs2,
    hyper, mio, reqwest, rhai, rustix, socket2, tokio, tracing), so a
    runtime-shaped dependency not on the list would pass. Its own logic is sound:
    --edges normal correctly ignores the verifier's dev-only tokio, and it
    distinguishes rg exit 0 from 1 from other, so a broken search fails rather
    than passing silently.
  20. release/scripts/check-release-source-model.sh does not require_path the
    four new crates.
    They are covered by the workspace-wide checks but not
    individually asserted to exist the way other areas are.
  21. products/evidence/IMPLEMENTATION.md:518 still says "Create the single
    crate and binary"
    , which reads oddly beside the crate tree now that the
    verifier is extracted. Scoped to a completed phase, so low risk.
  22. products/evidence/AGENTS.md:16 says "after those four product-level
    contracts" and then introduces seven files.
    Pre-existing on main,
    confirmed there before reporting, but it sits in a file this branch edits.
  23. Root AGENTS.md describes a CI job that does not exist. It says "Root
    CI's rust job runs ...", while ci.yml has rust-policy, rust-quality,
    rust-tests, and rust-result, and the Relay OpenAPI drift check it
    attributes to that job runs in relay-contracts. Pre-existing, found while
    fixing the section immediately below it.
  24. No CI job matches the shape of the Evidence Definition of Done gate list.
    cargo fmt --check is in rust-quality; clippy and test run through
    .github/scripts/run_cargo_packages.py over affected packages rather than
    --workspace; cargo deny check is in rust-policy; the three Evidence
    scripts are in evidence-contracts. Neither Evidence governance document
    mentions the client-bindings job, though the root AGENTS.md now does.
  25. The workspace jsonschema pin's comment overstates its effect for relay.
    It says neither the remote-$ref HTTP client nor the CLI parser belongs in
    the dependency graph. The clap half holds, but
    crates/registry-relay/Cargo.toml depends on reqwest directly and
    unconditionally, so dropping resolve-http removes no crate from relay's
    graph. The behavioral effect is real and now documented and tested; only the
    stated rationale is imprecise.
  26. Other copies of the corrected external-$ref claim may exist. The fix
    pass owns crates/registry-relay/docs/configuration.md only. If v0.16.x
    release notes or any other page repeated "fails config validation with
    spdci.config.schema_compile_failed", that copy is wrong in the same way.
  27. The neutrality gate deliberately excludes two manifests from its
    vocabulary sweep.
    package.json and pyproject.toml are in the
    source-product sweep but not the acceptance-vocabulary one, because their
    SPDX license field matches the licen[cs]e pattern and would fail the gate
    on legitimate metadata. A comment in the script records why. Weakening the
    pattern was not an option.
  28. The Python binding's token-mapping test asserts nothing about stray
    fields.
    Node's equivalent catches an unexpected extra field through the
    serialized JSON object's length; MappedError is a plain Rust struct with no
    direct analogue, and no existing test in that file does an exhaustive-absence
    check for any variant, so the file's own convention was followed instead.
  29. products/evidence/scripts/check-source-neutrality.sh carries two
    pre-existing lint findings
    : shellcheck SC1007 on its CDPATH= cd idiom,
    and one case-indentation difference under shfmt -i 2. Both are
    byte-identical on main, and neither tool runs in any workflow, so the fix
    pass left them alone.

DCO

  • Every commit includes a Signed-off-by trailer. All 45 commits carry
    exactly one, checked individually.
  • I reviewed the submitted changes and am responsible for the contribution.

Comment thread crates/registry-evidence-client-node/index.js
Comment thread crates/registry-evidence-client-py/tests/python/bootstrap.py
Comment thread crates/registry-evidence-client-py/tests/python/test_concurrency.py
Comment thread crates/registry-evidence-client-py/tests/python/test_concurrency.py
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

let key: PublicJwk =
serde_json::from_value(jwk.clone()).map_err(|_| VerificationError::Payload)?;
if key.kty != "OKP"
|| key.crv.as_deref() != Some("Ed25519")
|| key.algorithm().ok() != Some(SigningAlgorithm::EdDsa)

P2 Badge Validate holder cnf key material

When an SD-JWT VC includes cnf.jwk with kty: "OKP", crv: "Ed25519", and alg: "EdDSA" but a missing or malformed x coordinate, serde_json::from_value still constructs a PublicJwk and these checks pass because algorithm() only inspects the metadata fields. That accepts a credential outside the frozen profile's OKP Ed25519 public JWK constraint; parse the confirmation through the same public-key validation used at issuance (or otherwise require the canonical 32-byte coordinate) before returning Ok(()).

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-evidence-client/src/private_key_jwt.rs Outdated
Comment thread crates/registry-evidence-client-node/src/convert.rs
Comment thread .github/scripts/ci_changes.py Outdated
Comment thread crates/registry-evidence-client-py/src/convert.rs Outdated
Comment thread .github/scripts/ci_changes.py Outdated
Comment thread crates/registry-evidence-client-py/pyproject.toml

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f041d1120

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-evidence-client/src/problem.rs
Comment thread crates/registry-evidence-client/src/config.rs Outdated
Comment thread crates/registry-evidence-client/src/client.rs Outdated
Comment thread crates/registry-evidence-client/src/definitions.rs Outdated
Comment thread crates/registry-evidence-client/src/config.rs Outdated
Comment thread crates/registry-evidence-client-py/src/convert.rs Outdated
Comment thread crates/registry-evidence-client/src/prepare.rs Outdated
jeremi added 23 commits August 6, 2026 12:02
Move the Evidence response wire types, the payload contract, the SD-JWT VC
mapping, and the strict relying-party verifier into a new
`registry-evidence-verifier` workspace crate, and depend on it from
`registry-evidence` so every existing path still resolves unchanged. A consumer
that only verifies a stored response no longer has to build the Unix-only axum,
tokio, and Rhai runtime crate.

Security review notes:

- Pure code motion plus re-exports. No verification, signing, evaluation, or
  audit logic changed, and no wire format, schema, media type, or protected
  header value changed. `products/evidence/scripts/check-contracts.sh`
  reproduces the generated contracts bit for bit, so the published Evidence
  payload schema is byte-identical.
- Moved: `verifier.rs` and `sdjwt_vc.rs` in full with their tests, the
  response-side wire types out of `model.rs`, `AssuranceProfile`,
  `evidence_schema` with Evidence payload validation out of `contracts.rs`, and
  the response wire-format constants out of `lib.rs`.
- Widened from `pub(crate)` to `pub`: the `EvidenceVerificationPolicyDocument`
  fields and the expected-subject, expected-output, and expected-form document
  types, which the runtime's local verification command already constructs;
  `ContractValidationError`; and `safe_json_integer`, so the request-side and
  response-side numeric bounds remain one rule. The verifier's own limits and
  internal helpers stay private, and Evidence payload validation stays
  `pub(crate)` in `registry-evidence::contracts`.
- The new crate carries no tokio, axum, Rhai, reqwest, or Unix-only dependency
  and no `cfg(not(unix))` guard, so it can be linked from a client library.
- The new crate's own tests sign their inputs with a test-only fixture issuer,
  because a development dependency back onto the runtime links a second
  instance of the wire types. The runtime signer is still verified against this
  verifier by the runtime's own suite.
- The security and acceptance traceability indexes now name the verifier crate
  for the tests that moved, and the traceability checker accepts both Evidence
  source trees. Every mapped negative and every acceptance row still resolves
  to an executable test.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The Evidence Version 1 boundary statements named one `registry-evidence` crate
and one `evidence` binary with no room for a library beside the runtime. Name
`registry-evidence-verifier` in every one of those statements so the tracked
product material describes the arrangement as it stands.

The framing is the same everywhere: the runtime is still one `registry-evidence`
crate and one `evidence` binary; `registry-evidence-verifier` is the portable
response-verification library the runtime depends on, and it exists so client
tooling can verify a signed Evidence response without the runtime. It is a
library, not a second runtime, and not a pattern of its own.

Amended: the Version-one release scope and fixed decision 12 in
`products/evidence/CONCEPT.md`, the product boundary in
`products/evidence/README.md` and `products/evidence/AGENTS.md`, the Evidence
product boundary and repository map in the root `AGENTS.md`, the service surface
in `spec/rs-pr-evidence`, the Evidence architecture section in `spec/rs-arc-g`,
the crate README, and the Evidence project record in
`docs/site/src/data/projects.yaml`. `docs/site/src/data/generated/projects.json`
is regenerated with `npm run generate`.

`products/evidence/IMPLEMENTATION.md` keeps its phase-one wording, which
describes a schedule deliverable rather than the product boundary.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The portable verifier crate's test-only issuer fixtures now enforce the same
maximum published key count as the runtime signer, with the same
check-before-push semantics, so a fixture-built trusted key set cannot exceed
what a deployment can serve. A focused test proves the bound is live. The
module doc now states exactly what the fixtures mirror (protected header bytes,
signing input, SD-JWT VC issuance shape, published key count) and which
issuer-side configuration guards they deliberately omit (key identifier
validation, the check that the published key repeats the provider's algorithm
and key identifier, and the startup sign-and-verify self-test), with the reason
those omissions are safe for in-process fixtures built from a known good test
key.

The exported redacted_debug! macro now writes ::core::fmt paths, so its
expansion no longer depends on the calling module having std::fmt in scope. The
doc sentence that stated that requirement is gone, and both callers dropped the
fmt import that only the macro needed.

products/evidence/scripts/check-verifier-portability.sh refuses a normal
dependency tree for registry-evidence-verifier that reaches axum, clap, fs2,
hyper, mio, reqwest, rhai, rustix, socket2, tokio, or tracing, over every
target. CI runs it in the Evidence contracts job beside the contract and
source-neutrality checks, and the crate README and the Evidence final gate list
it.

The Rust shard inventory in .github/scripts/ci_changes.py did not name
registry-evidence-verifier, which failed
test_shards_cover_every_workspace_package_once and left the crate outside every
change-classified gate. The crate now sits in the evidence shard, so a
verifier-only change runs the Evidence Rust tests and the Evidence contracts
job.

Security review notes:

- No verification, signing, evaluation, authentication, authorization, audit,
  or data-minimization behavior changes. The fixture bound and the macro path
  qualification are compile-time and test-only concerns; production wire types
  keep the same redacted Debug output.
- The added test key material is a public JWK only, the public half of the
  Ed25519 test key already committed for these suites. No private key, token,
  or credential is added.
- The portability guard is a supply-chain control for a crate that client
  tooling will link: it keeps response verification free of an async runtime,
  an HTTP client, a script engine, a command line parser, and a logging
  framework, so a verifier cannot acquire a network or logging surface by
  transitive dependency.

One correction to the extraction's "pure code motion" framing:
contracts::evidence_contract_accepts is an inlining, not a move. The base
delegated it to a shared contract_validator helper that memoized three
validators behind three OnceLock cells. The portable crate needs only the
Evidence payload validator, so that helper's body is inlined into the function
against the crate's own EVIDENCE_VALIDATOR cell. The draft, the
should_validate_formats(true) setting, the compiled schema, the error mapping,
and the returned Result are the same, so the accept-or-refuse decision is
identical. The helper itself stays in the runtime crate, where
request_contract_accepts and definitions_contract_accepts still call it.

Corrected widening inventory for the extraction, superseding the notes in
"refactor(evidence): extract portable response verifier crate":

- verifier.rs: 26 items went pub(crate) to pub, unchanged in behavior.
- contracts.rs: SCHEMA_DIALECT, EVIDENCE_SCHEMA_ID, REQUEST_NONCE_PATTERN, and
  evidence_schema went private to pub; ContractValidationError and
  evidence_contract_accepts went pub(crate) to pub.
- model.rs: safe_json_integer went private to pub, not pub(crate) to pub, and
  redacted_debug! gained #[macro_export]. The base declared it as a plain
  macro_rules! reachable only inside the model module, so exporting it is a
  crate-root reachability widening. The runtime's model module needs that reach
  to keep applying the macro to the wire types it still declares. The macro
  body is unchanged apart from the ::core::fmt qualification above, and no
  registry_evidence::redacted_debug path exists, so the runtime crate's own
  surface does not grow.
- sdjwt_vc.rs and the crate root widen nothing beyond that exported macro. The
  moved payload helpers, the response media type and header constants,
  AssuranceProfile, and the module declarations that carry them were already
  pub.
- registry-evidence serves every widened name at the path it already had. The
  verifier and sdjwt_vc modules and the crate-root constants come back through
  a pub use in lib.rs, the model types through a pub use in model.rs, and
  AssuranceProfile through a pub use in config.rs. The five contracts.rs names
  the runtime reads outside its tests come back as a pub(crate) use, and
  contracts::evidence_contract_accepts comes back under #[cfg(test)] only, so
  no such path exists in a non-test build of the runtime crate.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The dependency search treated every non-zero search status as "no match", so a
broken pattern or an unreadable tree would have reported a portable crate. Each
package search now separates a match from a clean miss and refuses anything
else, naming the package and the status.

Root guidance lists the portability script beside the Evidence contract and
source-neutrality checks, so a contributor running the documented commands runs
the same gate CI runs.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The gate inventory now names the Evidence verifier portability step, so removing
it from root CI fails the release gate check instead of passing unnoticed.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The crate doc and the README overstated portability. Both claimed the crate has
no platform-specific requirement and can be linked anywhere, but the crypto
stack reaches aws-lc-sys through registry-platform-crypto and through
registry-platform-sdjwt's jsonwebtoken, and aws-lc-sys builds native C. Both
now say what the portability gate actually proves, which is freedom from the
service runtime, and state the real constraint: a build needs a C toolchain and
is limited to the targets aws-lc-sys supports, which excludes wasm32.

The README also claimed the wire types redact their Debug output while only the
runtime crate tested it. A test in this crate now builds one value of every
type it applies redacted_debug! to, from canary strings, and asserts each
Debug rendering carries the placeholder and none of the canaries. Breaking the
macro to print a field makes it fail, so the claim is now guarded where it is
made.

The fixture published-key bound and the verifier trusted-key bound are both 33
with nothing tying them together. MAX_TRUSTED_KEYS is now pub(crate) and the
fixtures assert equality at compile time, so a fixture can never build a key
set the verifier would refuse without someone deciding to let the two numbers
diverge. The assertion lives in the test-only fixtures module, so it is
evaluated in test builds. MAX_PUBLISHED_KEYS stays as its own constant because
it mirrors the runtime signer, which is a separate story from what the verifier
accepts.

The runtime's contracts module re-exported SCHEMA_DIALECT, EVIDENCE_SCHEMA_ID,
REQUEST_NONCE_PATTERN, evidence_schema, and ContractValidationError as
pub(crate), but nothing outside that module reads them. They are now a plain
private use. This supersedes the corresponding line of the re-export notes in
"fix(evidence): tighten verifier fixture fidelity and guard portability": those
five names are private to the module again, not crate-visible. The #[cfg(test)]
pub(crate) use of evidence_contract_accepts stays, because runtime_tests needs
it.

Security review notes:

- No verification, signing, evaluation, authentication, authorization, audit,
  or data-minimization behavior changes. The narrowed use, the pub(crate)
  constant, and the compile-time assertion are visibility and compile-time
  concerns, and the added test asserts existing behavior.
- The corrected portability wording is a security-relevant accuracy fix rather
  than a code change: a reader planning a wasm or toolchain-less client would
  otherwise have trusted a claim the dependency graph does not support.
- The added test builds values from synthetic canary strings only. No key
  material, credential, or real identifier is introduced.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jsonschema 0.18's default features are resolve-http, resolve-file, and cli,
which pull reqwest and clap into every consumer. Nothing in this workspace
resolves a remote or file $ref: the two schemas here that carry an external
$ref supply the referenced document in memory with
JSONSchema::options().with_document, in the registryctl project report contract
test and in the Evidence contracts module. Nothing invokes the bundled CLI
either. The workspace entry now turns defaults off and keeps only draft202012,
which is the dialect every consumer compiles against.

The workspace entry alone was not enough. registry-relay carried two
independent local 0.18 pins, one optional dependency behind
spdci-api-standards and one dev-dependency, both with defaults on, and a
workspace build unifies features across members. Both now inherit the
workspace entry, so the built graph really loses reqwest and clap rather than
just the verifier's own view of it. registry-evidence-verifier's local pin
becomes an inherit too, and its comment about default features moves to the
workspace entry where the decision now lives.

cargo tree -i clap --locked and cargo tree -i reqwest --locked no longer reach
either package through jsonschema from any crate. clap now arrives only through
criterion in dev builds and through the four crates with a command line
interface, and reqwest only through the OIDC and source HTTP paths that ask for
it. Cargo.lock shrinks accordingly.

Security review notes:

- Relay's SP DCI adapter compiles an adopter-supplied response schema from
  standards.spdci response_schema_path at startup. With resolve-http and
  resolve-file off, a schema containing an external $ref now fails
  configuration validation with spdci.config.schema_compile_failed instead of
  fetching the reference. No schema in this tree does that, and refusing to
  make a network request while validating configuration is the safer default,
  but it is an adopter-facing change on a deployment path and the release notes
  should say so. The feature is opt-in and off in relay's default build; it is
  enabled by registryctl's relay dependency and by the demo image.
- No change to authentication, authorization, assertion evaluation, signing,
  audit, or data minimization. Schema validation semantics for schemas without
  external references are identical: draft202012 is retained and the resolve
  features only govern how an external $ref is fetched.
- Removing an HTTP client and an argument parser from the dependency graph
  narrows the attack surface of every binary that compiles a JSON schema.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The redaction test built UnsignedEvidenceEnvelope with its real schema
constant, so the only strings it could leak were that constant, two enum
discriminants, and a nested Evidence that redacts itself. Dropping the envelope
from redacted_debug! would have left the test green. Its schema field now
carries a canary, which makes the envelope's own redaction load-bearing:
deriving Debug for the envelope and removing it from the macro list fails the
test with the canary in the panic message.

The portability prose named aws-lc-sys without saying how the crate reaches it.
Both the crate documentation and the README now name the two edges,
registry-platform-crypto's use of aws-lc-rs for RS256 key handling and
verification and registry-platform-sdjwt's through jsonwebtoken, so a reader
debugging a cross-build sees where the constraint enters without tracing the
dependency graph. Commit bodies get squashed, so the prose is the durable home
for this.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The SP DCI adapter compiles an adopter-supplied response schema at startup with
no remote or file reference resolution, so an external $ref fails configuration
validation instead of being fetched. The SP DCI section documented
response_mapping_path's feature requirement but said nothing about this, leaving
an adopter to discover it from an error code.

The guide now states the constraint beside the mapping-path note: the schema
must be self-contained, internal references resolve normally, and an external
http(s) or file target fails with spdci.config.schema_compile_failed. It also
states the reason, that validating configuration never makes a network request.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Adopter tooling beside the runtime, like registry-evidencectl: it requests
a signed assertion over the public HTTP contract and links
registry-evidence-verifier for every judgement about the answer. It
re-implements no part of evaluation, signing, or verification, and it sits
outside the frozen Version 1 runtime contract.

The exchange is prepare, send, verify. prepare generates the nonce and
closes the verification policy before any byte leaves the process, so a
response is judged only against expectations that predate it. There is no
retry at any layer: a second attempt is a second prepare with a fresh
nonce.

Security-sensitive surfaces that need review notes:

- Policy construction. prepare builds the whole
  EvidenceVerificationPolicyDocument, including the evidence type, issuer,
  provider, configuration revision, assurance profile, expected outputs,
  lifetime bound, and clock skew. Anything omitted or widened here is an
  expectation the verifier will not enforce.
- First-use subject acceptance. Subject bindings are keyed by a secret only
  the deployment holds, so a relying party cannot derive the binding for a
  subject it has never seen, and the verifier requires exact subject-set
  equality. SubjectExpectations::Pinned is the only setting under which a
  verified response proves the assertion is about the intended subject.
  AcceptFirstUse copies the response's claimed bindings into the policy and
  then runs the ordinary verifier; it adopts bindings only for exactly the
  requested roles, once each, and adopts nothing otherwise so the verifier
  refuses. No verifier bypass was added.
- Credential handling. Tokens live in a wiped buffer, are marked sensitive
  on the outbound header, and never reach an error, a Debug rendering, or a
  log line. Response bytes and header values are withheld from diagnostics;
  a failure carries only the deployment's operation identifier, accepted
  after a bounded alphanumeric check.
- Response bounds. Every body is read under a caller-configured byte bound
  before parsing, redirects are disabled, rustls is selected explicitly, and
  a pinned certificate bundle disables the platform trust store.

The integration suite drives the real evidence runtime in-process over
loopback, so discovery, the request contract, the problem contract, and
verification are proven against the runtime rather than a stub. The crate is
added to the evidence CI shard and to the source-neutrality scan.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Loopback HTTP now covers the forms an adopter actually types, a prepared
request enforces its one-send rule instead of only documenting it, a
failure keeps the deployment's correlation identifier when the problem
body is unreadable, selector integers get the same local pre-flight
strings already had, and a bounded transient failure surfaces the wait
the problem contract permits it.

Security-sensitive review notes:

- The single-send claim is taken before any I/O and is spent even by an
  attempt that fails on the wire, because the deployment may have
  answered a request whose answer the relying party never read. A resend
  would earn a second source access and a second audit entry there for
  one relying-party decision, and the request contract states the nonce
  is never uniqueness-checked. `PreparedEvidenceRequest` is no longer
  `Clone`: a clone would carry the same nonce with its own unclaimed
  flag, which is the reuse the flag exists to prevent. Verification
  stays unrestricted, being offline and idempotent.
- The correlation identifier from the response header is held to the same
  bounded-alphanumeric rule as the body's own, so a hostile header value
  is dropped rather than copied into a relying party's records. The
  body's value still wins when it is readable and usable.
- `format: uri` is deliberately not pre-flighted. The deployment asserts
  it, and a second opinion from a URL parser could disagree with a JSON
  Schema `uri` implementation in either direction.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A failed body read told every adopter their response was too large, even
when the cause was the request timeout elapsing mid-body, which is the
likely failure because the total timeout runs until the body finishes.
The same call site threw away the status and the correlation identifier
it already held. An empty pinned key set was accepted and then refused
once per request inside the verifier. A doubled separator in the base
path put "//" in every request path.

Security review notes:

- Transport classification now matches the reader's own error: the three
  size variants stay ResponseTooLarge, a timeout is reported as a
  timeout, and anything else, including a variant this crate does not
  know yet, is the coarse exchange failure. No part of the underlying
  error text reaches a diagnostic, so the classification is the only
  thing that changed.
- A non-2xx answer whose body cannot be read now reports the status and
  the deployment's identifier instead of a transport failure. Both were
  read from headers before the body was touched, and neither is response
  content. A 2xx read failure stays a transport failure, since there is
  no code worth reporting, only the reason the bytes never arrived.
- The pinned key set is the load-bearing trust decision, so an empty one
  is refused at construction. The verifier's per-request refusal is
  unchanged; this only stops the failure from looking like a deployment
  fault.
- One sanitizing rule for the correlation identifier, applied to both
  the problem body's member and the response header, replacing two
  copies that had already drifted. The value is judged exactly as
  received: HTTP field parsing has removed the whitespace the grammar
  permits, and trimming would rewrite a value the deployment chose
  rather than refuse it. A hostile header value is dropped, proven end
  to end.
- New security-path tests: transport classification, refusal of unusable
  pinned certificate material at construction, a redirect surfacing as a
  protocol failure with zero requests reaching the redirect target, and
  the configured user agent reaching the wire.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Narrow what the crate exposes so a future language binding has a small,
stable surface to carry across a boundary, and make each refusal name the
field a caller has to fix.

Surface:
- the problem-mapping module and the request wire types become crate
  internal; only SelectorValue stays public, because a caller supplies
  selector values
- lib.rs re-exports the request bounds beside the configuration defaults,
  so every value a refusal can spell is nameable
- EvidenceClientError::kind returns a stable machine-readable discriminant
  for metric labels, structured log fields, and binding boundaries
- verify_at becomes the public verify_as_of, for re-verifying a retained
  response at a decision instant

Clarity:
- one get_json helper behind discover and fetch_jwks
- a named Credential enum instead of a bare boolean at the exchange call
  sites
- the six identifier checks each carry their own reason
- RequestNonce::parse no longer implies a seam for an outside nonce
- rationale for refusing redirects and ignoring proxy environment
  variables, in code and in the README
- the README and crate docs state that the HTTP methods need a
  tokio-compatible reactor

No security-relevant behavior changes: the refusal set, the credential
handling, the redirect and proxy policy, and the verification seam are all
as before. The media type comparison drops two allocations while staying
case-insensitive over the essence, which two cases now pin.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Name the offending base URL when a validation case is expected to fail:
`expect_err` is not a format macro, so the placeholder was printed
literally.

State in the `Transport` doc that the four causes arrive in one variant but
stay distinguishable through `kind`, since the previous wording read as if
they were indistinguishable.

Cover the two gaps the tests left open: a PEM block whose body is outside
the base64 alphabet, which is the only input that reaches the "not readable
PEM" refusal, and a differently cased response media type on the success
path, which shares its comparison with the problem contract.

Check the base URL scheme before its path, so a URL that is wrong in both
ways names the transport that cannot protect the credential rather than a
path detail. That ordering is the only behavior change here.

Warn in `verify_as_of` about the stale instant, which accepts an assertion
whose validity interval has elapsed, and say that a live decision calls
`verify`.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Add a PrivateKeyJwt token provider to the Evidence client SDK so a relying
party can obtain its own bearer credential instead of being handed one. It is
plain OAuth 2.0: the client_credentials grant with the private_key_jwt client
authentication method of RFC 7523 section 2.2, carrying no claim, route, or
vocabulary belonging to any particular authorization server.

The outbound construction rules the Evidence request already followed now live
in one internal module, so the token request cannot drift from them: rustls,
no redirects, no ambient proxy, no transport retry, caller-set timeouts,
pinned certificate authorities, and the same loopback-only exception for
cleartext.

Security-sensitive surfaces for review:

- Client key handling. The signing key is held for the life of the provider,
  withheld from every Debug rendering, and never serialized or logged. An
  unusable key, an unprotected token endpoint, or an out-of-range lifetime is
  refused at construction rather than once per request.
- Assertion signing. Each token request signs its own assertion with a fresh
  ULID identifier and a short lifetime, so a captured assertion is worth one
  attempt inside that window and a replay-checking server can refuse a repeat.
  The assertion is built in a scrubbed buffer, but the copy the HTTP client
  owns as the request body cannot be wiped, and neither can the intermediate
  signing buffers. Memory hygiene is therefore partial by construction, and
  what actually bounds a leaked assertion is that it is single use with a
  sixty-second default lifetime. The call site says so where the copy is made.
- Token caching. A credential is reused until it has less life left than the
  refresh margin, and concurrent callers wait for one request rather than
  opening one each. A credential with no stated lifetime is not cached. A
  refusal reports the registered OAuth error code alone: the server's
  error_description is dropped where the body is parsed.

Offline tests cover the claim set, the header, per-request identifiers, the
cache boundary against a movable clock, single flight under concurrency, the
refusal matrix, and redaction. The integration suite adds a real authorization
server on its own loopback origin beside the real Evidence deployment, and
proves the whole chain: acquisition, reuse inside the window, replacement
inside the margin, and refusal of a client whose key was never registered.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…crates

The Version 1 product shape section described a tree that no longer exists.
Three distinct problems, kept apart here because they carry different weight.

Made stale by the client library work on this branch:

- The opening sentence said version one is one crate and one binary, and the
  listing placed verifier.rs under the runtime crate. Response verification now
  lives in the portable registry-evidence-verifier library.
- The prohibition said "Do not create client, worker, adapter, policy,
  credential, or interoperability crates in version one", and this branch adds
  registry-evidence-client. The word client leaves that list. The sentence sits
  in a section that enumerates the runtime crate's own files, beside a sentence
  about Rhai adapters not becoming crates, so it reads as a ban on decomposing
  the runtime into a service-oriented crate constellation rather than a ban on
  shipping a relying-party library. It is reworded to say that, and the
  amendment names the two non-runtime crates and binds them: they sit outside
  the frozen Version 1 runtime contract, delegate every Evidence semantic
  decision to the runtime or to the portable verifier, and add no Evidence
  semantics of their own. products/evidence/AGENTS.md already states the same
  boundary for both crates.

Already stale before this branch, corrected while the listing is open:

- local_verification.rs and observability.rs were missing from src/, though
  both are production modules declared in lib.rs.
- relay_shaped_source.rs was missing from tests/.
- registry-evidencectl was absent from the shape entirely, although this
  document's own Definition of Done depends on evidencectl build, doctor, and
  fixtures run.

Left alone deliberately: the Phase 1 line "Create the single crate and binary
with typed domain models" describes creating the runtime and is still true, and
"The binary exposes four commands" still describes the public surface because
the local verification helpers are hidden subcommands.

Test-only modules stay out of the listing, matching the existing convention
that omitted runtime_tests.rs; the verifier crate's fixtures.rs is omitted for
the same reason. Every path in the listing was checked against the tree.

This edits an approved governance statement rather than a descriptive one, so it
needs explicit review at merge.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
TokenError is non_exhaustive with six variants that only Display text
told apart, so external bindings (Node/Python) had nothing stable to
match on. Add TokenError::kind(), mirroring the existing style at
EvidenceClientError::kind(): rendered text stays free to reword, the
six kind names (unavailable, invalid_credential, configuration,
transport, refused, protocol) are the contract. This touches an
authentication error surface and needs explicit review; kind() only
exposes the existing discriminant, it does not add or widen anything
a caller could not already learn from matching on the variant itself,
and it changes no error content, credential handling, or zeroization.

Also, four smaller test-accuracy fixes:

- Tie the assertion-lifetime refusal message to
  MAXIMUM_ASSERTION_LIFETIME_SECONDS with a const assert, so a future
  change to the constant fails the build instead of leaving the
  message wrong.
- Narrow the one pinned-CA test row whose refusal reason depends on
  Cargo's feature unification across the workspace to assert only the
  Configuration variant; the other rows keep their exact-string
  assertions, and fail-closed construction stays proven for all rows.
- Correct a comment claiming a refresh-margin test path was
  uncacheable; the credential is cached, the configured margin just
  makes it unusable immediately.
- Narrow a test doc comment that claimed no request reaches the
  deployment on an unregistered client key. The test only proves the
  token issuer's own audit chain issued nothing; it does not observe
  the deployment's request count, so the comment now says that
  explicitly.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
TokenError::kind() let bindings distinguish token failures without
matching a #[non_exhaustive] enum. TransportKind and NonceError are
also #[non_exhaustive] and had no equivalent, so a binding could not
tell a connect failure from a timeout, or bad entropy from a
noncanonical nonce, despite the crate's own rustdoc promising that
TransportKind tells transport failures apart. VerificationError is not
#[non_exhaustive], so a binding can match it directly, but it carried
no stable string either: left alone, each binding would invent its own
verification-failure name and the two would drift apart. All three now
carry a kind() accessor in the same shape as TokenError::kind(), and a
test that constructs every variant and asserts the kinds are pairwise
distinct.

Security review: kind() exposes only which closed variant an error is,
the same information a caller already has from matching the variant
where the type permits it. No remote-controlled text (a response body,
a header, a credential) reaches any of these accessors; every arm
returns a fixed string chosen in this crate.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
An authorization server's expires_in was cached without an upper bound,
so a value such as i64::MAX kept a credential in memory for the life of
the process with no way for the integrator to evict it. Clamp it to
MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS (86400 seconds) before it reaches
the cache arithmetic; re-acquiring earlier than an issuer's stated
lifetime requires is always safe.

The refusal parser also read any 400 or 401 body as JSON regardless of
its announced media type, so an intermediary returning a non-JSON error
body could be misread as a registered OAuth refusal code. declined() now
requires the same media type the request asked for and falls through to
a protocol failure otherwise. This changes which error an adopter sees
for a proxy or gateway failure; it never widens what a refusal discloses.

Also adds a test pinning that dropping an acquisition future while it
holds the refresh lock does not leave the lock held: tokio's async mutex
is not poisoned on a guard drop, so the next caller still completes.

Security review: the expires_in clamp only shortens how long a remote-
controlled value can keep a credential cached; it cannot lengthen a
deployment's actual token lifetime or expose the credential differently.
The media type gate only changes which TokenError variant a non-JSON
400/401 body maps to (Protocol instead of Refused); it does not change
what is read from the body or disclosed to the caller in either case.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The module doc overclaimed that any server accepting this grant and
authentication method would work; the request body carries only
grant_type, client_assertion_type, and client_assertion, so a server
that also requires a scope, a resource indicator, or a body client_id
needs support this provider does not offer.

The bearer_token() comment described the wait for an in-flight token
request as bounded by the request timeout. It is bounded by the number
of waiters ahead of a caller times that timeout, and the freshness
check only spares a waiter its own request when the caller ahead of it
actually cached something.

with_assertion_lifetime_seconds and with_refresh_margin_seconds did not
document their accepted range, though an out-of-range value only
surfaces as a refusal later, when the provider is built. TokenProvider
did not note that implementing it outside this crate requires the
async-trait dependency directly. The README described the refresh
margin backwards: a credential is replaced once it enters the margin,
before it actually expires, not "before the refresh margin".

No behavior changes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The AGENTS.md statement that Evidence does not depend on Mint no longer
held once registry-evidence-client added registry-mint as a
dev-dependency, to drive a real Mint instance in its own tests. Narrow
the claim to production: no Evidence crate depends on Mint at runtime,
and Evidence test code may depend on Mint to prove a client against a
real authorization server.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…nt tests

Deployment and TokenIssuer each reserved an ephemeral loopback port the
same way and each stopped their spawned service the same way in Drop.
Factor both into reserve_loopback_port and stop_service, used by both
harnesses in this integration test file.

The closed_loopback_origin helper in src/client.rs is a separate
compilation unit with its own #[cfg(test)] constraints and is left
alone; this refactor is scoped to tests/against_a_real_deployment.rs.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi added 2 commits August 6, 2026 12:22
The development workflow gained a `clients` job, so the structure test's job
list, and its assertion on the notes' installer URL, no longer described the
workflow. Restate both, and cover what the job now owes the closed asset
roster: the three platform legs, a wheel name predictable from the source, a
platform-specific binding inside the packed tarball, and two offline smokes
that carry no credential.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The stub server resolved a route by indexing the caller's plain object with a
request line, so a request could reach an inherited `Object.prototype` member
instead of a stub route. Hold the routes in a `Map` and require a function,
which answers 404 for anything unrouted.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d542eb31c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-evidence-client/src/client.rs Outdated
jeremi added 11 commits August 6, 2026 12:44
A PEP 561 checker ignores a committed stub in a package with no py.typed
marker, so every installed client API resolved to Any and the __init__.pyi
drift test protected nothing a consumer could see. Verified against a built
wheel: the marker ships beside the stub and the compiled submodule.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Both bindings are Cargo path dependents of the client SDK and the verifier,
so either can move the native surface or the error envelope the packages
wrap. Selecting the job from changed paths alone skipped the npm suite, the
type-drift check, and the Python unittest suite for exactly the changes most
able to break them, and ci-result treats a skipped job as passing.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A `token` object naming both `static` and `privateKeyJwt`, or carrying a
stray key beside a real provider, selected whichever branch was tested
first. A botched merge of two authentication configurations, or a
misspelled provider name left beside a real one, would then run with a
credential the caller did not choose. Require exactly one provider.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The frozen problem contract registers nine (status, code) pairs, and the
status belongs to the pair: a code is registered for one status, and two
codes may share one. Mapping whatever code arrived under whatever status
let an unregistered code, or a registered one under a status it was never
registered for, reach a caller as a refusal it could act on, and put a
deployment-chosen string in `code`. Such a body is a promise the
deployment never made, so it now becomes an uninterpreted protocol
failure carrying no code and no retry hint.

`type` and `title` stay unvalidated on purpose: the contract does not pin
the `type` URI (only generated OpenAPI does), and `title` is human-facing
text a deployment may word or localize as it chooses.

Review note: this only narrows which bodies become a refusal. No body
that was previously uninterpreted can now be read as one.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A deployment keys its authorized shapes on requirement, purpose, and
selector profile together, so one requirement identifier may carry several
definitions, and two such entries are distinct items the contract's
`uniqueItems` does not stop. `definition()` answered with whichever shape
happened to serialize first, which had a relying party author a request,
and close a verification policy, for a purpose it never chose. Nothing
downstream would catch it: verification passes, because the deployment did
issue for that purpose.

It now answers `None` when more than one shape matches. `definitions`
stays public, so a caller that wants to disambiguate on purpose or
selector profile still can.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The cached access token's deadline was a wall-clock instant, so
correcting the host clock backwards extended how long the client kept
presenting one credential, past the lifetime the authorization server
granted. The deadline is now a reading that only moves forward, which no
clock adjustment can push out.

That reading excludes machine suspend, where wall clock did not, so a
host resuming from a long suspend may treat a credential that is still
live as spent. That direction fails closed: the next caller acquires a
replacement.

`PrivateKeyJwtConfig`'s `Debug` also rendered the token endpoint
verbatim, including any userinfo an integrator put in it. It now strips
userinfo the same way the base URL rendering does, from the same helper,
so the two cannot drift apart.

Review note: authentication credential lifetime and credential
redaction. Both changes fail closed.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`PreparedEvidenceRequest::new` refused only a zero assertion lifetime, so
a caller could close a policy the deployment's own verification policy
contract would reject: a lifetime past the contract's one-year ceiling, a
clock skew past 300 seconds, or a list-form expected output whose
cardinality sat outside 1..=64 or stated a minimum above its maximum. A
minimum above its maximum can never be satisfied at all, so accepting it
only deferred the failure to a place the caller could not diagnose.

Each ceiling is now a named constant, pinned to the number its refusal
message states by a compile-time assertion, so the two cannot drift. The
tests exercise each edge from both sides: one step past is refused, and
the edge itself stays accepted.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The rules a trusted key set must satisfy lived inside the verification
path, so the only way to learn a pinned set could never verify anything
was to attempt a verification. A relying party configuring a client wants
that answer at construction, and restating the rules there would let the
two copies drift.

`trusted_keys_are_usable` asks the existing rule once, at the point the
set is built, and carries no detail beyond usable or not, so a caller
cannot surface key material by rendering the refusal.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Two ways a caller's own value could reach a place the bridge did not
survive.

`python_to_json` followed an arbitrary Python object graph with no depth
limit. Unlike a JSON document, nothing bounded that graph on the way in:
it may nest far past anything a parser would have accepted, and it may
hold itself. A mapping that holds itself, which is a single readable line
of Python, descended until the process died. One finite depth bound
refuses both, since a cycle is exactly a graph that descends without end;
it mirrors `serde_json`'s own recursion limit.

`duration_from_seconds` refused a negative, infinite, or `NaN` input and
then called `Duration::from_secs_f64`, which still panics on a finite
value larger than the seconds a `Duration` holds.
`Duration::try_from_secs_f64` reports all four as errors, so the bridge
now asks it once instead of screening for a subset.

Both tests pin the accepted edge as well as the refusal, so a later
tightening cannot pass by refusing everything.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
… made

Two fixes in `EvidenceClientConfig::validate`, which share the function and
so share a commit.

The pinned key set was checked only for emptiness, so a set the verifier
could never use (a private key, a missing or duplicate `kid`, an algorithm
other than EdDSA) was accepted at construction and failed once per
request, where it reads to an adopter as a deployment fault rather than
their own configuration. `validate` now asks the verifier's own rule
instead of restating it, so the two cannot drift, and discards its detail
so no refusal can render key material.

The single response bound also governed the discovery document and the
published key set. Its default is derived from what the verifier will
accept as a signed response, and neither of those documents is signed or
verified, so that reasoning never reached them: a relying party tightening
the bound to what its own assertions need would silently lose the ability
to read discovery. A separate metadata bound now covers the two unsigned
documents, with the same 256 KiB default justified on its own terms
(roughly five hundred definitions, past what a deployment publishes, and
raisable). Both bindings expose it, since both already exposed the
response bound.

Also drops `config.rs`'s copy of the userinfo stripping helper, which the
token endpoint redaction fix moved into `outbound` for both callers.

Review note: data minimization (the key set refusal carries no key
detail) and a bound on unauthenticated remote input.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Every other test in the suite imports the compiled extension straight from
the bootstrap's own directory, as a top-level module. The hand-written
`__init__.py`, which makes the directory a package and re-exports the
extension as its submodule, was therefore never executed by any test, even
though that is the only shape an installed wheel presents and the file
does non-obvious work: it drops the submodule's own name so the surface
matches the committed stub, and claims to stay correct under a reload.

This assembles that layout from the cdylib the bootstrap already built and
imports it in a subprocess with nothing else on the path, then checks each
claim the file makes, that PEP 561's marker ships beside it, and that a
refusal still reaches the caller as `ConfigurationError`. Assembling the
top-level-extension layout instead fails it with the missing submodule, so
the test distinguishes the two.

Building an actual wheel would need maturin, which this crate's checks
keep out of CI, so the layout is reproduced rather than built.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af2aa00ee4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-evidence-client/src/problem.rs
Comment thread crates/registry-evidence-client/src/prepare.rs
Comment thread crates/registry-evidence-verifier/src/lib.rs
Comment thread crates/registry-evidence-client-node/src/convert.rs
jeremi added 3 commits August 6, 2026 13:59
The consumer tutorial states the verification boundary abstractly. A relying
party still has to decide where the trusted key set, the request policy, and
the subject binding live in its own program. Walk that journey once with the
Python client: give the application its own identity, pin the issuer keys out
of band, build the expectations while the answer is still unknown, and read a
value only after offline verification returns.

The page is deliberately not registered in EVIDENCE_TUTORIALS: the executable
tutorial gate mounts the repo read-only and injects only the Evidence
binaries, so the Python extension module cannot be built inside it.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Copying a 64-hex configuration revision into source by hand is a poor first
experience and invites the reader to keep re-copying it whenever verification
starts failing. Save the discovery document instead, transform it offline into
a procedure.json the application owns, and load that file at startup.

The pinning act stays explicit and still happens before any answer is read; it
is only automated instead of transcribed. Regenerating is documented as a
review step, with the diff to look at, not a retry.

expected_outputs stays hand-written: a concept's published form and a
verification expectation's form are separate vocabularies, so deriving one
from the other would work for this requirement and mislead on the next. The
generator checks the concept identifiers against discovery so a deployment
that stops publishing one fails at review time.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Review surfaced eight defects, each confirmed against source:

- the policy and client ids collided with the access-control tutorial, so
  authoring refused for a reader who had followed the other page; both pages
  now carry their own ids and this one says why they compose
- the source clone was unpinned, so a reader could build the client from a
  tree that need not match the installed evidencectl; it now pins the tag of
  the installed version
- the review step accepted a subset of the published concepts, which the
  verifier rejects at request time; it now requires the exact set
- the application hard-coded the selector profile instead of reading the
  shape it had reviewed
- the binding-stability prose named only subject and audience, while the MAC
  also covers purpose, role, selector profile, and the deployment's binding
  key and key version
- the bindings store dropped a concurrent run's entry, and its replacement
  stays owner-only whatever umask the shell carries
- the page never restarted the registry the prerequisite leaves stopped, and
  evidencectl dev reports ready without reaching the source
- the error-mapping prose implied any body code maps, where the contract
  honors a code only under a status registered for it

Verified by replaying all thirty-one fences against binaries built from this
checkout: every documented output matches apart from the deliberate revision
placeholder. docs/site npm test and npm run check pass.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi enabled auto-merge (rebase) August 6, 2026 07:29
@jeremi
jeremi disabled auto-merge August 6, 2026 07:32
jeremi added 4 commits August 6, 2026 14:48
A deployment advertises `signing.verifierClockSkewSeconds` so a relying party
can adopt it, and a relying party expresses what it adopted as the
verification policy's `clockSkewSeconds`. The two bounds disagreed: the bundle
contract and startup validation accepted up to 600 seconds, while no
conformant verification policy could express more than 300, so a deployment
could advertise skew advice its own relying parties had to refuse. The bundle
contract and `SigningConfig::validate` now carry the policy's 300.

A test reads both maxima from the contracts rather than restating them, and
pins startup validation to the contract at the boundary and one above it, so
moving either bound alone fails.

Security review notes: this narrows a deployment acceptance bound, it does not
widen one. Clock skew only ever widens the window in which an assertion is
accepted, so the affected direction is the safe one: a bundle that previously
validated with 301..=600 now fails at startup instead of advertising
unusable advice. Every `verifierClockSkewSeconds` in tree is 30, and no
generated contract artifact or published document restates the old maximum, so
nothing else moves with it. Signing, verification, and evidence construction
are untouched.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The Rust SDK's `RawEvidenceResponse` offers `body()` and `operation()` so a
relying party can retain the exact bytes it verified and correlate a failed
exchange with the deployment's audit trail. The Node binding wraps both as
getters; the Python binding exposed neither, so the same response object was
inspectable in one language and opaque in the other. A thin binding follows
its core: divergence belongs in the core, not in one language surface.

Python now exposes both as read-only attributes, the shape `VerifiedEvidence`
already uses in this crate, with the committed stub updated to match. The
existing drift test holds the stub and the compiled surface together in both
directions, and new tests assert the body is exactly the served bytes, the
operation is the correlation identifier the response carried (`None` when it
carried none), and neither reading can be reassigned.

Reading either one still judges nothing: `verify` remains the only thing that
decides whether the bytes are trustworthy, and the class docstring says so.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The gate searched with ripgrep, which the hosted runner does not have. An
absent command exits 127, and `if rg ...; then fail; fi` reads any non-zero
status as "found nothing", so every run on that runner reported a clean tree
after printing four `rg: not found` lines and reading nothing. The same
construct also swallowed a path that had been renamed away from under it.

It now searches with `grep -E`, the way its sibling
check-verifier-portability.sh already does for exactly this reason, and treats
every status above 1 as a broken check rather than a clean tree. Each path it
names explicitly must exist, each root it sweeps must exist, and both an empty
source enumeration and empty masked text are failures, so the gate can no
longer pass by searching nothing.

Because its subject is text rather than a program's behavior, nothing else in
the tree proved it could still fail. A self-test now builds a sandbox tree in
the shape the gate expects and plants one violation at a time: 18 cases cover
each of the three sweeps, the test-only exemptions that must keep passing
(`#[cfg(test)]` items and `*_tests.rs` files), the package-manifest exclusion
that keeps an SPDX license field from matching the licence pattern, a named
file and a named root that disappeared, both empty-enumeration cases, and a
clean tree and a planted violation under a PATH holding only the system
directories, which is where the ripgrep regression would have surfaced. CI
runs the self-test immediately before the gate.

The gate's header comment also described the masking as exempting comments and
string literals, which it never did: masking exists only to locate the braces
that close a `#[cfg(test)]` item, and the emitted text keeps comments and
literals. The comment now says what the code does, and the self-test pins both
behaviors.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The two binding crates' committed `policy.json` named a ten-year
`maximumAssertionLifetimeSeconds`, where the frozen verification-policy
contract bounds that field at one year. The fixture therefore modelled a
relying party's policy no conformant relying party could have written, and
nothing in either crate noticed.

The fixture window is now thirty days, and a new test in each crate validates
the committed document against
`products/evidence/contracts/verification-policy.schema.yaml`, so the fixture
cannot drift outside the contract again. The long window existed to keep the
committed response verifiable against the wall clock indefinitely; the checks
that read the committed response now verify at a pinned instant one day past
the signing instant instead, and the real-clock path through `into_policy` and
`verify_flattened_jws` is covered by signing fresh evidence in-memory. Only
`tests/golden_fixture.rs` in each crate and the Python crate's stale-fixture
negative case read the response and policy fixtures; the JS and Python suites
read only `jwks.json`, so no language-level test depends on the window.

The Python stale-fixture case now verifies through `verify_as_of` at that same
pinned instant and asserts the failure class is `policy`. Against the wall
clock it would have started failing for expiry once the shorter window elapsed,
while still passing, and the reason it exists to prove (a response signed for a
different nonce is refused) would have stopped being tested.

All six fixture files are regenerated with their documented generator commands,
never by hand; regeneration mints a fresh key, so `jwks.json` and
`response.jws.json` change in both crates.

Security review notes: no production code changes. The bound moves in the
restrictive direction only, from a value the contract forbids to one it allows,
so no policy that verified before is newly rejected. The added negative-case
assertion pins the generic `policy` class rather than any field-level detail, so
re-verification stays a non-oracle for which hidden comparison failed. No
credential, token, live response, or demo-subject identifier is committed: the
fixture keys are generated per run and only their public halves are stored.

Related gap, not addressed here and out of this change's scope: nothing
enforces the contract's `maximumAssertionLifetimeSeconds` bound when an
`EvidenceVerificationPolicyDocument` is deserialized, so a relying party can
still express in code a policy the contract forbids and the verifier will
honour it. That is the defect this fixture was a symptom of.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi enabled auto-merge (rebase) August 6, 2026 08:17
@jeremi
jeremi merged commit 53d3b8c into main Aug 6, 2026
43 checks passed
@jeremi
jeremi deleted the feat/evidence-client-library branch August 6, 2026 08:21

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

while cursor < len(code):
if code[cursor] in "{;":
opener = code[cursor]

P2 Badge Stop cfg field masking at the field comma

When #[cfg(test)] annotates a struct field, the item ends with ,, but this scanner recognizes only { and ;; it therefore skips the struct's closing brace, treats the next function or impl body as the test item, and removes that production code from the neutrality sweep. A prohibited source-product branch placed immediately after such a struct can consequently pass the gate, so field commas (or Rust item structure generally) must terminate the masked span.

AGENTS.md reference: products/evidence/AGENTS.md:L52-L64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if let SubjectExpectations::Pinned(pinned) = &spec.subject_expectations {
let pinned_roles: BTreeSet<String> = pinned
.iter()
.filter(|subject| !subject.binding.is_empty())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate pinned subject bindings before sending

When a caller pins a nonempty malformed binding such as raw-subject-id, this check accepts it even though the verification-policy contract requires urn:evidence:subject:v<version>_<43 base64url chars>. No conforming response can match that policy, but the client still sends the protected request and triggers source access before verification fails; validate the complete binding pattern during prepare.

AGENTS.md reference: AGENTS.md:L98-L102

Useful? React with 👍 / 👎.

Comment on lines +48 to +51
The `denied`/`protocol` split is a hazard worth calling out explicitly: HTTP
401, 403, and 429 all map to `denied` regardless of the response body's own
`code`, while every other non-2xx status (including 400, 500, and anything
else not specifically recognized) maps to `protocol`. A caller that only

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Correct the documented problem-kind mapping

When an integrator follows this mapping, it will branch incorrectly: a contract-valid 422 evidence_not_available response maps to not_available, not protocol, while a 401/403/429 body with an unregistered status/code pair maps to protocol, not always denied. The Python binding README repeats the same statement, so both should describe the registered-pair mapping implemented by problem.rs.

Useful? React with 👍 / 👎.

Comment on lines +194 to +196
evidencectl request prepare adult-status \
--purpose age-check \
--subject person_id=person-123 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep demo subject identifiers off command lines

When a reader runs this tutorial, the demo subject identifier is placed in shell history and the process argument list; the later python3 age_check.py person-456 command repeats this and the sample application prints the identifier as well. These local identifiers are not covered by either public-demo exception, so read them from an ignored owner-only input and avoid echoing them rather than teaching a command-line/logging pattern the Evidence guidance prohibits.

AGENTS.md reference: AGENTS.md:L104-L108

Useful? React with 👍 / 👎.

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.

2 participants