Skip to content

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

Open
jeremi wants to merge 45 commits into
mainfrom
feat/evidence-client-library
Open

feat(evidence): add a relying-party client library with Node and Python bindings#649
jeremi wants to merge 45 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.

jeremi added 30 commits August 5, 2026 22:10
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>
The token issuer's teardown comment said the task was stopped, while the
deployment's said it was abandoned. Both call the same helper, which
requests cancellation without joining, so the second wording is the
accurate one.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Add registry-evidence-client-node, a thin napi-rs binding over
registry-evidence-client, exposing prepare/discover/fetchJwks/send/verify/
requestAndVerify/verifyAsOf to JS callers. Every Evidence semantic decision
stays in the wrapped Rust crate; this crate only converts values across the
FFI boundary and maps client errors to a JSON-stringified napi::Error.

Wire the new crate into the workspace and its surrounding checks: root
Cargo.toml membership, the CI change-classifier's evidence shard and new
client_bindings output, a client-bindings CI job that builds and tests the
addon on the pinned Node version, source-neutrality checks, and the
repository/product AGENTS.md maps.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
TokenError::kind() described which token failure occurred but never
reached the JSON envelope map_client_error hands to callers. Insert it
as tokenKind alongside the existing sub-fields, so a token failure's
own discriminant is inspectable the same way transportKind and code
already are.

Also add a redaction test for this mapping layer, covering a
credential, a malformed signing key, a selector value, and a pinned
subject binding: each is planted as a canary in an otherwise-refused
input, and the mapped envelope must never repeat it.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
napi-derive 3.6.2's generated glue carries no panic handling for
synchronous #[napi] functions; only napi's own tokio bridge wraps
asynchronous work in catch_unwind. A panic in the constructor,
prepare, verify, verifyAsOf, or a PreparedEvidenceRequest/
RawEvidenceResponse getter would otherwise unwind across the FFI
boundary and abort the whole Node process instead of rejecting one
call.

Add a catch_panic helper and route every synchronous entry point
through it. The reported reason is fixed and never echoes the panic
payload, since a panic is an unvalidated code path that carries none
of the redaction guarantees the rest of this crate's error reporting
is held to.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
error.message from the generated binding was a JSON-stringified
envelope, so a caller had to JSON.parse it to branch on kind, status,
code, operation, retryAfterSeconds, transportKind, or tokenKind. That
defeats both instanceof and ordinary property access.

Add client.js: a hand-written entry point that patches the native
EvidenceClient.prototype methods and the PreparedEvidenceRequest/
RawEvidenceResponse prototype getters in place, normalizing any mapped
failure into an EvidenceClientError with the discriminants as real
properties and a plain-prose message. Patching the prototype (rather
than wrapping objects) keeps native object identity intact, which the
single-send guard on PreparedEvidenceRequest depends on; a test now
proves both the identity and the guard still hold. EvidenceClient
itself is subclassed only to intercept the constructor's own throw,
which is safe because nothing checks instanceof native.EvidenceClient
on an EvidenceClient argument anywhere in the native layer.

index.d.ts stays generated and byte-compared by check:types, so the
hand-written EvidenceClientError declaration lives in its own
client.d.ts, which re-exports index.d.ts wholesale and becomes the
package's types entry. A new drift test introspects the built native
module and cross-checks it against client.js/client.d.ts, so the
hand-written surface cannot silently fall out of step with the native
one it wraps.

Update the existing tests to assert on error properties directly
instead of parsing error.message.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
registry-evidence-client-node stayed in EVIDENCE_TUTORIAL_PACKAGES only
because that set was built from EVIDENCE_PACKAGES, which the evidence
shard's own membership already had to include. A Node-only change
therefore replayed the docs Evidence tutorials even though it touches
none of their shell commands or fixtures.

Exclude binding crates from EVIDENCE_TUTORIAL_PACKAGES only. Source
neutrality still runs on the binding crate: evidence_contracts is
computed from EVIDENCE_PACKAGES directly, which is untouched.

