Goal
Make crate::version::ModuleID tolerate the custom module IDs the OCPI spec explicitly allows, instead of hard-rejecting the entire /versions/{version} details payload when it meets one. This is a conformance gap on the version-information / credentials-handshake path (M2) in 2.2.1 (the primary production target) — and it is the follow-up the crate's own doc comment already says it needs but which was never filed.
The spec allows custom module IDs (2.2.1 and 2.3.0, verbatim)
specs/ocpi/2.2.1/version_information_endpoint.asciidoc:189-194 (identical text in specs/ocpi/2.3.0/version_information_endpoint.asciidoc, under ===== Custom Modules; 2.3.0 also re-titles the section ==== ModuleID _OpenEnum_):
Custom Modules — Parties are allowed to create custom modules or customized versions of the existing modules. To do so, the ModuleID enum can be extended with additional custom moduleIDs. […] It is advised to use a prefix (e.g. country-code + party-id) […]
For example: nltnm-tokens
So a spec-conformant peer MAY list an endpoint whose identifier is nltnm-tokens (or any <cc>-<party>-… custom id) in its VersionDetails.
Current crate behaviour (a hard reject — verified against main)
crates/ocpi-types/src/version.rs:
ModuleID is a closed enum (10 fixed variants: cdrs, chargingprofiles, … payments) with no #[serde(other)] / Custom(String) catch-all.
Endpoint.identifier is typed ModuleID (not a string), and VersionDetails.endpoints is Vec<Endpoint>.
- The type's own doc comment (
version.rs:95-97) already records the gap and promises the fix:
"Custom module IDs ("nltnm-tokens" style) are spec-allowed but not yet modelled; deserializing one will return an error. A follow-up issue will add an Other(String) catch-all."
That follow-up issue does not exist (searched open + closed: no ModuleID / "custom module" / nltnm-tokens issue). This issue is it.
Because serde fails the whole struct on an unknown enum variant, one custom endpoint entry makes the entire VersionDetails (and therefore the whole GET /versions/{version} response) fail to deserialize — not just the one unknown endpoint. That happens on the credentials/version-negotiation path, before any module traffic flows.
Note: this is deliberately out of scope of #184. #184's OpenEnum decision-support comment explicitly punts ModuleID/VersionNumber to "the version layer, handled separately," and recommends keeping them out of any blanket Other(String) change. VersionNumber forward-compat is already handled (recognition-only 2.0/#182 and 3.0/#219,#223). ModuleID custom-module tolerance is the one piece of that "handled separately" story that is not actually handled or tracked — this issue closes it, without touching #184's enum-policy decision.
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 fetches every partner's GET /versions/{version} catalogue during the credentials handshake to discover which module endpoints that partner exposes. The spec deliberately lets partners advertise custom / customized modules (prefixed like nltnm-tokens) alongside the standard ones. Under today's closed ModuleID, a single custom endpoint in a partner's version details makes the whole catalogue fail to deserialize — so the Hub cannot even read the partner's standard Locations/Sessions/CDRs endpoints, and the handshake breaks, purely because the partner also offered a module the Hub doesn't recognise. That is a hard onboarding failure for a case the spec explicitly designed to be additive: the Hub should read the endpoints it understands, preserve the custom id (round-trip it, ignore it for routing), and carry on — exactly the "tolerant where the spec is open, strict where it is closed" contract the crate already applies to OcpiStatusCode::Unknown(u16). This unblocks charge-hub onboarding partners that ship any custom module, on the 2.2.1 path it depends on most.
Proposed shape (implementation notes for whoever picks this up)
- Add a raw-preserving fallback to
ModuleID — a Custom(String) variant with #[serde(untagged)]/manual Deserialize, or a #[serde(other)]-style catch-all that keeps the original string — so an unknown id round-trips as data instead of erroring. Mirror the OcpiStatusCode::Unknown(u16) precedent.
- Ripple to flag:
ModuleID currently derives Copy. Custom(String) is not Copy, so Copy must be dropped and the (small) set of Copy-reliant call sites adjusted to Clone. Call this out in the PR — it is the one non-mechanical part.
- Keep standard ids serializing exactly as today (
cdrs, chargingprofiles, …); only genuinely-unknown ids land in Custom.
- Ensure a
Custom id is never treated as a known module by any routing / endpoint-lookup logic (it is opaque, agreement-only data per the spec's "only send to parties you have an agreement with").
Acceptance criteria
Spec / references
Filed by the nightly routine as a grooming issue from a spec-vs-crate diff: the open-PR queue is at the 5-PR cap tonight (#223 / #226 / #227 / #229 / #231 — all CI-green, no conflicts, each carrying a Real-use-case section + Claude session link), so per the "if the queue is full, groom + review the goal" cadence no new implementation PR was opened. This surfaces the one spec-designated forward-compat gap the crate's own doc comment flags but no issue tracked. Unblocked and non-decision-gated whenever a PR slot frees.
Filed via Claude Code
Goal
Make
crate::version::ModuleIDtolerate the custom module IDs the OCPI spec explicitly allows, instead of hard-rejecting the entire/versions/{version}details payload when it meets one. This is a conformance gap on the version-information / credentials-handshake path (M2) in 2.2.1 (the primary production target) — and it is the follow-up the crate's own doc comment already says it needs but which was never filed.The spec allows custom module IDs (2.2.1 and 2.3.0, verbatim)
specs/ocpi/2.2.1/version_information_endpoint.asciidoc:189-194(identical text inspecs/ocpi/2.3.0/version_information_endpoint.asciidoc, under===== Custom Modules; 2.3.0 also re-titles the section==== ModuleID _OpenEnum_):So a spec-conformant peer MAY list an endpoint whose
identifierisnltnm-tokens(or any<cc>-<party>-…custom id) in itsVersionDetails.Current crate behaviour (a hard reject — verified against
main)crates/ocpi-types/src/version.rs:ModuleIDis a closed enum (10 fixed variants:cdrs,chargingprofiles, …payments) with no#[serde(other)]/Custom(String)catch-all.Endpoint.identifieris typedModuleID(not a string), andVersionDetails.endpointsisVec<Endpoint>.version.rs:95-97) already records the gap and promises the fix:That follow-up issue does not exist (searched open + closed: no
ModuleID/ "custom module" /nltnm-tokensissue). This issue is it.Because serde fails the whole struct on an unknown enum variant, one custom endpoint entry makes the entire
VersionDetails(and therefore the wholeGET /versions/{version}response) fail to deserialize — not just the one unknown endpoint. That happens on the credentials/version-negotiation path, before any module traffic flows.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 fetches every partner'sGET /versions/{version}catalogue during the credentials handshake to discover which module endpoints that partner exposes. The spec deliberately lets partners advertise custom / customized modules (prefixed likenltnm-tokens) alongside the standard ones. Under today's closedModuleID, a single custom endpoint in a partner's version details makes the whole catalogue fail to deserialize — so the Hub cannot even read the partner's standard Locations/Sessions/CDRs endpoints, and the handshake breaks, purely because the partner also offered a module the Hub doesn't recognise. That is a hard onboarding failure for a case the spec explicitly designed to be additive: the Hub should read the endpoints it understands, preserve the custom id (round-trip it, ignore it for routing), and carry on — exactly the "tolerant where the spec is open, strict where it is closed" contract the crate already applies toOcpiStatusCode::Unknown(u16). This unblocks charge-hub onboarding partners that ship any custom module, on the 2.2.1 path it depends on most.Proposed shape (implementation notes for whoever picks this up)
ModuleID— aCustom(String)variant with#[serde(untagged)]/manualDeserialize, or a#[serde(other)]-style catch-all that keeps the original string — so an unknown id round-trips as data instead of erroring. Mirror theOcpiStatusCode::Unknown(u16)precedent.ModuleIDcurrently derivesCopy.Custom(String)is notCopy, soCopymust be dropped and the (small) set ofCopy-reliant call sites adjusted toClone. Call this out in the PR — it is the one non-mechanical part.cdrs,chargingprofiles, …); only genuinely-unknown ids land inCustom.Customid is never treated as a known module by any routing / endpoint-lookup logic (it is opaque, agreement-only data per the spec's "only send to parties you have an agreement with").Acceptance criteria
ModuleIDdeserializes a custom id ("nltnm-tokens") into a raw-preservingCustom(String)and re-serializes it byte-for-byte (round-trip test).VersionDetails/Endpointlist mixing standard ids and a custom id deserializes successfully, with the standard endpoints intact and the custom one preserved (the load-bearing regression fence for the handshake path).Custom.Customid as opaque (never matches a standard module).Copy-drop ripple resolved;cargo fmt --check,cargo clippy --all-targets --all-features -- -D warnings,cargo test --workspace --all-featuresgreen.unsafe. Update theversion.rs:95-97doc comment to record the catch-all now exists (removing the "a follow-up issue will add…" promise). Reviewable slice (≤ ~150 LOC).Spec / references
specs/ocpi/2.2.1/version_information_endpoint.asciidoc:189-194— Custom Modules (thenltnm-tokensexample). Identical inspecs/ocpi/2.3.0/version_information_endpoint.asciidoc(==== ModuleID _OpenEnum_+===== Custom Modules).crates/ocpi-types/src/version.rs—ModuleID(closed enum + the:95-97promise),Endpoint.identifier: ModuleID,VersionDetails.endpoints.crates/ocpi-types/src/status.rs—OcpiStatusCode::Unknown(u16), the existing raw-preserving forward-compat precedent to mirror.ModuleIDto "handled separately"); complements the recognition-onlyVersionNumberstory (M7: define the "definition of done" for OCPI 2.0 back-coverage — the last untracked M7 slice (spec is an unvendored stub) #182 / 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).Filed by the nightly routine as a grooming issue from a spec-vs-crate diff: the open-PR queue is at the 5-PR cap tonight (#223 / #226 / #227 / #229 / #231 — all CI-green, no conflicts, each carrying a Real-use-case section + Claude session link), so per the "if the queue is full, groom + review the goal" cadence no new implementation PR was opened. This surfaces the one spec-designated forward-compat gap the crate's own doc comment flags but no issue tracked. Unblocked and non-decision-gated whenever a PR slot frees.
Filed via Claude Code