Skip to content

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

Description

@duyhuynh-vn

Goal (M9 — conformance)

Every credentials-handshake test the SDK ships today drives negotiate_version with a single-element supported set, 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)

  • 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 negotiated VersionDetails (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)

Add crates/ocpi-client/tests/m9_cross_version_registration.rs:

  1. 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.
  2. 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).
  3. Recognition-only edge is never selected for a handshake. A partner advertising 2.3.0 plus a forward-looking 3.0 still negotiates 2.3.0 and registers there; a partner advertising only 3.0 (or only 2.0) degrades to no mutual version → the caller maps it to an explicit UnsupportedVersion and no register_* is attempted (the handshake-side corollary of negotiate_disjoint_returns_none / the M9: OCPI 3.0 forward-scaffold — recognise VersionNumber::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-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-version supported set into negotiate_version (so the selection is non-trivial) and feeds the negotiated VersionDetails into the matching register_* 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-features green.
  • 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.
  • crates/ocpi-client/src/lib.rsnegotiate_version (.filter(|v| supported.contains(v)).max()), register / register_2_3_0.
  • Isolated halves this joins: #236/PR test(M9): consolidate version negotiation into a table-driven matrix #247 (negotiation matrix), m2_registration.rs / m8_credentials_2_3_0.rs (single-version handshakes), the recognition-only fences from #219/#223.
  • Milestone M9 (Conformance, fuzzing, docs, 3.0 forward-scaffold).

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.

Claude session: https://claude.ai/code/session_01SfEsNcUpw3ifeGwhKpUeYt

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions