Service traits and identifiers

How an application declares what it supports, and why the compiler is the completeness check.

A server supporting two services. Dashed edges are impl blocks the application writes; the dispatch impl is generated.

WriteDataByIdentifier hangs unimplemented deliberately: nothing in that diagram is reachable by the dispatcher unless it appears in the uds_server! list. That is not a limitation of the design so much as a fact about Rust, and UDSSVC_ARCH_0013 explains why it forces an explicit assembly step.

The traits

Architecture Element: Each service is its own trait UDSSVC_ARCH_0012
status: draft
tags: api, traits
origin: derived

A service supported by the server is declared by implementing that service’s trait. Each trait carries its own associated types and its own handler method.

impl ReadDataByIdentifier for Ecu {
    type Did = MyDid;

    const MAY_RESPOND_PENDING: bool = false;
    const MAX_DIDS_PER_REQUEST: usize = 4;

    async fn read(
        &mut self,
        did: Self::Did,
        out: &mut ResponseSink<'_>,
    ) -> Result<(), Nrc> { /* ... */ }
}

There is no ServiceSet impl here, because the application does not write one: UDSSVC_ARCH_0013’s uds_server! generates it, along with the Store whose sizes it folds. Nor is there a generic sink parameter — the sink is the concrete ResponseSink, for the reason UDSSVC_ARCH_0017 records. An earlier version of this example carried impl UdsServer for Ecu { type Sess = MySession; } and an async fn read<S: Sink>; both are gone, the first with the rename recorded in UDSSVC_ARCH_0013 and the second with the de-genericisation in UDSSVC_ARCH_0017.

Rationale: a server’s vocabulary must stay local to the services it actually supports: a server with no routines must not have to name a routine-identifier type, and a server supporting one service must not carry the vocabulary of all of them. Per-service traits deliver this directly, because each trait’s associated types exist only where that trait is implemented. They also keep each service independently testable and make the surface discoverable — “implement ReadDataByIdentifier” is a thing a reader can look up, where “override method 9 of 16” is not.

Architecture Element: Assembly is explicit, and the macro is the crate's const evaluator UDSSVC_ARCH_0013
status: draft
tags: api, traits
origin: derived

A server type lists the services it supports in one place, and that list generates the dispatch:

uds_server! {
    Ecu: ReadDataByIdentifier, SecurityAccess, DataTransfer;
    transport = DoIpTransport<Entity<'static, TcpAcceptor, 1, 4096>, 2>,
    peers = 1,
    server = EcuServer,
}

Three numbers meet in that line, and are not the same number. At a sensor serving one tester, the entity’s MCTS, the testers it serves at once (ISO 13400-2:2019 Table 11), is 1; its connection table counts the reserve socket too (REQ 4.DoIP-002), so it and the transport’s CONNECTIONS are 2; and peers, the testers the server keeps a session for, is 1. DoIpTransport’s rustdoc carries the detail.

A service absent from the list is not supported, and a request naming it settles with serviceNotSupported (0x11) by the path in UDSSVC_ARCH_0006.

Rationale: Rust cannot ask whether a type implements a trait, so a generic dispatcher over S: ServiceSet has no way to discover which per-service traits S implements. An assembly step resolves this directly: the list is declared once, and the dispatch match is generated from it — rather than the application writing that service-identifier match itself, which is precisely the clause 8.7 machinery this crate exists to own.

The load-bearing reason assembly is a macro is const evaluation, not explicitness, and this element understated it for as long as it named only the dispatch match. An associated const of a generic parameter is not usable as an array length: [u8; S::MAX_REQUEST] for S: SomeService is rejected without the unstable generic_const_exprs, so no blanket impl and no generic function anywhere in this crate can fold a service’s declared maximum into a buffer. At the macro’s expansion site the types are concrete — <Ecu as DataTransfer>::MAX_BLOCK_LENGTH is an ordinary const — and folding a list of them into an array length is stable Rust with nothing unusual about it. That is why the macro exists where a blanket impl would otherwise serve, and it is what lets UDSSVC_ARCH_0017’s buffers be sized without the application picking a number.

UdsTransport::MAX_PDU joins the same fold, which is why the assembly names the transport type. That introduces no dependency on a binding: the application names the type and already depends on both crates, so UDSSVC_ARCH_0002 and UDSSVC_ARCH_0003 are untouched.

The macro is macro_rules!, not a procedural macro: a declarative macro can match the service names as literal identifiers and expand one arm per service, which is all this needs, at no build-time dependency and no compile-time cost.

