You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
M9: Conformance — end-to-end cross-version negotiate→register seam (multi-version client picks the mutual version per partner and completes Token A→B→C on it) #252
Every credentials-handshake test the SDK ships today drives negotiate_version with a single-elementsupported set, so negotiation is trivial and the handshake runs on a version both sides were pinned to up front:
crates/ocpi-client/tests/m7_credentials_2_1_1*.rs — same shape, one version each.
What is never exercised end-to-end is the seam a Hub lives on: a client advertising a range (e.g. {2.1.1, 2.2, 2.2.1, 2.3.0}) meeting a partner that caps below the client's own top version, negotiating the mutual maximum, and then running the Token A→B→C exchange on that negotiated version (not on the client's preferred one). Negotiation is proven in isolation by the new matrix (#236/PR #247); the per-version handshakes are proven in isolation by the tests above. Nothing chains a non-trivial negotiation into the registration that follows it — the exact place a version-selection bug would silently route a partner onto the wrong handshake surface.
What's missing (verified 2026-07-26)
No test where negotiate_version receives a multi-version supported set and must choose (the singleton sets above make .filter().max() a no-op).
No test that feeds the negotiatedVersionDetails (its version + its credentials endpoint URL) into the matching register_* call and asserts the handshake completed on the version negotiation actually selected — i.e. that a 2.3.0-capable Hub correctly falls back to register (2.2.1) for a 2.2.1-capped partner, and to register_2_3_0 only for a genuinely-2.3.0 partner.
Proposed slice (test-only, unblocked — all pieces are already on main)
Down-negotiation → 2.2.1 handshake. Stand up a receiver whose /versions advertises only 2.1.1 + 2.2.1. Drive a client whose supported set is the full range [V2_1_1, V2_2, V2_2_1, V2_3_0]. Assert negotiate_version selects 2.2.1 (the mutual max, below the client's own V2_3_0 top), then run register against the negotiated credentials URL and assert Token A→B→C completes on the 2.2.1 surface.
Full-range partner → 2.3.0 handshake. Same client range against a receiver advertising through 2.3.0; assert negotiation selects 2.3.0 and register_2_3_0 completes, with hub_party_id surviving the exchange (the 2.3.0 Credentials fork).
ocpi-rs is consumed by evlinked/charge-hub (a hosted OCPI roaming Hub) and evlinked/stationlink. A Hub is the one party that registers with partners spanning the entire version range at once — a legacy CPO on 2.1.1, mainstream partners on 2.2.1, early adopters on 2.3.0 — and its first act with each is GET /versions → negotiate → POST /credentials. The correctness that matters in production is not "the 2.3.0 handshake works" or "negotiation returns the right number" in isolation, but that the Hub, holding a single wide supported set, selects the right version for each partner and then runs the handshake on that version — falling back to 2.2.1 for a 2.2.1-capped partner while still reaching for 2.3.0 (with hub_party_id) where the partner supports it. A regression in that seam — negotiating 2.2.1 but registering with the 2.3.0 body, or letting a forward-looking 3.0 entry misroute the handshake — is a silent onboarding failure that only surfaces against a real partner, exactly when it is most expensive. The per-version tests and the negotiation matrix each guard one half; this pins the join between them, which is the guarantee a Hub actually depends on.
Acceptance criteria
crates/ocpi-client/tests/m9_cross_version_registration.rs covering the three scenarios above (down-negotiation → 2.2.1 handshake; full-range → 2.3.0 handshake with hub_party_id; recognition-only 2.0/3.0 never drives a handshake).
Each scenario drives a multi-versionsupported set into negotiate_version (so the selection is non-trivial) and feeds the negotiatedVersionDetails into the matching register_* call — asserting the handshake ran on the version negotiation selected, not a pre-pinned one.
No new dependencies; no unsafe. Test-only; reuses the existing in-process axum loopback + OcpiVersionFetcher fetch-back plumbing the current credentials tests already use.
Spec / references
specs/ocpi/2.2.1/version_information_endpoint.asciidoc — the /versions + /versions/{version} catalogue negotiation consumes.
specs/ocpi/2.2.1/credentials.asciidoc — the Token A→B→C exchange that runs on the negotiated version's credentials endpoint.
Filed by the nightly routine as an M9 grooming issue. The open-PR queue is at the 5-PR cap tonight (#245 / #247 / #248 / #231 / #229 — all CI-green, all mergeable_state: clean, blocked only on human merge; see #244), so no new implementation PR was opened. Unlike the remaining M8 items (#237 close-out, #184 Part-2 enum policy) which are owner-decision-gated, this slice is fully unblocked — every piece is already on main — so it is the natural next forward-work task once a queue slot frees.
Goal (M9 — conformance)
Every credentials-handshake test the SDK ships today drives
negotiate_versionwith a single-elementsupportedset, so negotiation is trivial and the handshake runs on a version both sides were pinned to up front:crates/ocpi-client/tests/m2_registration.rs:140—.negotiate_version(&[VersionNumber::V2_2_1])crates/ocpi-client/tests/m8_credentials_2_3_0.rs:147—.negotiate_version(&[VersionNumber::V2_3_0])crates/ocpi-client/tests/m7_credentials_2_1_1*.rs— same shape, one version each.What is never exercised end-to-end is the seam a Hub lives on: a client advertising a range (e.g.
{2.1.1, 2.2, 2.2.1, 2.3.0}) meeting a partner that caps below the client's own top version, negotiating the mutual maximum, and then running the Token A→B→C exchange on that negotiated version (not on the client's preferred one). Negotiation is proven in isolation by the new matrix (#236/PR #247); the per-version handshakes are proven in isolation by the tests above. Nothing chains a non-trivial negotiation into the registration that follows it — the exact place a version-selection bug would silently route a partner onto the wrong handshake surface.What's missing (verified 2026-07-26)
negotiate_versionreceives a multi-version supported set and must choose (the singleton sets above make.filter().max()a no-op).VersionDetails(its version + its credentials endpoint URL) into the matchingregister_*call and asserts the handshake completed on the version negotiation actually selected — i.e. that a 2.3.0-capable Hub correctly falls back toregister(2.2.1) for a 2.2.1-capped partner, and toregister_2_3_0only for a genuinely-2.3.0 partner.Proposed slice (test-only, unblocked — all pieces are already on
main)Add
crates/ocpi-client/tests/m9_cross_version_registration.rs:/versionsadvertises only 2.1.1 + 2.2.1. Drive a client whose supported set is the full range[V2_1_1, V2_2, V2_2_1, V2_3_0]. Assertnegotiate_versionselects 2.2.1 (the mutual max, below the client's ownV2_3_0top), then runregisteragainst the negotiated credentials URL and assert Token A→B→C completes on the 2.2.1 surface.register_2_3_0completes, withhub_party_idsurviving the exchange (the 2.3.0 Credentials fork).2.3.0plus a forward-looking3.0still negotiates 2.3.0 and registers there; a partner advertising only3.0(or only2.0) degrades to no mutual version → the caller maps it to an explicitUnsupportedVersionand noregister_*is attempted (the handshake-side corollary ofnegotiate_disjoint_returns_none/ the M9: OCPI 3.0 forward-scaffold — recogniseVersionNumber::V3_0(recognition-only, no type surface), mirroring the resolved 2.0 slice (#182) #219/feat(M9): recognise OCPI 3.0 at the version layer — recognition-only forward-scaffold #223 recognition-only fences).Real use case — why this PR is needed
ocpi-rsis consumed by evlinked/charge-hub (a hosted OCPI roaming Hub) and evlinked/stationlink. A Hub is the one party that registers with partners spanning the entire version range at once — a legacy CPO on 2.1.1, mainstream partners on 2.2.1, early adopters on 2.3.0 — and its first act with each isGET /versions→ negotiate →POST /credentials. The correctness that matters in production is not "the 2.3.0 handshake works" or "negotiation returns the right number" in isolation, but that the Hub, holding a single wide supported set, selects the right version for each partner and then runs the handshake on that version — falling back to 2.2.1 for a 2.2.1-capped partner while still reaching for 2.3.0 (withhub_party_id) where the partner supports it. A regression in that seam — negotiating 2.2.1 but registering with the 2.3.0 body, or letting a forward-looking3.0entry misroute the handshake — is a silent onboarding failure that only surfaces against a real partner, exactly when it is most expensive. The per-version tests and the negotiation matrix each guard one half; this pins the join between them, which is the guarantee a Hub actually depends on.Acceptance criteria
crates/ocpi-client/tests/m9_cross_version_registration.rscovering the three scenarios above (down-negotiation → 2.2.1 handshake; full-range → 2.3.0 handshake withhub_party_id; recognition-only 2.0/3.0 never drives a handshake).supportedset intonegotiate_version(so the selection is non-trivial) and feeds the negotiatedVersionDetailsinto the matchingregister_*call — asserting the handshake ran on the version negotiation selected, not a pre-pinned one.cargo fmt --all -- --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo test --workspace --all-featuresgreen.unsafe. Test-only; reuses the existing in-processaxumloopback +OcpiVersionFetcherfetch-back plumbing the current credentials tests already use.Spec / references
specs/ocpi/2.2.1/version_information_endpoint.asciidoc— the/versions+/versions/{version}catalogue negotiation consumes.specs/ocpi/2.2.1/credentials.asciidoc— the Token A→B→C exchange that runs on the negotiated version's credentials endpoint.crates/ocpi-client/src/lib.rs—negotiate_version(.filter(|v| supported.contains(v)).max()),register/register_2_3_0.m2_registration.rs/m8_credentials_2_3_0.rs(single-version handshakes), the recognition-only fences from #219/#223.Filed by the nightly routine as an M9 grooming issue. The open-PR queue is at the 5-PR cap tonight (#245 / #247 / #248 / #231 / #229 — all CI-green, all
mergeable_state: clean, blocked only on human merge; see #244), so no new implementation PR was opened. Unlike the remaining M8 items (#237 close-out, #184 Part-2 enum policy) which are owner-decision-gated, this slice is fully unblocked — every piece is already onmain— so it is the natural next forward-work task once a queue slot frees.Claude session: https://claude.ai/code/session_01SfEsNcUpw3ifeGwhKpUeYt