Add a test asserting a binding-only change still runs evidence_contracts
and the evidence shard, but not evidence_tutorial.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Four confirmed issues from the stage-two review of
registry-evidence-client-node:

- README's error mapping section described the pre-client.js contract
  (JSON.parse(error.message)), which now throws a SyntaxError on the
  common path since client.js reconstructs an EvidenceClientError with
  ordinary prose in message. Rewrite it to document what ships today.
- package.json had no exports map, so a subpath require reached the
  raw native module and its unpatched JSON-message errors, bypassing
  client.js entirely. Add an exports map with client.js as the only
  resolvable entry point, and a drift test proving the guard holds.
- package.json had no publish guard, unlike Cargo.toml's publish =
  false. Add "private": true so the npm side enforces what it already
  claims about being out of scope for distribution.
- The panic-containment doc comment attributed the async-path catch to
  napi's own catch_unwind, which is not compiled into this build's
  feature set. Reword it to name tokio's task-level panic isolation,
  and record the asymmetry that the async path can echo a panic
  payload to JS while the sync path never does.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi added 15 commits August 5, 2026 22:12
Add crates/registry-evidence-client-py, a PyO3 binding over
registry-evidence-client, distributed as registry-evidence-client on PyPI
and imported as registry_evidence_client. It follows the same pattern as
the Node binding: construction, discover/fetch_jwks, the one-send guard,
error mapping to a shared EvidenceClientError base with per-kind
subclasses, and verify/verify_as_of for a signed response.

Wire it into the rest of the repository the way the Node binding already
is: the evidence Cargo workspace test shard, the client-bindings CI job
(built and tested with the system python3 already on the runner, no
actions/setup-python, mirroring the crate's own committed test command),
the Evidence source-neutrality check, and the two AGENTS.md repository
maps.

python/registry_evidence_client/ needed a runtime __init__.py: without
one, importing the package resolved as an empty PEP 420 namespace
package with the compiled extension unreachable, confirmed by building
and installing a wheel before adding the file. This is maturin's
ordinary mixed Rust/Python layout, so pyproject.toml's own comment
about the extension being the package's __init__ needed correcting to
match.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Eight fix-pass items in the PyO3 client binding: release the GIL for the
constructor's TLS trust-store load (with a self-calibrating concurrency
test), replace a vacuous redaction test with real planted canaries, correct
a panic-boundary comment that named the wrong unguarded path, narrow the
base exception's docstring to the two failures that actually escape it,
give the four pyclasses their real Python module so introspection doesn't
report `builtins`, make the package's `__init__.py` safe to reload, and
record why one match arm deliberately reports no `nonce_kind`.

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

Two canary arrangements now trigger a refusal caused by the offending
selector value and subject binding themselves, not just an unrelated
missing-purpose refusal. The construction GIL-release test now counts
a spinning observer thread's ticks instead of a wall-clock ratio, so
it measures GIL availability directly rather than CPU parallelism that
can serialize on a single core regardless of whether the GIL was held.
The async concurrency test builds both clients before its timed region
so the budget applies to the overlap it claims to measure, not to
construction eating most of the intended headroom. The README's panic
section now matches the corrected private-key-JWT jti panic path.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Snapshot the observer's tick count immediately around each timed call
instead of over the observer thread's whole lifetime, so ticks from
starting or stopping it never leak into the control or construction
counts. Update the docstring and assertion comment to describe the
delta-snapshot measurement and the one unavoidable post-call timeslice
it cannot eliminate, rather than the observer's prior lifetime count.

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

Mint's audit configuration requires a per-segment rotation threshold, so the
fixture that starts a real Mint beside the Evidence client has to supply one or
the binary refuses to start before the test can reach its assertions. The value
matches Mint's own documented example.

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

The workspace publishes every crate at one version, and the Node binding's
package metadata carries that number separately from Cargo's. `index.js` is
regenerated by `npm run build`, which embeds the version in its loader guards.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A subject binding is a pseudonymous per-subject identifier, so it must not
reach a Debug rendering. Making ExpectedSubjectDocument public SDK surface
made one renderable by any relying party that formats a retained policy, and
ExpectedSubject carried the same derive.

