Changelog
All notable changes to the VECU Custody SDK for .NET are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[3.4.0] - 2026-05-12
Issue #467 holderId on release + Issue #464 COMPLETED state machine
Surfaces two additive SDK properties for the vecu-custody-service PR #484
release-state-machine refactor (issues #464 + #467). HolderId on
VehicleReleaseRequest lets multi-driver releases disambiguate which driver
is presenting; TokenEndpointOverride on CustodyClientOptions lets
integration tests target the direct auth-service backend without changing the
production constants table. Both properties are nullable and default to null —
existing callers continue to work unchanged. No breaking changes.
✨ Added
VehicleReleaseRequest.HolderId— new optionalstring?request field. When supplied, the custody service uses it to disambiguate which driver is releasing the vehicle under an ADR-042 multi-driver authorization (generate_mapped_user_idis applied server-side per ADR-043 §Amendment 2026-04-28; callers supply the rawpersonIdentityKey). Omitted from the wire entirely when null (via the SDK's globalJsonIgnoreCondition.WhenWritingNullpolicy), so legacy single-driver releases continue to work without modification. Required by upstreamvecu-release-bff#51to forward the verifier's driver identity through to custody-service.CustodyClientOptions.TokenEndpointOverride— new optionalstring?configuration field. Mirrors the existingBaseUrlOverrideprecedent (null-or-whitespace fallback, absolute-URL validation withhttp/httpsscheme check). Used by integration tests that target the direct auth-service backend (vecu-authorization.{env}.{domain}.manheim.com/v2/token) instead of the CIP gateway entry inCustodyServiceConstants.TokenEndpointUrls. Production callers should leave this null. The newCustodyClientOptions.GetTokenEndpoint()helper resolves the effective URL via the same null-or-whitespace pattern asGetBaseUrl().
🔁 Changed
- Release-state-machine assertions aligned with
vecu-custody-servicePR #484. Existing release tests (Test_ReleaseVehicle_Success,Test_ReleaseVehicle_WithLocation_Success,Lifecycle_CreateGetRelease_StatusTransitionsAndAuditTrail,Get_AfterRelease_DeserializesReleasedStateCorrectly) now assertStatus == COMPLETEDpost-release (wasRELEASED). Co-shipped with the v3.3.1COMPLETEDenum value and the service-side state-machine refactor. No public-API impact — assertion-level alignment only.
🐛 Fixed
- Issue #467 regression coverage — four new
ReleaseVehicleHolderIdTestscover (a) single-driver release with explicitHolderId, (b) single-driver release with nullHolderId(legacy compat), (c) multi-driverADR-042release withHolderIddisambiguating between drivers, (d)WaitForAuthorizationAsyncreturns immediately onStatus == COMPLETED(guards the v3.3.1 terminal-status whitelist fix). Seevecu-custody-service#467for the original defect. - Regression coverage for the 2026-05-11
AddOrCreateDriverAsyncbug. When the backend's uniqueness check used to match terminal (CANCELLED) authorizations, the convenience method returnedCreated=false, Added=trueagainst the cancelled auth — silently lying about the add. Backend was fixed in vecu-custody-service. NewAddOrCreate_OnCancelledAuth_CreatesNewAuthInsteadOfReusingTerminaltest pins the post-fix contract:result.AuthorizationIdis a NEW auth ID,result.Created == true(POST branch took),result.Version == 1, andGetAuthorizationAsyncon the result returnsStatus == PENDING. Each assertion's failure message references the original bug shape so future regressions point straight at the report.
💡 Migration Notes
- Source compatibility: existing code that constructs
VehicleReleaseRequestwithAuthorizationIdandReleaseMethodcompiles and runs unchanged. NewHolderIddefaults to null; set it explicitly when you want the multi-driver disambiguation side effect on the emittedcustody.vehicle.releasedevent payload. - Wire compatibility: outgoing JSON for legacy callers is byte-identical
(the new
HolderIdfield is omitted when null). Subscribers of the release event continue to receive their existing payload shape; callers that DO supplyHolderIdenable the newholderId+releasedPoolCompositeIdfields on the event downstream. TokenEndpointOverride: purely opt-in. Production deployments should not set it. If misused (e.g., pointing at the wrong host), the failure mode is a 401 /InvalidClientExceptionfrom the token exchange — not a silent success.
[3.3.1] - 2026-05-11
Wire-fix coordinated with vecu-custody-service #464 + #467
Adds AuthorizationStatus.COMPLETED to the enum and expands the SDK's
terminal-status whitelist to include it. Without this fix,
WaitForAuthorizationAsync would have polled a terminal auth indefinitely the
moment the producer-side state machine flipped to COMPLETED. Shipped ~30
minutes before vecu-custody-service PR #484 was unblocked to merge. See
engineering-journal/LEARNINGS.md
2026-05-11
for the cross-repo enum-drift prevention pattern that surfaced this.
✨ Added
AuthorizationStatus.COMPLETED— new enum value (7). Used by the custody service for vehicle releases that complete thePENDING → ACCEPTED → COMPLETEDstate machine (post-PR-#484). The legacyRELEASEDvalue is retained for historical records that pre-date the deployment.
🐛 Fixed
WaitForAuthorizationAsynccorrectly recognizesCOMPLETEDas terminal.AuthorizationOperations.IsTerminalStatusandMockCustodyServiceClient.IsTerminalStatusboth addCOMPLETEDto the terminal-status whitelist. Without this, the waiter would poll a COMPLETED auth every 5s until the default 5-minute timeout instead of returning immediately. Production symptom would have been ~30-60s spurious delays on the happy path the moment the producer-side state machine flipped.
💡 Migration Notes
- Source compatibility: additive enum value — exhaustive
switchstatements overAuthorizationStatusneed a new arm forCOMPLETED. Callers that fall through to a generic default branch are unaffected. - Wire compatibility: the SDK's
EnumMemberJsonConverterhandles unknown enum values gracefully on deserialize, so older SDK versions receiving aCOMPLETEDresponse from the new service will throw a clear typed exception rather than silently mis-deserialize. Upgrade before the producer-side change deploys to your environment.
[3.3.0] - 2026-05-04
ADR-042 multi-driver runtime
This release wires up the runtime half of the ADR-042 multi-driver
authorization work. v3.2.0 added the contract surface (types, exceptions,
DriverAuthorizationMode enum, AuthorizationStatus.ASSIGNED); v3.3.0 wires
those types to real HTTP calls and adds the convenience-method fallback flow.
The retired vin_origin_same_company_allowed exception mode is removed; the
marker is unconditionally strict. Coordinate the Production opt-in via
#log-vc-custody-integration before deploying multi-driver code to
Production.
✨ Added
AssignAuthorizationToDriverAsync— wired runtime (replaced the v3.2.xNotImplementedExceptionskeleton). IssuesPUT /v1/authorizations/{id}/assignwithIf-Match: "<n>"(RFC 7232). Signature:(authorizationId, personIdentityKey, ifMatchVersion, actorIdentifier?, ct)— no request object; SDK builds theAssignAuthorizationRequestinternally. ReturnsHttpResponseEnvelope<AssignAuthorizationResponse>(per ADR-042 §AC-12..AC-14).UpdateAuthorizationDriversAsync—PATCH /v1/authorizations/{id}/driverswith ETag-guarded optimistic concurrency. Signature:(authorizationId, PatchDriversRequest, ifMatchVersion, ct).ifMatchVersionis a separate parameter, NOT embedded in the request body. ReturnsHttpResponseEnvelope<PatchDriversResponse>.AddOrCreateDriverAsync— convenience helper. Two modes: PATCH-only (whencreateRequestIfMissingis null — the v3.2.x default), and try-POST-then-PATCH (when supplied — v3.3.0 addition, per AC-20). PATCH attempts use boundedStaleETagExceptionretry (3 attempts, re-listing between).ListAuthorizationsAsync(string vin, ct)— listing primitive for multi-driver integration; auto-scoped to the caller'sclientIdserver-side via JWT. Single-page in v3.3.0; pagination wrapping is a follow-up.CreateAuthorizationRequest.AuthorizedDrivers+DriverAuthorizationMode— multi-driver POST shape per AC-17. Supply a non-emptyAuthorizedDriverslist together withDriverAuthorizationMode = MultiDriver, or supplyPersonIdentityKeyalone.CreateAuthorizationRequestnow implementsIValidatableObjectto surface XOR / mode-mismatch violations asValidationResultentries before the wire.AssignAuthorizationRequest— internal request body type (sealed record) forPUT /assign. Built by the SDK from theAssignAuthorizationToDriverAsyncparameters.AuthorizationStatus.ACCEPTEDandAuthorizationStatus.ASSIGNED— two new enum values. Neither is terminal —ASSIGNEDindicates a multi-driver authorization where one specific driver has been bound; the existing transitions toRELEASED|EXPIRED|REVOKED|CANCELLEDstill apply. Audit exhaustiveswitchstatements.- Seven new typed exceptions for multi-driver paths:
MultiDriverModeRequiredException(400, inheritsValidationException),EmptyDriverListException(400, inheritsValidationException),NotAuthorizationOwnerException(403, inheritsForbiddenException),StaleETagException(412, inheritsCustodyException— transient),AuthorizationAlreadyAssignedException(409, inheritsConflictException),DriverLimitExceededException(429, inheritsCustodyException— NOT aRateLimitException; operational cap, not a transient throttle),CustodySdkInvariantException(n/a, inheritsCustodyException— SDK-side invariant violation; surface to operators).
🔁 Changed
CreateAuthorizationRequest.PersonIdentityKeyis now optional (wasrequiredin v3.2.x). XOR with the newAuthorizedDriverslist: set this for single-driver mode (default), or leave null and supplyAuthorizedDriversfor multi-driver. The XOR rule is enforced client-side viaIValidatableObject.Validate.UniquenessScope.VinOriginSameCompanyAllowedis retired. The marker is unconditionally strict; two single-driver POSTs for the same(VIN, origin)collide on the second withDUPLICATE_AUTHORIZATION. Pre-v3.3.0 records may carry the historical scope value for forensic reasons. The exception mode is removed from the server regardless of SDK version — exception-mode consumers will see 409s in Sandbox as soon as the server cutover lands, even on prior SDK versions.
💡 Migration Notes
See Upgrading for the version-by-version
matrix. Single-driver consumers: bump the package and add ACCEPTED /
ASSIGNED cases to exhaustive switches. Multi-driver consumers:
coordinate the Production opt-in via #log-vc-custody-integration
before deploy.
The SDK repo's v3.3.0 quickstart is the canonical step-by-step migration document.
[3.2.1] - TBD ship date
🔒 Security — bearer-token leak in TokenProviderAuthenticationHandler
- Removed a
LogDebugstatement inTokenProviderAuthenticationHandler.ExchangeTokenAsyncthat emitted the acquired internal bearer access token verbatim ("Internal token: {InternalToken}"). Anyone with Debug-level log access to a process running the SDK could read the bearer credential out of their log stream and replay it against the Custody Service until expiry. Discovered while writing iter-1 LogRedactionTests; fix removes the offending call without altering the surroundingLogInformation(which only recordsexpires_in). Bumping to v3.3.0 inherits this fix transparently.
💡 Migration Notes
Re-audit your own auth code for parallel patterns: LogDebug lines
that interpolate token-bearing values, structured-logging fields named
Token / AccessToken / Bearer. The SDK fix does not protect
against parallel sites in your own code.
[3.2.0] - 2026-04-25
ADR-040 + ADR-041 Sync
This is a MINOR release that surfaces the custody-service ADR-040
(organization-id-payload uniqueness scopes) and ADR-041 (origin
canonicalization via geohash) contracts in the SDK. The Existing* fields on
DuplicateAuthorizationException become nullable — review the migration
guidance below before upgrading. Requires a custody service that has shipped
ADR-041 (non-prod and beyond).
✨ Added
CreateAuthorizationRequest.OrganizationId— new optional request field (string, 2–128 chars, pattern^[A-Za-z0-9][A-Za-z0-9_\-.]*[A-Za-z0-9]$— the regex itself enforces ≥2 chars since start and end characters are separate atoms). When supplied, the custody service applies theVinOriginSameCompanyAlloweduniqueness scope, permitting concurrent authorizations for the same VIN and origin within the same organization. When omitted, the service falls back to the legacyVinOriginStrictscope (one authorization per VIN + canonical origin globally).AuthorizationResponse.UniquenessScope— new response field. Enum values:VinOriginStrict(noOrganizationIdwas supplied) orVinOriginSameCompanyAllowed(the request was scoped to an organization). Echoed on both201 Createdand409 Conflictresponses so callers can verify which scope was effective for the request.AuthorizationResponse.OrganizationId— echoed organization id when the scope isVinOriginSameCompanyAllowed;nullunderVinOriginStrict.AuthorizationResponse.OriginGeohash— precision-7 geohash of the canonicalized origin address (≈152 m × 152 m cell) per ADR-041. The marker key the service uses for uniqueness is derived from this geohash, not from the raw origin string. Nullable for migration-window and pre-ADR-041 terminal records that were backfilled without geohash data; populated on all new responses.AuthorizationResponse.OriginFormattedAddress— Amazon Location Service canonical address string for the geocoded origin. Surface this back to end users when echoing the authorization, so they see the address as the system stored it. Nullable for the same migration-window reasons asOriginGeohash.AuthorizationResponse.LocationUri— theLocationresponse header is now parsed and exposed on the response model. Always present on201 Created; on409 Conflict, present only under the same disclosure guard + enrichment-success conditions that gateExistingAuthorization(see the Behavioral Changes section below and the api-reference page for full details). Points atGET /v1/authorizations/{id}for the authoritative authorization (newly created or pre-existing duplicate).PoolCapacityExceededException— new typed exception for HTTP 429 responses withErrorCode=POOL_CAPACITY_EXCEEDED. Thrown when the authorization pool has reached the per-VIN concurrent-authorization cap under the effective uniqueness scope. Inherits fromRateLimitException. Properties:EffectiveScope(UniquenessScope),OrganizationIdPresent(bool),CurrentCount(int),MaxAllowed(int),CorrelationId(string).GeocodingServiceUnavailableException— new typed exception for HTTP 503 responses withErrorCode=GEOCODING_SERVICE_UNAVAILABLE. Thrown when the upstream geocoder (Amazon Location Service) is unreachable or returning errors and the service cannot canonicalize the origin. Properties:RetryAfterSeconds(int?, parsed from theRetry-Afterheader — typical range 30–300 s),CorrelationId(string),Retryable(bool, alwaystrue). Inherits fromNetworkException.InvalidOriginAddressException— new typed exception for HTTP 400 responses withErrorCode=INVALID_ORIGIN_ADDRESS. Thrown when the geocoder declines the submittedOrigin. Property:Reason(OriginRejectionReasonenum:LowRelevance|NoMatch|Interpolated|InvalidCategory; PascalCase mapping of the SCREAMING_SNAKE wire-format values). Inherits fromValidationException. Requires custody service ≥ ADR-041 (non-prod and beyond) — earlier service versions raise a genericValidationExceptionfor the same geocoder-rejection conditions.
🔁 Changed
DuplicateAuthorizationException.EffectiveScope(new property) —UniquenessScopeenum carried alongside the existing409envelope so consumers can branch on whether they hit the strict or same-company scope.DuplicateAuthorizationException.OrganizationIdPresent(new property) —boolflag indicating whether the original request was scoped to an organization. Drives the safe-access pattern for the now-nullableExistingAuthorizationsubobject (see Breaking Changes).DuplicateAuthorizationException.LocationUri(new property) —Uri?parsed from the responseLocationheader on the 409 envelope. Tracks the same nullability semantics asExistingAuthorization: populated when the disclosure guard passes AND service-side enrichment succeeded; null otherwise. Convenience pre-parsed pointer atGET /v1/authorizations/{id}for the existing authorization.- Retry policy honors
Retry-Afteron 503 — the Polly resilience pipeline now reads theRetry-Afterresponse header on 503 responses and uses its delay (delta-seconds or HTTP-date) for the next retry attempt. Falls back to the existing exponential backoff when the header is missing or malformed.GeocodingServiceUnavailableException.RetryAfterSecondsexposes the same value to consumers writing custom retry logic. - Defensive 5-minute upper cap on retry delays — every
Retry-After- derived delay AND every exponential-backoff fallback is capped at 5 minutes. Defends against pathological upstream values (Retry-After: 9999999or a far-future HTTP-date) that would otherwise schedule a multi-day Polly wait. The total retry budget (number of attempts) is unchanged.
💥 Breaking Changes
Migration Notes — Nullable Existing* Fields
DuplicateAuthorizationException.ExistingAuthorization and
ExistingAuthorizationId are now nullable. The 409 envelope
populates them only when the ADR-040 §7.1 disclosure guard passes
(same clientId OR both requests carrying equal, non-null
OrganizationId); otherwise they are omitted. The fields are also
null when the service hits a transient enrichment failure on a
guard-eligible conflict and falls back to the minimal envelope. Callers
that unconditionally dereference .ExistingAuthorizationId after
catching DuplicateAuthorizationException will throw
NullReferenceException against a v3.2.0+ service.
Safe-access pattern:
catch (DuplicateAuthorizationException ex)
{
if (ex.ExistingAuthorization is not null)
{
// Guard passed (same-client or matching OrganizationId) AND the
// service successfully enriched the response. Reuse or supersede
// the existing record.
var existing = await _client.GetAuthorizationAsync(
ex.ExistingAuthorization.AuthorizationId);
}
else
{
// Existing record not surfaced. Could be the disclosure guard,
// could be a transient enrichment failure on the service side —
// the 409 side-channel does NOT distinguish. Per ADR-040 §7.1,
// do NOT infer disclosure-guard denial from a null
// ExistingAuthorization. If you need a deterministic check, use
// a fresh GET /authorizations/{id} with a known ID.
_logger.LogInformation(
"Conflict on VIN/origin; existing record not surfaced. " +
"EffectiveScope={Scope} OrgIdPresent={OrgIdPresent} " +
"CorrelationId={CorrelationId}",
ex.EffectiveScope, ex.OrganizationIdPresent, ex.CorrelationId);
}
}
ex.OrganizationIdPresent and ex.EffectiveScope are informational
(useful for logs and metrics) — they are NOT the discriminator for
whether the existing record is present, because strict-scope same-client
conflicts pass the disclosure guard with OrganizationIdPresent=false.
The breaking surface is shape-only — the exception type and HTTP status remain the same. Code paths that already null-check the property need no changes.
⚠️ Behavioral Changes
- Origin canonicalization via geohash (ADR-041) — case, whitespace, and
abbreviation variants of the same physical address now resolve to the same
precision-7 geohash and therefore collide on uniqueness. Requests that
previously succeeded with
201 Createdbecause the rawOriginstring differed by case or punctuation ("123 main st"vs."123 Main St."vs."123 MAIN ST") now return409 Conflictagainst the canonical marker. Update any integration-test fixtures that relied on string-level variation to bypass uniqueness; use distinct addresses or a fresh VIN instead. Locationheader is conditional on 409 — the custody service emits theLocationheader on every201 Created. On409 Conflict, the header is present only when the ADR-040 disclosure guard passes (same client OR matching non-nullOrganizationId) AND service-side enrichment succeeded — the same conditions that gateExistingAuthorizationon the 409 envelope. The SDK surfaces the header throughAuthorizationResponse.LocationUri(Uri?); see the api-reference page for the full conditions. No action required for existing callers; new callers can dereference the location URI directly to fetch the authoritative record without rebuilding the URL, but must null-check first.
📐 Design Notes
- The
OrganizationIdfield is opt-in: omitting it preserves v3.1.0 behavior (strict global uniqueness per VIN + canonical origin). This makes the upgrade an additive minor for callers that don't yet need the same-company exception scope. PoolCapacityExceededExceptioninherits fromRateLimitExceptionso that existingcatch (RateLimitException)handlers continue to work, while new callers can branch on the typed subclass to inspectEffectiveScope/CurrentCount/MaxAllowed.GeocodingServiceUnavailableExceptioninherits fromNetworkException(5xx family) so existing retry-on-network-error patterns keep working. TheRetryableproperty is alwaystruefor this type — distinct fromInvalidOriginAddressException(400, never retryable, the address itself is the problem).InvalidOriginAddressExceptioninherits fromValidationExceptionso it flows through the same field-error handling consumers use today. TheReasonenum lets callers offer targeted UX:LowRelevance/NoMatch— prompt the user for a more specific address.Interpolated— the geocoder synthesized a point on a road segment; confirm the address is a real building or use a nearby place.InvalidCategory— the address is real but disqualified (e.g., residential when only commercial is allowed).
🔄 Migration from 3.1.0
- Make duplicate handlers null-safe (required if you catch
DuplicateAuthorizationException). Gate onex.ExistingAuthorization is not nullbefore dereferencing — and do not infer disclosure-guard denial from a null value, since transient enrichment failures share the same shape (see the Migration Notes callout above). - Update test fixtures that relied on origin-string variation. If your
tests deliberately submit
"123 main st"and"123 Main St."as "different" origins to assert idempotency or distinct creation, switch to using two genuinely different addresses or two different VINs. - (Optional) Adopt
OrganizationIdonCreateAuthorizationAsyncto unlock the same-company concurrent-authorization scope. No effect on single-org callers. - (Optional) Catch the three new typed exceptions for richer telemetry
and UX:
catch (PoolCapacityExceededException)beforecatch (RateLimitException).catch (GeocodingServiceUnavailableException)beforecatch (NetworkException)— and surface theRetryAfterSecondshint to your operator dashboards.catch (InvalidOriginAddressException)beforecatch (ValidationException)— and branch onReasonfor a better end-user error message.
[3.1.0] - 2026-04-23
✨ Added
- Six new nullable companion properties on
DuplicateAuthorizationExceptioncarrying the existing authorization's record fields when the server returns an enrichedDUPLICATE_AUTHORIZATION409 body:ExistingStatus,ExistingVin,ExistingOrigin,ExistingDestination,ExistingCreatedAt,ExistingExpiresAt. Populated when the caller owns the record (server-side ownership check); null on older server deployments or cross-client sanitized responses. Callers recovering from a timeout+retry can now reconstruct the orphan authorization without a follow-upGetAuthorizationAsync. - Matching nullable properties on
ErrorResponse— the wire-layer carriers that thread enriched fields onto the exception.
💡 Migration Notes
No required code changes. The new fields are nullable and additive.
[3.0.1] - 2026-04-23
🐛 Fixed
DuplicateAuthorizationException.ExistingAuthorizationIdis now populated from the server's 409 response body. Previously the response handler constructed the exception via the single-argDuplicateAuthorizationException(string message)constructor, which hardcodedExistingAuthorizationId = string.Empty. Callers who timed out onCreateAuthorizationAsync, retried, and caught the 409 could not recover the orphaned authorization. The server has always emittedexistingAuthorizationIdon the 409 body; only the SDK was dropping it.
💡 Migration Notes
Prior to v3.0.1, some callers tracked
ExistingAuthorizationId == string.Empty as a "not populated"
sentinel. After v3.0.1, real IDs flow through. Switch to
string.IsNullOrEmpty(ex.ExistingAuthorizationId) before using the
value if you relied on the empty-string sentinel. (Note: v3.2.0 then
made the property nullable for the disclosure-guard suppression case;
the recommended pattern is now if (ex.ExistingAuthorizationId is { } existingId) { … }.)
[2.1.0] - 2026-04-20
✨ Added
ConflictException— new public base class for all HTTP 409 Conflict exceptions. Consumers can nowcatch (ConflictException)to handle any conflict generically.PoolConflictException— new typed exception for 409POOL_CONFLICTresponses fromPOST /v1/authorizations. ExposesExistingClientIdproperty.
🔁 Changed
DuplicateAuthorizationException,InvalidStateException, andVehicleNotReleasableExceptionnow inherit fromConflictExceptioninstead ofCustodyExceptiondirectly. Existingcatchchains on each specific type remain source- and binary-compatible; existingcatch (CustodyException)continues to catch them.
🐛 Fixed
- 409 responses with
"error":"POOL_CONFLICT"fromPOST /v1/authorizationswere previously misclassified asNetworkException(StatusCode=ServiceUnavailable/ErrorCode=NETWORK_ERROR). They now correctly throwPoolConflictException(StatusCode=Conflict/ErrorCode=POOL_CONFLICT). Retry logic that distinguishes transient from non-transient failures viaStatusCodeorErrorCodeis now reliable.
⚠️ Behavior Change
Prior to v2.1.0, any 409 with an unrecognized error code was thrown as
NetworkException with StatusCode=ServiceUnavailable. Callers with
catch (NetworkException) { Retry(); } were silently spin-retrying a
non-transient condition. In v2.1.0 the same response throws
ConflictException with StatusCode=Conflict. If your retry handler
catches only NetworkException, the exception now bubbles up as
intended — add catch (ConflictException) (or rely on
catch (CustodyException)) before the NetworkException catch.
[3.0.0] - 2026-04-20
Breaking Release
This is a MAJOR release. Three public methods have been removed and HTTP
403 responses are now classified as typed exceptions instead of
NetworkException. Review the migration guidance below before upgrading.
💥 Removed
- BREAKING:
ICustodyServiceClient.GetCustodyStatusAsyncremoved. The underlyingGET /v1/transfers/status/{vin}route was taken offline by the server team on 2026-04-14 pending Broken Object-Level Authorization (BOLA, OWASP API1:2023) controls. Will be re-added in a future release when server-side per-object authorization is in place. Track at vecu-custody-service#208. - BREAKING:
ICustodyServiceClient.GetCustodyChainAsyncremoved. Same reason as above; underlying routeGET /v1/transfers/history/{vin}offline. - BREAKING:
ICustodyServiceClient.RecordTransferAsyncremoved. The underlyingPOST /v1/transfersroute was taken offline by the server team on 2026-04-14 pending Broken Object-Level Authorization (BOLA, OWASP API1:2023) controls. Will be re-added in a future release when server-side per-object authorization is in place. Track at vecu-custody-service#208. - BREAKING: DTOs
CustodyTransferRequest,CustodyTransferResponse,TransferHistoryResponse,TransferHistoryItem,CustodyStatusand theCustodyServiceConstants.Endpoints.Transferspath constant are removed as they were used only by the removed methods. Consumers that referenced these types directly (e.g., for building requests or typing return values) will need to remove those references.
✨ Added
ForbiddenException— new public base exception for HTTP 403 responses.StatusCodeisHttpStatusCode.Forbidden. Consumers can nowcatch (ForbiddenException)to handle any forbidden response generically instead of the previouscatch (NetworkException)misclassification.RouteNotAvailableException : ForbiddenException— new typed exception for gateway-level 403 responses where the route is not in the API Gateway spec.ErrorCodeis"ROUTE_NOT_AVAILABLE". Thrown when the 403 body is the API Gateway default shape{"message":"Forbidden"}rather than a structured application error.
🔁 Changed
- BEHAVIOR CHANGE (retry-logic callers): Prior to 3.0.0, HTTP 403 responses
were thrown as
NetworkExceptionwithStatusCode=ServiceUnavailable/ErrorCode=NETWORK_ERROR— causing retry-logic callers to spin-retry a non-transient permission failure. In 3.0.0, 403 responses now throwForbiddenException(orRouteNotAvailableExceptionfor gateway-default bodies) withStatusCode=Forbiddenand a descriptiveErrorCode. Migration: addcatch (ForbiddenException)(or the existingcatch (CustodyException)) before theNetworkExceptioncatch.
📐 Design Notes
- Removing the 3 methods was chosen over marking them
[Obsolete]because all consumer calls currently fail with 403 regardless — leaving the methods in place would mislead consumers into thinking they're usable. A clean removal with a documented plan to re-add in a future minor release (once server-side BOLA controls land) gives consumers a clear signal. ForbiddenException+RouteNotAvailableExceptionmirrors theConflictException+PoolConflictExceptionhierarchy introduced in 2.1.0 — a consistent pattern across HTTP status families.
🔄 Migration from 2.x
- Remove any calls to
RecordTransferAsync,GetCustodyStatusAsync, orGetCustodyChainAsync— these calls have been returning HTTP 403 for all consumers since 2026-04-14 regardless, so no runtime behavior is lost. - Remove DTO references. If your application defines its own types,
helpers, or method signatures that reference
CustodyTransferRequest,CustodyTransferResponse,TransferHistoryResponse,TransferHistoryItem, orCustodyStatus, remove or locally redefine them. These types are no longer exported by the SDK. - Add
catch (ForbiddenException)before any existingcatch (NetworkException)to correctly handle 403 responses (for example, token scope denials that may begin flowing through structured error bodies in future server releases). - Wait for the BOLA re-enablement release before reintroducing custody-status / custody-chain / transfer recording calls against real server routes.
[1.2.3] - 2026-03-19
🐛 Bug Fixes
- AuthorizationRole enum: Replaced
DRIVER/TRANSPORTERwithOWNER/ADMIN/DRIVERto align with the authorization pool service.TRANSPORTERwas being rejected by the auth-pool with a 400 validation error, silently preventing authorizations from reaching the wallet.
✨ Features
- Docker test app: Added
examples/DockerTestfor end-to-end SDK validation in a containerized environment
🔧 Infrastructure
- Security: Removed
.envfile with credentials from tracking, added.env.exampletemplate - TokenProviderAuthenticationHandler: Fix from docker-test-app branch
[1.3.0] - Unreleased
Pending Release
These changes are implemented on the development branch and will ship once
the version is bumped and the CI pipeline publishes to Artifactory.
Breaking Changes
Authorization API Contract
- CreateAuthorizationRequest:
RequestedByrenamed toAuthorizedBy;Purpose(AuthorizationPurpose) replaced byRole(AuthorizationRole:DRIVER,TRANSPORTER);ValidityHours(int?) replaced byValidUntil(DateTimeOffset?);TransportOrderIdis now optional;Constraintsremoved; new required fieldMakeModel(string); new optional fieldsPermittedActions(List<string>),BuyerIdentityKey(string) - AuthorizationResponse (POST): removed
PersonIdentityKey,EstimatedCredentialAt,ReleasabilityRequired,Message,RetryInfo; addedExpiresAt; response now contains onlyAuthorizationId,Vin,Status,Origin,Destination,CreatedAt,ExpiresAt - AuthorizationDetails (GET): removed
TransportOrderId,CredentialId,RetryStatus;ValidUntilrenamed toExpiresAt; addedRole,PoolId,AuthPoolAuthorizationId,CancelledAt,CancelledBy,CancellationReason - CancelAuthorizationRequest:
Reason(enum) +Notesreplaced by singleCancellationReason(string, minimum 10 characters) - CancelAuthorizationResponse: removed
Message; addedVin,CancellationReason
Transfers API Contract
- CustodyTransferRequest: new required field
Vin;Locationobject replaced byLocationId(string);Timestampnow optional; addedMetadata(Dictionary<string, object>); removedVerificationMethod,Notes - CustodyTransferResponse: removed
CredentialId,LedgerEventId;Hashrenamed toEventHash;RecordedAtrenamed toCreatedAt; addedSessionId,Vin,TransferType;Statusis nowstring - CustodyStatus: now a flat structure with
Vin,SessionId?,Status?,CurrentCustodian?,CurrentLocation?,LastTransferAt?,TransferCount
Error Response
RequestIdrenamed toCorrelationIdonErrorResponseand all exception types
Enums
AuthorizationPurposeenum replaced byAuthorizationRole(DRIVER,TRANSPORTER)
Coordinates
Latitude/Longituderenamed toLat/Lngon coordinate objects
[1.1.0] - Unreleased
Pending Release
These features are implemented on the development branch and will ship once
the version is bumped and the CI pipeline publishes to Artifactory.
✨ Features
Transfers API (New)
- RecordTransferAsync() - Record a custody transfer between locations with verification details
- GetCustodyStatusAsync() - Retrieve the current custody status for a vehicle authorization
- GetCustodyChainAsync() - Retrieve the full custody chain history for a vehicle (VIN)
.NET 10 Multi-targeting
- SDK now targets both
net8.0andnet10.0(multi-targeting via<TargetFrameworks>) - Consumers may target either .NET 8.0 (LTS) or .NET 10 without any configuration changes
Testing Builders (New)
- AuthorizationResponseBuilder - Fluent builder for constructing test authorization response objects
- AuthorizationDetailsBuilder - Fluent builder for constructing detailed authorization test data
- MockHttpRouteBuilder - Configure mock HTTP routes for integration-style testing without a real API
Options Validation
- CustodyClientOptionsValidator - Validates
CustodyClientOptionsat startup viaIValidateOptions<T>, surfacing misconfiguration errors before the first API call
📦 Dependency Upgrades
Microsoft.Extensions.*→ 10.0.2Polly→ 8.6.5
🔧 Infrastructure
- .NET SDK pinned to 10.0.100 in
global.json - C# language version set to
latest(C# 13 with .NET 10)
[1.0.0] - 2026-02-26
Migration Note: Authentication and Environment Model Changed in v1.1.0
The v1.0.0 release used a different authentication and environment model.
Starting in v1.1.0, all environments use the same TokenProvider callback
— you supply a bearer token (e.g., a CIP token) and the SDK exchanges it
internally for a VECU service token. The Sandbox, Development, Staging,
and Production environment names from v1.0.0 are replaced by Sandbox,
NonProd, PreProd, and Production. If upgrading from v1.0.0, update your
environment enum value and replace any API key configuration with a
TokenProvider callback.
Stable Release
The first stable release of the VECU Custody SDK for .NET. This release includes CI/CD improvements, accurate documentation, and a stable API ready for production use.
✨ Features
Custody Permits API
- CreateAuthorizationAsync() - Create new custody authorizations for vehicles
- GetAuthorizationAsync() - Retrieve authorization details by ID
- CancelAuthorizationAsync() - Cancel existing authorizations with reason tracking
- WaitForAuthorizationAsync() - Poll for authorization completion with configurable timeout and custom predicates
Releasability API
- GetReleasabilityAsync() - Check if vehicles can be released from custody
- Gate pass data retrieval with barcode information
- Business signals evaluation (payment, title, inspection, holds)
- Blocker identification for non-releasable vehicles
Environment Support
- Sandbox - API key authentication for testing
- Development - OAuth bearer token authentication
- Staging - OAuth bearer token authentication
- Production - OAuth bearer token authentication
Dependency Injection Integration
- AddCustodyClient() extension method for
IServiceCollection - Automatic client lifetime management
- Configuration-based setup with
CustodyClientOptions - Typed
ICustodyServiceClientinterface for easy mocking
Testing Support
- Built-in mock client -
MockCustodyServiceClientfor unit testing - Test data builders - Fluent builders for creating test data
- Configurable mock responses - Control mock behavior for different scenarios
- No external dependencies required for testing
Resilience & Reliability
- Polly retry policies - Automatic retries for transient failures
- Typed exception hierarchy - Specific exceptions for different error scenarios
- Rate limiting support - Automatic handling of 429 responses with retry-after
- Cancellation token support - All methods support
CancellationToken
Developer Experience
- Strongly-typed models - All requests and responses use C# records
- Comprehensive XML documentation - IntelliSense support for all public APIs
- Async/await patterns - Modern asynchronous programming model
- Fluent configuration - Easy-to-use configuration API
📦 Package Information
- Package Name:
CoxAuto.Vecu.CustodySdk - Version: 1.0.0
- Target Framework: .NET 8.0 (LTS)
- Language: C# 12
🔧 CI/CD & Infrastructure (v1.0.0)
- Re-enabled test coverage CI job with 90%+ coverage enforcement
- Re-enabled security vulnerability scanning in CI pipeline
- Fixed Artifactory authentication - migrated from insecure
IFS=':'parsing to split secrets (ARTIFACTORY_USER+ARTIFACTORY_TOKEN) - Added global.json for SDK version pinning (.NET 8.0.100)
- Added test project back to solution - tests now run in CI
- Updated framework support documentation - accurately reflects .NET 8.0 support (not 6.0/7.0)
📋 Requirements
- .NET SDK: 8.0 or later
- C# Version: 12 or later
- Authentication:
- Sandbox: API key (obtain from VECU team)
- Other environments: OAuth 2.0 bearer token
🚧 Known Limitations
The following APIs are not yet implemented in the .NET SDK and are planned for future releases:
- Audit API - View audit trails for custody operations
For this operation, please use the Python SDK or contact the VECU team for roadmap details.
📚 Documentation
- Getting Started - Quick start guide
- Installation - Installation instructions
- Configuration - Environment and authentication setup
- API Reference - Complete API documentation
- Testing - Testing with mock client
🐛 Bug Reports & Feedback
This is a stable release. We welcome feedback and bug reports to help improve the SDK.
- GitHub Issues: vecu-custody-csharp-sdk/issues
- Support Channel: #vehiclecustody-chainproofers
- Email: vecu-support@coxautoinc.com
🔄 Planned for Next Release
The following features are planned for the next release (tentative):
- Audit API - View audit trails for custody operations
- List authorizations - Pagination support for authorization queries
- Webhook support - Receive real-time notifications for authorization events
- Enhanced caching - Built-in response caching for releasability checks
- Batch operations - Process multiple authorizations in a single call
💡 Migration Notes
N/A - This is the initial release.
🙏 Acknowledgments
Thanks to the VECU team and early beta testers for their feedback and contributions.
Version History
| Version | Release Date | Status | Notes |
|---|---|---|---|
| 3.3.0 | 2026-05-04 | Stable | ADR-042 multi-driver runtime: 4 new methods (AssignAuthorizationToDriverAsync, UpdateAuthorizationDriversAsync, AddOrCreateDriverAsync, ListAuthorizationsAsync), 7 new typed exceptions, 2 new AuthorizationStatus values (ACCEPTED, ASSIGNED), PersonIdentityKey now optional, UniquenessScope.VinOriginSameCompanyAllowed retired |
| 3.2.1 | TBD ship date | Stable | SECURITY: removed LogDebug line in TokenProviderAuthenticationHandler that emitted internal bearer access token verbatim |
| 3.2.0 | 2026-04-25 | Stable | ADR-040 + ADR-041 sync: OrganizationId request field, UniquenessScope / OriginGeohash / OriginFormattedAddress response fields, 3 new typed exceptions, Retry-After honored on 5xx; NULLABILITY: DuplicateAuthorizationException.ExistingAuthorization now nullable (disclosure guard + enrichment fail-closed) |
| 3.1.0 | 2026-04-23 | Stable | 6 nullable companion properties on DuplicateAuthorizationException (ExistingStatus, ExistingVin, ExistingOrigin, ExistingDestination, ExistingCreatedAt, ExistingExpiresAt) for orphan-recovery without a follow-up GET |
| 3.0.1 | 2026-04-23 | Stable | FIX: DuplicateAuthorizationException.ExistingAuthorizationId now populated from the 409 body (was always string.Empty) |
| 3.0.0 | 2026-04-20 | Stable | BREAKING: removed RecordTransferAsync/GetCustodyStatusAsync/GetCustodyChainAsync; added ForbiddenException + RouteNotAvailableException for correct 403 classification |
| 2.1.0 | 2026-04-20 | Stable | ConflictException base class for all 409 typed exceptions; PoolConflictException for 409 POOL_CONFLICT; existing 409 typed exceptions re-parented under ConflictException |
| 1.3.0 | - | Unreleased | Breaking API contract changes: renamed fields, simplified models, new CorrelationId |
| 1.2.3 | 2026-03-19 | Stable | Fix AuthorizationRole enum, Docker test app, security fix |
| 1.1.0 | - | Unreleased | .NET 10 multi-targeting, Transfers API, new testing builders, options validation |
| 1.0.0 | 2026-02-26 | Stable | CI/CD fixes, accurate .NET 8.0-only documentation, stable API |
| 1.0.0-beta | 2025-02-10 | Beta | Initial release with Custody Permits and Releasability APIs (superseded by 1.0) |
Semantic Versioning
This project follows Semantic Versioning:
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality additions
- PATCH version for backwards-compatible bug fixes
- Beta suffix indicates pre-release versions under active development
Support
For questions, issues, or feature requests:
- Check the documentation
- Search existing issues
- Join #vehiclecustody-chainproofers on Slack
- Create a new issue
Stay Updated
Subscribe to the GitHub repository to receive notifications about new releases and updates.