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 optional string? 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_id is applied server-side per ADR-043 §Amendment 2026-04-28; callers supply the raw personIdentityKey). Omitted from the wire entirely when null (via the SDK's global JsonIgnoreCondition.WhenWritingNull policy), so legacy single-driver releases continue to work without modification. Required by upstream vecu-release-bff#51 to forward the verifier's driver identity through to custody-service.
  • CustodyClientOptions.TokenEndpointOverride — new optional string? configuration field. Mirrors the existing BaseUrlOverride precedent (null-or-whitespace fallback, absolute-URL validation with http/https scheme 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 in CustodyServiceConstants.TokenEndpointUrls. Production callers should leave this null. The new CustodyClientOptions.GetTokenEndpoint() helper resolves the effective URL via the same null-or-whitespace pattern as GetBaseUrl().

🔁 Changed

  • Release-state-machine assertions aligned with vecu-custody-service PR #484. Existing release tests (Test_ReleaseVehicle_Success, Test_ReleaseVehicle_WithLocation_Success, Lifecycle_CreateGetRelease_StatusTransitionsAndAuditTrail, Get_AfterRelease_DeserializesReleasedStateCorrectly) now assert Status == COMPLETED post-release (was RELEASED). Co-shipped with the v3.3.1 COMPLETED enum value and the service-side state-machine refactor. No public-API impact — assertion-level alignment only.

🐛 Fixed

  • Issue #467 regression coverage — four new ReleaseVehicleHolderIdTests cover (a) single-driver release with explicit HolderId, (b) single-driver release with null HolderId (legacy compat), (c) multi-driver ADR-042 release with HolderId disambiguating between drivers, (d) WaitForAuthorizationAsync returns immediately on Status == COMPLETED (guards the v3.3.1 terminal-status whitelist fix). See vecu-custody-service#467 for the original defect.
  • Regression coverage for the 2026-05-11 AddOrCreateDriverAsync bug. When the backend's uniqueness check used to match terminal (CANCELLED) authorizations, the convenience method returned Created=false, Added=true against the cancelled auth — silently lying about the add. Backend was fixed in vecu-custody-service. New AddOrCreate_OnCancelledAuth_CreatesNewAuthInsteadOfReusingTerminal test pins the post-fix contract: result.AuthorizationId is a NEW auth ID, result.Created == true (POST branch took), result.Version == 1, and GetAuthorizationAsync on the result returns Status == 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 VehicleReleaseRequest with AuthorizationId and ReleaseMethod compiles and runs unchanged. New HolderId defaults to null; set it explicitly when you want the multi-driver disambiguation side effect on the emitted custody.vehicle.released event payload.
  • Wire compatibility: outgoing JSON for legacy callers is byte-identical (the new HolderId field is omitted when null). Subscribers of the release event continue to receive their existing payload shape; callers that DO supply HolderId enable the new holderId + releasedPoolCompositeId fields 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 / InvalidClientException from 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 the PENDING → ACCEPTED → COMPLETED state machine (post-PR-#484). The legacy RELEASED value is retained for historical records that pre-date the deployment.

🐛 Fixed

  • WaitForAuthorizationAsync correctly recognizes COMPLETED as terminal. AuthorizationOperations.IsTerminalStatus and MockCustodyServiceClient.IsTerminalStatus both add COMPLETED to 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 switch statements over AuthorizationStatus need a new arm for COMPLETED. Callers that fall through to a generic default branch are unaffected.
  • Wire compatibility: the SDK's EnumMemberJsonConverter handles unknown enum values gracefully on deserialize, so older SDK versions receiving a COMPLETED response 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.x NotImplementedException skeleton). Issues PUT /v1/authorizations/{id}/assign with If-Match: "<n>" (RFC 7232). Signature: (authorizationId, personIdentityKey, ifMatchVersion, actorIdentifier?, ct) — no request object; SDK builds the AssignAuthorizationRequest internally. Returns HttpResponseEnvelope<AssignAuthorizationResponse> (per ADR-042 §AC-12..AC-14).
  • UpdateAuthorizationDriversAsyncPATCH /v1/authorizations/{id}/drivers with ETag-guarded optimistic concurrency. Signature: (authorizationId, PatchDriversRequest, ifMatchVersion, ct). ifMatchVersion is a separate parameter, NOT embedded in the request body. Returns HttpResponseEnvelope<PatchDriversResponse>.
  • AddOrCreateDriverAsync — convenience helper. Two modes: PATCH-only (when createRequestIfMissing is null — the v3.2.x default), and try-POST-then-PATCH (when supplied — v3.3.0 addition, per AC-20). PATCH attempts use bounded StaleETagException retry (3 attempts, re-listing between).
  • ListAuthorizationsAsync(string vin, ct) — listing primitive for multi-driver integration; auto-scoped to the caller's clientId server-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-empty AuthorizedDrivers list together with DriverAuthorizationMode = MultiDriver, or supply PersonIdentityKey alone. CreateAuthorizationRequest now implements IValidatableObject to surface XOR / mode-mismatch violations as ValidationResult entries before the wire.
  • AssignAuthorizationRequest — internal request body type (sealed record) for PUT /assign. Built by the SDK from the AssignAuthorizationToDriverAsync parameters.
  • AuthorizationStatus.ACCEPTED and AuthorizationStatus.ASSIGNED — two new enum values. Neither is terminalASSIGNED indicates a multi-driver authorization where one specific driver has been bound; the existing transitions to RELEASED|EXPIRED|REVOKED|CANCELLED still apply. Audit exhaustive switch statements.
  • Seven new typed exceptions for multi-driver paths: MultiDriverModeRequiredException (400, inherits ValidationException), EmptyDriverListException (400, inherits ValidationException), NotAuthorizationOwnerException (403, inherits ForbiddenException), StaleETagException (412, inherits CustodyExceptiontransient), AuthorizationAlreadyAssignedException (409, inherits ConflictException), DriverLimitExceededException (429, inherits CustodyException — NOT a RateLimitException; operational cap, not a transient throttle), CustodySdkInvariantException (n/a, inherits CustodyException — SDK-side invariant violation; surface to operators).

🔁 Changed

  • CreateAuthorizationRequest.PersonIdentityKey is now optional (was required in v3.2.x). XOR with the new AuthorizedDrivers list: set this for single-driver mode (default), or leave null and supply AuthorizedDrivers for multi-driver. The XOR rule is enforced client-side via IValidatableObject.Validate.
  • UniquenessScope.VinOriginSameCompanyAllowed is retired. The marker is unconditionally strict; two single-driver POSTs for the same (VIN, origin) collide on the second with DUPLICATE_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 LogDebug statement in TokenProviderAuthenticationHandler.ExchangeTokenAsync that 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 surrounding LogInformation (which only records expires_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 the VinOriginSameCompanyAllowed uniqueness scope, permitting concurrent authorizations for the same VIN and origin within the same organization. When omitted, the service falls back to the legacy VinOriginStrict scope (one authorization per VIN + canonical origin globally).
  • AuthorizationResponse.UniquenessScope — new response field. Enum values: VinOriginStrict (no OrganizationId was supplied) or VinOriginSameCompanyAllowed (the request was scoped to an organization). Echoed on both 201 Created and 409 Conflict responses so callers can verify which scope was effective for the request.
  • AuthorizationResponse.OrganizationId — echoed organization id when the scope is VinOriginSameCompanyAllowed; null under VinOriginStrict.
  • 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 as OriginGeohash.
  • AuthorizationResponse.LocationUri — the Location response header is now parsed and exposed on the response model. Always present on 201 Created; on 409 Conflict, present only under the same disclosure guard + enrichment-success conditions that gate ExistingAuthorization (see the Behavioral Changes section below and the api-reference page for full details). Points at GET /v1/authorizations/{id} for the authoritative authorization (newly created or pre-existing duplicate).
  • PoolCapacityExceededException — new typed exception for HTTP 429 responses with ErrorCode=POOL_CAPACITY_EXCEEDED. Thrown when the authorization pool has reached the per-VIN concurrent-authorization cap under the effective uniqueness scope. Inherits from RateLimitException. Properties: EffectiveScope (UniquenessScope), OrganizationIdPresent (bool), CurrentCount (int), MaxAllowed (int), CorrelationId (string).
  • GeocodingServiceUnavailableException — new typed exception for HTTP 503 responses with ErrorCode=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 the Retry-After header — typical range 30–300 s), CorrelationId (string), Retryable (bool, always true). Inherits from NetworkException.
  • InvalidOriginAddressException — new typed exception for HTTP 400 responses with ErrorCode=INVALID_ORIGIN_ADDRESS. Thrown when the geocoder declines the submitted Origin. Property: Reason (OriginRejectionReason enum: LowRelevance | NoMatch | Interpolated | InvalidCategory; PascalCase mapping of the SCREAMING_SNAKE wire-format values). Inherits from ValidationException. Requires custody service ≥ ADR-041 (non-prod and beyond) — earlier service versions raise a generic ValidationException for the same geocoder-rejection conditions.

🔁 Changed

  • DuplicateAuthorizationException.EffectiveScope (new property)UniquenessScope enum carried alongside the existing 409 envelope so consumers can branch on whether they hit the strict or same-company scope.
  • DuplicateAuthorizationException.OrganizationIdPresent (new property)bool flag indicating whether the original request was scoped to an organization. Drives the safe-access pattern for the now-nullable ExistingAuthorization subobject (see Breaking Changes).
  • DuplicateAuthorizationException.LocationUri (new property)Uri? parsed from the response Location header on the 409 envelope. Tracks the same nullability semantics as ExistingAuthorization: populated when the disclosure guard passes AND service-side enrichment succeeded; null otherwise. Convenience pre-parsed pointer at GET /v1/authorizations/{id} for the existing authorization.
  • Retry policy honors Retry-After on 503 — the Polly resilience pipeline now reads the Retry-After response 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.RetryAfterSeconds exposes 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: 9999999 or 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 Created because the raw Origin string differed by case or punctuation ("123 main st" vs. "123 Main St." vs. "123 MAIN ST") now return 409 Conflict against 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.
  • Location header is conditional on 409 — the custody service emits the Location header on every 201 Created. On 409 Conflict, the header is present only when the ADR-040 disclosure guard passes (same client OR matching non-null OrganizationId) AND service-side enrichment succeeded — the same conditions that gate ExistingAuthorization on the 409 envelope. The SDK surfaces the header through AuthorizationResponse.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 OrganizationId field 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.
  • PoolCapacityExceededException inherits from RateLimitException so that existing catch (RateLimitException) handlers continue to work, while new callers can branch on the typed subclass to inspect EffectiveScope / CurrentCount / MaxAllowed.
  • GeocodingServiceUnavailableException inherits from NetworkException (5xx family) so existing retry-on-network-error patterns keep working. The Retryable property is always true for this type — distinct from InvalidOriginAddressException (400, never retryable, the address itself is the problem).
  • InvalidOriginAddressException inherits from ValidationException so it flows through the same field-error handling consumers use today. The Reason enum 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

  1. Make duplicate handlers null-safe (required if you catch DuplicateAuthorizationException). Gate on ex.ExistingAuthorization is not null before 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).
  2. 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.
  3. (Optional) Adopt OrganizationId on CreateAuthorizationAsync to unlock the same-company concurrent-authorization scope. No effect on single-org callers.
  4. (Optional) Catch the three new typed exceptions for richer telemetry and UX:
    • catch (PoolCapacityExceededException) before catch (RateLimitException).
    • catch (GeocodingServiceUnavailableException) before catch (NetworkException) — and surface the RetryAfterSeconds hint to your operator dashboards.
    • catch (InvalidOriginAddressException) before catch (ValidationException) — and branch on Reason for a better end-user error message.

[3.1.0] - 2026-04-23

✨ Added

  • Six new nullable companion properties on DuplicateAuthorizationException carrying the existing authorization's record fields when the server returns an enriched DUPLICATE_AUTHORIZATION 409 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-up GetAuthorizationAsync.
  • 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.ExistingAuthorizationId is now populated from the server's 409 response body. Previously the response handler constructed the exception via the single-arg DuplicateAuthorizationException(string message) constructor, which hardcoded ExistingAuthorizationId = string.Empty. Callers who timed out on CreateAuthorizationAsync, retried, and caught the 409 could not recover the orphaned authorization. The server has always emitted existingAuthorizationId on 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 now catch (ConflictException) to handle any conflict generically.
  • PoolConflictException — new typed exception for 409 POOL_CONFLICT responses from POST /v1/authorizations. Exposes ExistingClientId property.

🔁 Changed

  • DuplicateAuthorizationException, InvalidStateException, and VehicleNotReleasableException now inherit from ConflictException instead of CustodyException directly. Existing catch chains on each specific type remain source- and binary-compatible; existing catch (CustodyException) continues to catch them.

🐛 Fixed

  • 409 responses with "error":"POOL_CONFLICT" from POST /v1/authorizations were previously misclassified as NetworkException (StatusCode=ServiceUnavailable / ErrorCode=NETWORK_ERROR). They now correctly throw PoolConflictException (StatusCode=Conflict / ErrorCode=POOL_CONFLICT). Retry logic that distinguishes transient from non-transient failures via StatusCode or ErrorCode is 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.GetCustodyStatusAsync removed. The underlying GET /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.GetCustodyChainAsync removed. Same reason as above; underlying route GET /v1/transfers/history/{vin} offline.
  • BREAKING: ICustodyServiceClient.RecordTransferAsync removed. The underlying POST /v1/transfers 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: DTOs CustodyTransferRequest, CustodyTransferResponse, TransferHistoryResponse, TransferHistoryItem, CustodyStatus and the CustodyServiceConstants.Endpoints.Transfers path 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. StatusCode is HttpStatusCode.Forbidden. Consumers can now catch (ForbiddenException) to handle any forbidden response generically instead of the previous catch (NetworkException) misclassification.
  • RouteNotAvailableException : ForbiddenException — new typed exception for gateway-level 403 responses where the route is not in the API Gateway spec. ErrorCode is "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 NetworkException with StatusCode=ServiceUnavailable / ErrorCode=NETWORK_ERROR — causing retry-logic callers to spin-retry a non-transient permission failure. In 3.0.0, 403 responses now throw ForbiddenException (or RouteNotAvailableException for gateway-default bodies) with StatusCode=Forbidden and a descriptive ErrorCode. Migration: add catch (ForbiddenException) (or the existing catch (CustodyException)) before the NetworkException catch.

📐 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 + RouteNotAvailableException mirrors the ConflictException + PoolConflictException hierarchy introduced in 2.1.0 — a consistent pattern across HTTP status families.

🔄 Migration from 2.x

  1. Remove any calls to RecordTransferAsync, GetCustodyStatusAsync, or GetCustodyChainAsync — these calls have been returning HTTP 403 for all consumers since 2026-04-14 regardless, so no runtime behavior is lost.
  2. Remove DTO references. If your application defines its own types, helpers, or method signatures that reference CustodyTransferRequest, CustodyTransferResponse, TransferHistoryResponse, TransferHistoryItem, or CustodyStatus, remove or locally redefine them. These types are no longer exported by the SDK.
  3. Add catch (ForbiddenException) before any existing catch (NetworkException) to correctly handle 403 responses (for example, token scope denials that may begin flowing through structured error bodies in future server releases).
  4. 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/TRANSPORTER with OWNER/ADMIN/DRIVER to align with the authorization pool service. TRANSPORTER was being rejected by the auth-pool with a 400 validation error, silently preventing authorizations from reaching the wallet.

✨ Features

  • Docker test app: Added examples/DockerTest for end-to-end SDK validation in a containerized environment

🔧 Infrastructure

  • Security: Removed .env file with credentials from tracking, added .env.example template
  • 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: RequestedBy renamed to AuthorizedBy; Purpose (AuthorizationPurpose) replaced by Role (AuthorizationRole: DRIVER, TRANSPORTER); ValidityHours (int?) replaced by ValidUntil (DateTimeOffset?); TransportOrderId is now optional; Constraints removed; new required field MakeModel (string); new optional fields PermittedActions (List<string>), BuyerIdentityKey (string)
  • AuthorizationResponse (POST): removed PersonIdentityKey, EstimatedCredentialAt, ReleasabilityRequired, Message, RetryInfo; added ExpiresAt; response now contains only AuthorizationId, Vin, Status, Origin, Destination, CreatedAt, ExpiresAt
  • AuthorizationDetails (GET): removed TransportOrderId, CredentialId, RetryStatus; ValidUntil renamed to ExpiresAt; added Role, PoolId, AuthPoolAuthorizationId, CancelledAt, CancelledBy, CancellationReason
  • CancelAuthorizationRequest: Reason (enum) + Notes replaced by single CancellationReason (string, minimum 10 characters)
  • CancelAuthorizationResponse: removed Message; added Vin, CancellationReason

Transfers API Contract

  • CustodyTransferRequest: new required field Vin; Location object replaced by LocationId (string); Timestamp now optional; added Metadata (Dictionary<string, object>); removed VerificationMethod, Notes
  • CustodyTransferResponse: removed CredentialId, LedgerEventId; Hash renamed to EventHash; RecordedAt renamed to CreatedAt; added SessionId, Vin, TransferType; Status is now string
  • CustodyStatus: now a flat structure with Vin, SessionId?, Status?, CurrentCustodian?, CurrentLocation?, LastTransferAt?, TransferCount

Error Response

  • RequestId renamed to CorrelationId on ErrorResponse and all exception types

Enums

  • AuthorizationPurpose enum replaced by AuthorizationRole (DRIVER, TRANSPORTER)

Coordinates

  • Latitude/Longitude renamed to Lat/Lng on 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.0 and net10.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 CustodyClientOptions at startup via IValidateOptions<T>, surfacing misconfiguration errors before the first API call

📦 Dependency Upgrades

  • Microsoft.Extensions.*10.0.2
  • Polly8.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 ICustodyServiceClient interface for easy mocking

Testing Support

  • Built-in mock client - MockCustodyServiceClient for 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

🐛 Bug Reports & Feedback

This is a stable release. We welcome feedback and bug reports to help improve the SDK.

🔄 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

VersionRelease DateStatusNotes
3.3.02026-05-04StableADR-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.1TBD ship dateStableSECURITY: removed LogDebug line in TokenProviderAuthenticationHandler that emitted internal bearer access token verbatim
3.2.02026-04-25StableADR-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.02026-04-23Stable6 nullable companion properties on DuplicateAuthorizationException (ExistingStatus, ExistingVin, ExistingOrigin, ExistingDestination, ExistingCreatedAt, ExistingExpiresAt) for orphan-recovery without a follow-up GET
3.0.12026-04-23StableFIX: DuplicateAuthorizationException.ExistingAuthorizationId now populated from the 409 body (was always string.Empty)
3.0.02026-04-20StableBREAKING: removed RecordTransferAsync/GetCustodyStatusAsync/GetCustodyChainAsync; added ForbiddenException + RouteNotAvailableException for correct 403 classification
2.1.02026-04-20StableConflictException base class for all 409 typed exceptions; PoolConflictException for 409 POOL_CONFLICT; existing 409 typed exceptions re-parented under ConflictException
1.3.0-UnreleasedBreaking API contract changes: renamed fields, simplified models, new CorrelationId
1.2.32026-03-19StableFix AuthorizationRole enum, Docker test app, security fix
1.1.0-Unreleased.NET 10 multi-targeting, Transfers API, new testing builders, options validation
1.0.02026-02-26StableCI/CD fixes, accurate .NET 8.0-only documentation, stable API
1.0.0-beta2025-02-10BetaInitial 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:

  1. Check the documentation
  2. Search existing issues
  3. Join #vehiclecustody-chainproofers on Slack
  4. Create a new issue

Stay Updated

Subscribe to the GitHub repository to receive notifications about new releases and updates.