Both types now render the role and elide the binding, following the
hand-written Debug that SubjectExpectations already carries. The policy
document keeps its derive and inherits the elision through the subject type,
which its own test pins.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The config's hand-written Debug promises to withhold the credential source,
but it rendered the base URL verbatim. validate() refuses a base URL carrying
credentials and runs inside EvidenceClient::new, so a config formatted before
construction rendered the password.

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

verifyAsOf built its refusal with NapiError::from_reason, so it reached the
caller without the kind every other mapped failure carries, and the f64 to
i64 cast saturated ahead of that check: NaN became the Unix epoch and
verified against an instant the caller never asked for.

A conversion helper mirrors the Python binding's own, refusing non-finite and
out-of-range values as a configuration failure through the JSON envelope.
client.d.ts's status line gains the token failure that convert.rs sets it on,
and the nonce arm gains the comment its Python counterpart carries.

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

`transport_kind` is set on a `TokenError` whose `token_kind` is `transport` as
well as on a `TransportError`, so the README and the comment above
`create_exception!` both stated a rule the conversion layer does not follow,
and disagreed with the committed `.pyi` and the Node binding's `client.d.ts`.
The panic-path passages read as absolute while `to_py_err`'s `set_attr!` calls
can panic under allocation failure.

Conversion coverage now matches the Node binding's: all six `TokenError`
sub-kinds, and a verification failure carrying the verifier's kind as its
`code`. A test pins each of the eight known kind strings to its specific
exception class, so renaming a kind fails rather than quietly degrading to the
base class through the deliberate catch-all. The `DYLD_LIBRARY_PATH` example
loses a developer's home directory and a pinned interpreter patch version.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The Evidence product boundary governed the runtime, the verifier, and
`evidencectl`, while the repository map directly above it already introduced the
SDK and both bindings, so the document a contributor reads first stated no rule
about whether the SDK may add Evidence semantics or where the bindings sit
relative to the frozen Version 1 contract. "Verify your change" likewise omitted
the two binding suites that gate their PR.

The verifier sentence claimed no platform-specific dependency, which reads as
the target independence the crate's own module doc explicitly disclaims.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The README listed five dependency categories the check proves absent, but the
script also denies `fs2`, `rustix`, and `socket2`, so a tree could satisfy all
five named categories and still fail the check.

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

The crate tree and Definition of Done gate block in `IMPLEMENTATION.md`, and the
reproducible gate in the product README, all predate the four crates this branch
adds: following either verbatim skips the verifier's tests, the client's tests,
and the portability check. The README also never told an adopter what to use to
consume Evidence from an application.

The anti-proliferation clause regains the word `client`, because 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, and
`registry-evidence-verifier` is named as the one approved decomposition and
declared closed. The neutrality gate now sweeps the bindings' shipped non-Rust
surface, which it could not see while it scanned only Rust under `src/`.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Two published pages describe that CI job by listing its steps, and it also runs
the verifier portability script.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The configuration guide said an external `$ref` fails config validation with
`spdci.config.schema_compile_failed`. It does not: `$ref` resolution in the
schema compiler is lazy, so such a schema loads and then fails every record at
request time, which answers `500 internal.unhandled` for any non-empty result.
Nothing in the tree tested that behavior, nor the internal `#/` resolution the
same paragraph promises.