The list also makes the set of supported services readable directly in the application, without inferring it from which methods were overridden.

The trait the macro implements is ``ServiceSet``, and it was called ``UdsServer`` here until the driver landed. UDSSVC_ARCH_0040 made this crate the driver, and the driver is a struct named Server. Two names one letter apart, one meaning “the set of services an application implements” and the other “the loop that calls them”, is a trap rather than a naming preference: a reader who reaches for UdsServer expecting the thing that runs finds the thing that is run. The trait is the assembled set, so it is named for that, and Server is left to mean one thing.

Architecture Element: Each service declares whether it may answer response-pending UDSSVC_ARCH_0033
status: draft
tags: api, traits, response-pending
origin: derived
depends on: UDSSVC_ARCH_0012
depended on by: UDSSVC_ARCH_0032

Each service trait whose handler can be in progress when a deadline passes carries an associated constant, with no default, stating whether that service may answer requestCorrectlyReceivedResponsePending (0x78):

impl ReadDataByIdentifier for Ecu {
    /// Annex A: only where the server cannot receive further requests
    /// from the client while completing this service.
    const MAY_RESPOND_PENDING: bool = false;

    type Did = MyDid;
    async fn read(&mut self, did: MyDid, out: &mut ResponseSink<'_>)
        -> Result<(), Nrc> { .. }
}

UDSSVC_ARCH_0032 settles half of admissibility from what this server implements. The other half cannot be derived here. ISO 14229-2:2021 REQ 5.4 makes a response-pending inadmissible for a service whose tP4_Server_Max equals tP2_Server_Max, a per-service value this crate does not hold; ISO 14229-1:2020 A.1 permits the code only where the server “will not be able to receive further request messages from the client while completing the requested diagnostic service”, which is a property of the handler’s implementation. Both are the application’s to declare, and this is where it declares them.

Rationale: the constant has no default because both defaults are traps, and choosing between them is choosing which failure to ship. A default of true sends 0x78 where ISO 14229-2:2021 REQ 5.4 and REQ 5.6 forbid it. A default of false leaves slow handlers silently never answering response-pending, which is invisible until a handler overruns tP2_Server in the field. Requiring the value makes omission a compile error and neither failure reachable — the same completeness argument UDSSVC_ARCH_0013 makes for the assembly list and UDSSVC_ARCH_0014 for a fallible from_u16. A defaulted method would be the “override method 9 of 16” shape UDSSVC_ARCH_0012 rejects.

It is a constant rather than a method because the value is a property of the service as implemented, not of the request or the server’s current state: admissibility folds at compile time, and ISO 14229-2:2021 REQ 5.6’s unsupported-service case needs no runtime check at all, since a service that is not implemented has no impl to read the constant from.

Two services do not carry it, and cannot. A response-pending is what the driver sends while it is still awaiting a handler, so a service with nothing awaited has no window in which one could come due. TesterPresent’s on_tester_present is synchronous; DiagnosticSessionControl’s supports, supported_from, timing and leaves_running_software are lookups made before the response is composed or sent, and its on_transition runs after that response has gone out. On both, the constant would have had one possible value and no effect, and declaring it asked an application to answer a question with one answer. __uds_may_pend! answers false for both, so this is not a default reintroduced by another name: there is nothing an application can write that would change it.

This is a statement about the shape of the trait, not about the two services. If on_transition ever becomes async — entering a programming session can be slow — then DiagnosticSessionControl acquires a window and the constant comes back with it.

Protocol state

Architecture Element: Protocol state is owned by the crate and created at assembly UDSSVC_ARCH_0035
status: draft
tags: api, traits, state
origin: derived

Some of ISO 14229-1’s behaviour cannot be decided from one request. A server cannot produce wrongBlockSequenceCounter (0x73) without remembering the last block it accepted, requestSequenceError (0x24) without remembering what preceded, or exceedNumberOfAttempts (0x36) without counting. That state is this crate’s, and the application cannot reach it.

It is created by the assembly step of UDSSVC_ARCH_0013, which already names every service the server supports and so already knows which state is needed:

uds_server! {
    Ecu: ReadDataByIdentifier, SecurityAccess, DataTransfer;
    transport = DoIpTransport<Entity<'static, TcpAcceptor, 1, 4096>, 2>,
    peers = 1,
    server = EcuServer,
}

Rationale: UDSSVC_ARCH_0034 puts protocol concerns in the stack, and a block sequence counter is protocol bookkeeping by any reading — no application should reimplement it, and an application that cannot name it cannot corrupt it. Putting the state behind the assembly rather than in the application’s own type is what makes that true: the generated server composes the application type, and the protocol state is a private field of the composition.

Two scopes, and the standard draws the line, not convenience.

Scope

Holds

Fixed by

Server-global

active security level, periodic schedules, the transfer in progress

“Only one security level shall be active at any instant of time” (10.4)

Per-channel

authentication state

“An authenticated state shall be linked to a certain diagnostic channel. Multiple clients can be handled on multiple channels with different authentication settings.” (10.6.4)

DTC setting and communication control are not in that list, though they are server-global too. Their state is the effect itself — DTC status updates stopped, normal messages disabled — which the application performs and this crate cannot, so it holds them, and resumes them on the transitions UDSSVC_ARCH_0038 tabulates. on_transition names the session entered so that it can: whether the service is supported there is what clause 10.8.1 turns the resumption on, and a SessionTransition alone cannot tell extended to programming from extended to extended. An earlier draft listed both as crate-held, which nothing implemented.

Annex J corroborates the keying without being the authority for it. It is informative, so it obliges nothing, but it is where clause 8.7.6 sends a reader asking how multiple clients are handled, and its J.2 recommends that “a unique Address Information should be assigned to each communication participant to allow the detection of different clients” — which is A_SA, used for exactly this purpose.

Those two clauses rule in opposite directions on the same question, so a design that collapses them is wrong whichever way it collapses.

The per-channel scope is why the driver passes the addressing triple at all (UDSSVC_ARCH_0015), and A_SA is the field that keys the state. Authentication state cannot be keyed to a channel without something naming the channel, and the source address is what ISO 14229-1 clause 7.4.1 already carries for the purpose — which is the ground this rests on, rather than on clause 7.4.1’s bare “mandatory”, the weaker argument it replaced.

This sentence used to read “the per-channel scope is why Ctx gains A_SA”. There is no Ctx; UDSSVC_ARCH_0015 retired it, and what survived of it was exactly the triple this paragraph needs. The requirement is unchanged — something must name the channel — and only the carrier is different.

Sizing is declared, not allocated. UDSSVC_ARCH_0017 forbids allocation, so the per-channel table is a const-generic array whose length the assembly states. A deployment that supports one tester writes peers = 1 and pays for one.

That choice is now forced rather than preferred, and the reason is worth recording because this element argued it the weaker way. It used to say that caller-supplied storage — the shape uds_session itself takes — remained available if a deployment ever wanted to size the table at run time, and was declined only because it costs every caller a lifetime and a borrow. Implementation retired that. UDSSVC_ARCH_0040’s driver owns a uds_session::Server<PEERS> by value, alongside the storage and the transport, in one struct an application holds. A borrowing session would have that struct hold a reference to storage the same struct owns, which is a self-referential composition and not expressible: the attempt fails at E0515 returning a reference to a local, or at E0503 using the owner while the borrow lives, and no arrangement of the fields escapes it without unsafe, which #![forbid(unsafe_code)] does not permit. So const-generic storage is not the ergonomic choice over an available alternative; it is the only one the composition admits. The lifetime-and-borrow cost stands as an observation, not as the reason.

Where the state lives, and why the application cannot reach it. The assembly takes peers = N and emits the Server<A, T, N> alias from it, so the count is stated once. Milestone 1 admits peers = 1 only, and both the macro and Server::new reject any other count at compile time: the driver keeps one slot for a selecting DiagnosticSessionControl response awaiting its confirmation, and a second peer’s response could overwrite it. The protocol state is uds_services::State, a type this crate declares with private fields; uds_server! only names it as ServiceSet::State, because the macro expands in the application’s crate and a struct declared there could keep nothing private from it. Server holds the state in a private field and passes it to dispatch and to the two session hooks the macro emits. Holding the state inside Store was rejected too: Store holds per-request buffers, and state that outlives a request does not belong with them. The per-channel authentication table this element describes is still not built; nothing in scope needs it yet.

What this element does not settle is what happens to this state on a session transition. ISO 14229-1:2020 10.2 requires a return to defaultSession to relock security, disable periodic schedulers and output controls, resume stored ResponseOnEvent configurations, and “reset all activated/initiated/changed settings/controls during the activated session” — one obligation spanning five services’ state. It is recorded here so it is not mistaken for an omission, and is designed with the state bodies it acts on.

Architecture Element: The transfer lifecycle is a state machine this crate owns UDSSVC_ARCH_0036
status: draft
tags: api, traits, state, nrc
origin: application-layer-standard
source: ISO 14229-1:2020 15.2.4 Table 444; ISO 14229-1:2020 15.4.1; ISO 14229-1:2020 15.4.2.3; ISO 14229-1:2020 15.4.4; ISO 14229-1:2020 15.5.4
depended on by: UDSSVC_ARCH_0039

ISO 14229-1 clause 15 specifies a transfer as a sequence with one live instance, a counter, and four codes that report departures from it. All of that is bookkeeping, so all of it is this crate’s under UDSSVC_ARCH_0034, held in the state of UDSSVC_ARCH_0035.

The transfer state machine. Every transition labelled with a code is settled by this crate; the handler is not called.

The counter is arithmetic, and the wrap is the part to get right. Clause 15.4.1 initialises it to one on RequestDownload (0x34), RequestUpload (0x35) or RequestFileTransfer (0x38), so the first TransferData carries 0x01. Clause 15.4.2.3 increments it by one per request and, at 0xFF, rolls it over to ``0x00`` — not to 0x01. An implementation that wraps to one drifts by a block every 255 and reports 0x73 forever after, which is precisely the class of error this crate exists to remove from applications.

A repeated block is not an error, and the two directions differ. Clause 15.4.4 attaches the same sentence to both 0x24 and 0x73: “the repetition of a TransferData request message with a blockSequenceCounter equal to the one included in the previous TransferData request message shall be accepted by the server.” Clause 15.4.1 says what accepting means, and it is not the same on each side. A repeated download block is answered positively “without writing the data once again into its memory” — so the handler is not called, and this crate replays the previous response. A repeated upload block is answered by “accessing the previously provided data once again” — so the handler is called again. A design that treats repetition uniformly is wrong in one direction whichever uniform choice it makes.

What the application supplies, and nothing more: whether the dataFormatIdentifier, addressAndLengthFormatIdentifier and memory range are acceptable (0x31), whether a fault condition blocks the transfer (0x70), the maxNumberOfBlockLength it will accept, the bytes themselves, and any finalisation failure at exit (0x72). Each is a property of one ECU. None of the sequencing is reachable from the handler, so no application can answer 0x73 where 0x24 is required, or accept a second transfer while one is live.

``maxNumberOfBlockLength`` is an associated const, not a value a handler returns, and the change removes a disagreement rather than saving a parameter. Clause 15.2.3.2 obliges the server to report the number in its RequestDownload positive response, and UDSSVC_ARCH_0013’s fold needs the same fact to size the buffer a block is decoded into. Had begin returned it, those would be two statements of one fact made at two different times — one at compile time in the array length, one per request from a handler — with nothing obliging them to match, and a handler advertising more than the buffer holds produces a client that sends a block the server structurally cannot receive. As DataTransfer::MAX_BLOCK_LENGTH the fact is declared once and folded into the buffer, so begin returns nothing. Deriving the advertised value from it is still to be done: 15.2.3.2’s number counts the service identifier and the block sequence counter, which the const excludes, and that is one of the problems open question 8 records (#50). SUPPORTS_UPLOAD sits beside it for the same kind of reason: a download-only server never puts a block in a response, so it must not pay for a response buffer sized to hold one.

Direction is carried by ``TransferRequest`` rather than by a separate field, which is what makes the paragraph above structural instead of advisory. The repeated-block rule splits by direction — a repeated download is answered without calling the handler, a repeated upload calls it again — so direction is not an attribute of a request that happens to be worth knowing; it is the thing that selects the behaviour. As an enum, Download, Upload and File each carry exactly the parameters clause 15 gives them, a file transfer’s modeOfOperation and filePathAndName are not a memory address and a size pretending otherwise, and there is no state in which the direction is absent or contradicts the parameters beside it.

This element is the first instance of UDSSVC_ARCH_0035 and is what tests it: the state is created at assembly, reached only by the dispatcher, and sized without allocation — a transfer is one live instance per server, so it needs no per-channel table.

One reading is flagged rather than assumed. Clause 15.5.4 gives RequestTransferExit 0x24 on two conditions, the second being “the programming process is not completed”. Read here as the transfer’s own completion — fewer bytes transferred than the active memorySize — which is knowable from this state. If it instead means clause 17’s programming process, the condition belongs to a layer this crate does not yet have, and the element changes. It is worth settling against a reviewer who has run a programming sequence in anger.

Architecture Element: The security access sequence is a state machine this crate owns UDSSVC_ARCH_0037
status: draft
tags: api, traits, state, nrc, security
origin: application-layer-standard
source: ISO 14229-1:2020 10.4.2; ISO 14229-1:2020 10.4.4; ISO 14229-1:2020 Annex I Table I.1; ISO 14229-1:2020 Annex I Table I.2

Annex I is normative and specifies security access as a four-state chart. Every state and every transition in it is sequencing, so all of it is this crate’s under UDSSVC_ARCH_0034; what Annex I marks optional and vehicle-manufacturer specific is the application’s, and the boundary is drawn where the annex draws it.

Annex I’s four states. Labels name the code this crate settles with; the application is consulted only for a seed and a verdict on a key.

Any refused request discards the seed, not only a failed key. Table I.2’s rows for transitions 9 and 10 include a requestSeed whose length is wrong (0x13) and “a SecurityAccess request [that] results in a general negative response code” (8.7), so in state B or D every SecurityAccess request answered negatively leaves the seed discarded, whichever check refused it — Figure 6’s 0x12 and 0x7E included, and a request too long to be received whole. uds_server!’s dispatch applies it once, to its result, rather than in each check.

The restart rule is the chart’s shape, and an earlier draft had it wrong. Clause 10.4 states that “an invalid key shall require the client to start over from the beginning with a SecurityAccess ‘requestSeed’ message as specified in Annex I”, and Figure I.1 implements it as topology rather than as a note: transition 9 leaves state B for state A, and transition 10 leaves state D for state C, on every sendKey outcome including the failures. So a failed key discards the stored seed, and the client cannot try a second key against it — it must ask for a new one.

An earlier version of this element drew those two transitions as self-loops, which left the seed live and let a client retry keys against it indefinitely. That is the behaviour the restart rule exists to prevent, and it was reachable because the element was written from Table I.2 without Figure I.1, which is an image in the markdown conversion of the standard. UDSSVC_ARCH_0034 had quoted the restart rule correctly all along; the chart contradicted it.

Five rules an application would have to rediscover, four of which this crate enforces.

The paired sub-function. Clause 10.4.2 makes requestSeed the odd values and sendKey the even, with a fixed relationship — level 0x01 pairs with 0x02, 0x03 with 0x04. Annex I checks yy == xx+1 against the stored xx and answers 0x24 when it fails, so a sendKey for a level whose seed was never requested is a sequence error rather than a bad key. The pair is held as one value, SecurityLevel, rather than as a raw sub-function byte, which is what makes yy == xx+1 structural instead of checked.

A defect was found and fixed in that pairing during implementation, and it is recorded here because the way it hid is more instructive than the arithmetic. SecurityLevel::from_request_seed originally admitted any odd u8, on the reading that “requestSeed is the odd values” is the whole of clause 10.4.2’s rule. It is not, and 0xFF is the counter-example: it is odd, so it was admitted, and send_key’s self.0 + 1 then overflowed — a panic in a release no_std build, on a byte an untrusted client chooses, reached by sending one SecurityAccess request. The overflow sat under an #[allow(clippy::arithmetic_side_effects)] whose stated reason asserted that the overflow was impossible, so the lint that exists to catch exactly this had been told not to, by a justification that was itself the bug. An #[allow] reason is a claim about the code, and this one was false; it is worth recording that the suppression is what made the defect survive review, not the missing bound.

The bound is now odd values below ``0x7F``, and it is derived rather than patched to the failing case. A sub-function byte is seven bits, because bit 7 is suppressPosRspMsgIndication — so both halves of a requestSeed/sendKey pair must fall in 0x00–0x7F to be sub-function values at all. That excludes every odd byte above 0x7F as a level, and excludes 0x7F itself for a second reason: its partner would be 0x80, which is not a sub-function but the suppress bit set on zero. The largest level is therefore 0x7D, pairing with 0x7E. Fixing it as “reject 0xFF” would have left 0x81 through 0xFD admitted and the type meaning something the standard does not.

The zero seed. A requestSeed for a level already unlocked is answered positively with a seed of zero, and clause 10.4.1 adds that a server “shall never send an all zero seed for a given security level that is currently locked”. Clients use this to probe lock state, so an application that returned a real seed would break a client that is reading the standard correctly.

Relock before unlock. Transition 10 has a successful key at a level other than the unlocked one “lock currently unlocked security level” before unlocking the new one. That is how “only one security level shall be active at any instant of time” is implemented, and it is invisible unless read.

The attempt threshold. A wrong key under the limit is 0x35; the one that reaches the limit is 0x36 and starts the delay. Annex I expresses the boundary as (Att_Cnt+1) >= Att_Cnt_Limit, evaluated before the increment, which is off by one from the obvious reading. A requestSeed while the delay runs is 0x37. Note also that the at-limit action is Att_Cnt = Att_Cnt_Limit — a clamp, not a further increment — so a counter that is already at the limit does not run away.

The seed policy is the application’s. Table I.1 defines Static_Seed: true means a stored seed is re-used when the same level’s seed is requested again, false means a fresh seed is generated each time. It governs transitions 5, 7 and 10, including the “if Static_Seed = True then clear generated seed for SubFunction xx” action that follows a successful unlock. Table I.1 also fixes the fallback: “if Delay_Timer and Att_Cnt are not supported, a random seed shall always be used”, so a deployment that declines both loses the choice.

It is the one rule here this crate does not enforce, because the seed’s bytes are the application’s and this crate keeps none: State is not generic, and holding a seed would make it so. An earlier draft carried static_seed on SecurityPolicy and read it nowhere. The obligation is SecurityAccess::seed’s contract instead: a static seed is returned again until verify_key reports the level’s key valid, and under RandomSeedOnly every seed is fresh. The application sees the unlock that clears a static seed, because the verdict that causes it is its own.

What the application supplies, and why the split falls here rather than elsewhere. Annex I Table I.1 marks Delay_Timer, Att_Cnt_Limit and Static_Seed as optional and vehicle-manufacturer specific, and clause 10.4.2 says the manufacturer selects whether the delay timer is supported at all. So the application supplies a seed, a verdict on a key, the limit, the delay duration, and whether either is supported.

It also supplies a verdict on the optional pre-conditions Table I.2 conditions transitions 4 and 7 on, whose failure is conditionsNotCorrect (0x22). Like the seed and the key, what constitutes a satisfied pre-condition is the vehicle manufacturer’s.

It also supplies the storage, and that one is forced by this crate’s own shape rather than by the annex. Annex I writes persistence as “store Att_Cnt in non-volatile memory (if applicable)” throughout, and Table I.1 makes Delay_Timer and Att_Cnt support optional and vehicle-manufacturer selected — so the standard obliges no storage at all. The argument is simply that this crate has none: it has no non-volatile memory and no clock, so wherever a deployment does persist a counter or time a delay, it cannot be here. It keeps the arithmetic and hands over the result:

/// Attempts for `level`, as last stored. Read at the start of an exchange.
fn attempts(&self, level: u8) -> u8;
/// Store the count this crate computed. The application never computes one.
fn store_attempts(&mut self, level: u8, count: u8);
/// The delay timer's state for `level`: running, expired since last asked, or idle.
fn delay(&mut self, level: u8) -> Delay;
/// Begin the delay this crate decided is owed.
fn start_delay(&mut self, level: u8);

The application persists and times; it never decides.

The timer reports its expiry; the crate does not infer it. An earlier draft took “count at the limit, no delay running” to mean the delay had run out, and reset the count. That is also the state after a restart: the count survives in non-volatile storage, a RAM timer does not, and the next requestSeed would have reset the count and issued a seed — a brute force by power cycle. The application now reports Expired once when its timer runs out, which alone resets the count, and an Idle timer with the count at the limit is a delay owed. Transition 1’s “start Delay_Timer … if required on start up” is ServiceSet::start_up, which the driver runs on its first step (Server::new is a const fn and cannot call the application) and which starts the delay of every supported level at its limit. That keeps UDSSVC_ARCH_0034’s third question answered — an application cannot produce 0x35 where 0x36 is required, because it is not asked which to send.

An earlier version of this element argued the storage seam from “Annex I requires Att_Cnt to be stored in non-volatile memory”, which the annex does not say. The seam is unchanged; the reason for it is.

Att_Cnt is per level, not per server: Table I.1 states that “when implemented, a separate counter is required for each individual security level”. The unlocked level is per server, by clause 10.4.2. Both live in the server-global scope of UDSSVC_ARCH_0035; neither is per channel, which is the distinction that element draws against authentication.

Note

The source check this element asked for has been done (2026-09-16), and it found one defect. Annex I’s normative content is Figure I.1 — an image, not text — plus Table I.2 in disjunctive normal form, and the markdown conversion of the standard mangles the table and drops the figure entirely. Both were recovered from the licensed PDF with pdftotext -layout, and Figure I.1 was read as an image.

Outcome: the four rules this element already stated are confirmed verbatim, and the state chart’s topology was wrong — transitions 9 and 10, corrected above. That is precisely the part the earlier warning said had not been read, which is the argument for writing such warnings at all.

One correction to that warning’s own claims. Att_Cnt = 0++ is in the standard, in Table I.2 transition 10’s at-limit branch; transition 9’s equivalent branch does not carry it. It is a defect in ISO 14229-1:2020 rather than an artefact of the conversion, and it is read here as redundant with the Att_Cnt = Att_Cnt_Limit assignment beside it. Worth confirming against a later corrigendum if one exists.

Architecture Element: A session transition is classified here and applied to configuration state UDSSVC_ARCH_0038
status: draft
tags: api, traits, state, session
origin: application-layer-standard
source: ISO 14229-1:2020 10.2 Figure 7 Key

Several services change server behaviour that outlives their own exchange — the ControlDTCSetting state, the CommunicationControl state, a periodic schedule, an event registration, an active output control. Clause 10.2 specifies what a diagnostic session transition does to every one of them, and it does not do the same thing to each.

The four classes are Figure 7’s Key notes 1 to 4. An earlier version of this element cited them as “Figure 6 Key”, which is clause 8.7.3.1’s SubFunction figure and has no such notes.

Clause 10.2’s four transition classes

Transition

ResponseOnEvent

Security

Periodic / output control

CommunicationControl, ControlDTCSetting

default → default

reset with everything else

—

reset

reset

default → non-default

pause

—

maintained

maintained

non-default → non-default (including the same session)

stop

relock

maintained, unless security-dependent

not affected

non-default → default

resume, if the event window is still valid

lock

disabled

reset

Three things in that table are worth stating in words, because each is a plausible wrong guess. The event verb is different in all four rows — pause, stop and resume are distinct operations, not synonyms. The two middle rows disagree about CommunicationControl: clause 10.2 says its state “shall not be affected” on a non-default to non-default transition and reset on a return to default, so disabled normal communication survives one and not the other. And a same-session re-entry is a transition: rule 3 says “including the currently active diagnostic session”, so re-entering the session you are already in stops events and relocks security.

Relocking cascades. Clause 10.2 has the locking of security access “reset any active diagnostic functionality that was dependent on security access to be unlocked (e.g. active inputOutputControl of a DID)”. So the relock of UDSSVC_ARCH_0037 is not only a state change here; it invalidates application-held functionality, and the application has to be told.

Rationale: the classification is a pure function of the previous and next session values, which this crate has, and misclassifying it is the whole failure mode. So this crate classifies and hands down the class, with the session entered beside it but never the pair of raw values to classify:

pub enum SessionTransition {
    DefaultToDefault,
    DefaultToNonDefault,
    NonDefaultToNonDefault,
    NonDefaultToDefault,
}

/// Called after the positive response to DiagnosticSessionControl, and on
/// session timeout. Also called with `security_relocked` when a transition
/// relocked a level, so functionality gated on it can be dropped.
fn on_transition(
    &mut self,
    t: SessionTransition,
    entered: DiagnosticSessionType,
    security_relocked: bool,
);

An application receiving a classified transition cannot mistake a same-session re-entry for a no-op, which is what it would do given two session bytes and clause 10.2 to read. The session entered is handed down too, because what a class owes can turn on it: the ControlDTCSetting and CommunicationControl state an application holds resumes on entering a session where the service is not supported, and an application that programs from a bootloader must know that 10 02 was the session entered, not 10 03. Both are non-default to non-default. State this crate holds — the security level of UDSSVC_ARCH_0037, the transfer of UDSSVC_ARCH_0036 — it resets itself, without asking.

Timing. Clause 10.2’s rule 1 and clause 10.4’s transition 6 both act on the session having been accepted, and clause 10.2 states the server “shall stop the current diagnostic session when it has sent the DiagnosticSessionControl positive response message”. So the transition is applied after the response is written, not before, and a DiagnosticSessionControl settled negatively leaves the active session unchanged. Session timeout reaches this crate as a transition to default from the binding, since UDSSVC_ARCH_0002 keeps uds_session’s tS3_Server out of this crate.

What is blocked, and on what. The table names five bodies of state, and this crate can hold only those whose services have message types to dispatch over. uds_protocol enumerates ReadDataByPeriodicIdentifier (0x2A), ResponseOnEvent (0x86), InputOutputControlByIdentifier (0x2F), DynamicallyDefineDataIdentifier (0x2C), LinkControl (0x87), AccessTimingParameter (0x83), Authentication (0x29) and SecuredDataTransmission (0x84) in UdsServiceType with their request and response identifiers, but provides a request or response message type for none of them.

The consequence is sharper than “not yet supported”. Those requests decode to uds_protocol’s Request::Other, documented there as “a known-but-unmodeled (or unrecognized) service”, so UDSSVC_ARCH_0005 settles them at 0x11 — and a server that wants to implement one cannot, because there is no typed message for a service trait to be written against and so nothing to name in UDSSVC_ARCH_0013’s assembly list. This crate therefore cannot distinguish a service the server chose not to support from one the stack cannot yet express, and answers both 0x11.

Until those message types exist, the periodic and event columns are rules with nothing to apply them to, and on_transition is the only route by which an application holding that functionality can comply. The classification is authored now because it is the part that does not change when the message types arrive.

Identifiers

The identifier traits sit to one side of both roles rather than inside either. That is the whole point of them, and it is the thing a list of signatures does not show:

One vocabulary, bound by both roles. The record types supply their own extent through the codec, so no length is written anywhere.

Architecture Element: The application owns its identifier enumerations UDSSVC_ARCH_0014
status: draft
tags: api, identifiers
origin: derived
depends on: UDSSVC_ARCH_0012

Data identifiers, routine identifiers and session types are supplied by the application as enumerations implementing this crate’s identifier traits. Those traits are free-standing and role-neutral: they belong to neither the client nor the server, and implementing one requires implementing no service trait.

pub trait DataIdentifier: Copy + Eq {
    /// The wire value.
    fn as_u16(self) -> u16;

    /// From the wire. `None` means this application does not support it.
    fn from_u16(value: u16) -> Option<Self>;

    /// Split this identifier's data record off the front of `buf`.
    fn split_record<'a>(self, buf: &'a [u8])
        -> Result<(&'a [u8], &'a [u8]), RecordError>;
}

A service trait binds one as an associated type, as UDSSVC_ARCH_0012 has it, and client code binds one per call and infers it from its arguments:

// server
impl ReadDataByIdentifier for Ecu {
    type Did = MyDid;
    async fn read(&mut self, did: MyDid, out: &mut ResponseSink<'_>)
        -> Result<(), Nrc> { .. }
}

// client: implements no service trait, and names no vocabulary type
let response = client
    .read_data_by_identifier(Address(0x0E80), &[MyDid::VehicleSpeed])
    .await?;

Rationale: ISO 14229-1 fixes identifier ranges and a small set of standardised values, not the catalogue: data and routine identifiers are vehicle-manufacturer or system-supplier specific, so the library cannot own those enumerations without either being wrong or being a u16.

Making from_u16 fallible is what wires the identifier set into clause 8.7. A requested identifier the application’s type cannot represent is an unsupported data parameter, which feeds the ALL / At least 1 / None classification of UDSSVC_ARCH_0008 and produces requestOutOfRange (0x31) or silence accordingly. The dispatcher needs no list of supported identifiers, and none can drift from the handler’s own match.

It returns Option rather than being a TryFrom<u16> impl because there is exactly one way to fail — the value is not in this application’s set — and an error type carrying that would exist only to be discarded.

split_record is specified by UDSSVC_ARCH_0026, which is also where the reason it carries no length appears.

Why the traits are free-standing rather than tied to a role. Client code implements no server trait and must still be able to name an identifier type (UDSSVC_ARCH_0024). A role-neutral trait, bound by whichever role is using it, is what makes one vocabulary serve both — an identifier type declared as an associated type of a server trait alone could not be named by the client.

The traits are also kept separate rather than grouped into one covering all three kinds, for the same reason UDSSVC_ARCH_0012 keeps services separate: a grouping trait would force a server with no routines to name a routine identifier type. Type inference at the call site already gives a caller the ergonomics a grouping trait would otherwise buy.

This is the completeness check the design is aiming for, and it is worth being precise about what it does and does not give. Adding a variant to the application’s identifier enumeration breaks that application’s own exhaustive match — in the handler, and in split_record — so a newly defined identifier cannot silently go unhandled. What it does not give is any guarantee that the enumeration covers what the vehicle’s identifier catalogue says it should; that is a requirement on the application, not something a type can enforce.