The guide now states what the compiler does: which draft a schema compiles
under, the silent draft 7 fallback for a draft it does not carry, and that
2020-12 treats `format` as an annotation rather than an assertion, which can
widen what an adopter's committed schema accepts. Eight characterization tests
pin all of it through `config::load` and the response mapper, including that no
request reaches a mock upstream serving the referenced document. Each was proven
load-bearing against a scratch crate toggling the schema compiler's features.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
res.end('no stub route for this request');
return;
}
handler(req, res, body);
}
return null
}
if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) {
if str(_IMPORT_DIR) not in sys.path:
sys.path.insert(0, str(_IMPORT_DIR))

_built = True
try:
barrier.wait(timeout=JOIN_TIMEOUT_SECONDS)
client.discover()
except BaseException as error: # noqa: BLE001 - captured, not swallowed
try:
while not stop_event.is_set():
counter[0] += 1
except BaseException as error: # noqa: BLE001 - captured, not swallowed
@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".

fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PrivateKeyJwtConfig")
.field("token_endpoint", &self.token_endpoint.as_str())

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 Redact userinfo from token endpoint debug output

When a caller formats PrivateKeyJwtConfig before PrivateKeyJwt::new validates it, a token endpoint like https://client:secret@issuer.example/token is rendered verbatim here. The constructor later rejects userinfo, but this Debug implementation is available on the unvalidated config, so configuration diagnostics can log the credential that the Evidence rules require keeping out of logs.

Useful? React with 👍 / 👎.

Comment on lines +372 to +378
if let Some(value) = token_object.get("static") {
let value = value
.as_str()
.ok_or_else(|| ConversionError::new("`token.static` must be a string"))
.map_err(ConfigError::Shape)?;
let provider = StaticToken::new(value)?;
return Ok(Arc::new(provider));

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 Reject ambiguous token provider configs

If a Node caller supplies both token.static and token.privateKeyJwt (or token.static plus any stray key), this branch silently selects the static bearer token and ignores the rest, despite the error contract saying exactly one provider is allowed. That makes a typo or merge of two auth configurations run with the wrong credential instead of failing closed; the Python binding already rejects objects whose token mapping has more than one key.

Useful? React with 👍 / 👎.

Comment on lines +485 to +489
client_bindings = complete or any(
path.startswith(f"crates/{package}/")
for path in paths
for package in EVIDENCE_BINDING_PACKAGES
)

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 Run binding jobs for SDK changes too

Because this output only looks at paths under the two binding crates, a change in crates/registry-evidence-client or crates/registry-evidence-verifier can change the native surface or error envelope that the Node/Python packages wrap while leaving client-bindings false, so the npm tests/type-drift check and Python unittest suite are skipped. The binding crates are reverse-dependencies of the SDK, so this should be based on affected packages (or include the SDK/verifier paths), not just direct binding file paths.

AGENTS.md reference: AGENTS.md:L158-L175

Useful? React with 👍 / 👎.

"{what} must be a finite, non-negative number of seconds"
)));
}
Ok(Duration::from_secs_f64(seconds))

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 Bound Python timeout floats before conversion

For a Python caller who passes a very large but finite timeout such as 1e300 (either top-level or inside private_key_jwt), the guard lets the value reach Duration::from_secs_f64, which panics on values too large for Duration. That turns bad configuration input into a PyO3 panic path instead of the mapped ConfigurationError; reject values above Duration::MAX before calling this constructor.

Useful? React with 👍 / 👎.

Comment on lines +485 to +489
client_bindings = complete or any(
path.startswith(f"crates/{package}/")
for path in paths
for package in EVIDENCE_BINDING_PACKAGES
)

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 Run binding jobs for SDK changes too

Because this output only looks at paths under the two binding crates, a change in crates/registry-evidence-client or crates/registry-evidence-verifier can change the native surface or error envelope that the Node/Python packages wrap while leaving client-bindings false, so the npm tests/type-drift check and Python unittest suite are skipped. The binding crates are reverse-dependencies of the SDK, so this should be based on affected packages (or include the SDK/verifier paths), not just direct binding file paths.

Useful? React with 👍 / 👎.

# `registry_evidence_client.registry_evidence_client`, and `__init__.py`
# re-exports its public names with `from .registry_evidence_client import *`.
python-source = "python"
module-name = "registry_evidence_client"

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 Use the package-qualified extension module name

With python-source = "python", Maturin's mixed-project layout places a Rust extension inside the Python package only when module-name is the full import path, e.g. registry_evidence_client.registry_evidence_client. As written, the wheel builds a top-level extension next to the python/registry_evidence_client package; Python imports the package directory first, runs __init__.py, and from .registry_evidence_client import * then cannot find the compiled submodule. The local unittest bootstrap copies a top-level .so directly, so it does not exercise this wheel layout.

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