Multi-Driver Authorization
End-to-end integration guide for ADR-042 multi-driver authorizations using the Python SDK.
What this guide covers
This guide assumes you're already familiar with the single-driver
authorization flow.
Multi-driver mode is opt-in and introduces three operations beyond the
single-driver shape: an extended create() accepting an authorized_drivers
list, update_drivers() to add or remove drivers, and assign_to_driver() to
lock the authorization to one driver.
When to use multi-driver mode
Multi-driver mode is appropriate when you don't know which specific person will take custody at the time you create the authorization but you do know the eligible pool. Typical scenarios:
- Carrier with a roster: a transport company has 5 drivers any of whom could end up assigned to the route — you create the authorization with all 5 on the list, the pool service issues credentials to each, and the first driver to accept (or the dispatcher's explicit assignment) locks in the chosen driver while the others' credentials are revoked.
- Marketplace dispatch: a job is posted to a pool of drivers; the driver who accepts becomes the assigned driver and the rest are released.
- Pre-staging credentials: you want credentials issued to multiple drivers ahead of time so any of them can pick up the vehicle, with the first acceptor locking it in.
Multi-driver mode is not appropriate when:
- You already know the specific driver — use the standard single-driver
create()shape withperson_identity_key. - Your roster is large (hundreds) — the per-client cap is 25 drivers per authorization (configurable down, not up). For larger pools, treat the authorization as a small selection from a separate roster system.
Opt-in mechanics
Multi-driver mode is per-client, controlled by a vecu-config
field your VECU administrator manages on your behalf:
driver_authorization_mode:single_driver(default) ormulti_driverdriver_list_max: per-client driver-list cap (default 25, configurable down by request)
To opt in, contact your VECU administrator. They will:
- Coordinate a documented risk acknowledgment (per ADR-042 AC-5 — the multi-driver mode preserves an attack surface that single-driver mode forecloses; opt-in carries an explicit acknowledgment).
- Flip your client's
driver_authorization_modetomulti_drivervia the vecu-config admin write surface. - Optionally lower your
driver_list_maxif your roster is smaller than 25.
Once your client is configured for multi_driver, the SDK methods
documented below begin returning success instead of
MultiDriverModeRequiredError. You do not need to redeploy or restart
your application — mode changes take effect on the next API call.
Mode is stamped at creation
The driver_authorization_mode is stamped on each authorization at creation
time. A multi_driver authorization keeps that mode for its entire lifecycle,
even if your client is later switched back to single_driver. Conversely, a
single_driver authorization can never receive a PATCH /drivers — even after
your client opts into multi-driver mode, existing single-driver authorizations
stay single-driver.
Idiomatic flows
Flow A — Known roster up front
When you know all eligible drivers at authorization-creation time, pass
them all on create():
from vecu_custody import CustodyClient
from vecu_custody.models import AuthorizedDriver
client = CustodyClient.sandbox(token="your-jwt-token")
auth = client.authorizations.create(
vin="9HGBH41JXMN999999",
origin="LOC-AUCTION-MANHEIM-ATLANTA",
destination="LOC-DEALERSHIP-CARMAX-ORLANDO",
authorized_drivers=[
AuthorizedDriver(person_identity_key="vecu_alice...", name="Alice"),
AuthorizedDriver(person_identity_key="vecu_bob...", name="Bob"),
AuthorizedDriver(person_identity_key="vecu_carol...", name="Carol"),
],
authorized_by="dispatch-system",
make_model="Honda Accord 2023",
actor_identifier="ops-user-42@client.example.com",
)
# auth.version == 1 — save this for later modifications
When the assigned driver is determined (e.g., one accepts the job), call
assign_to_driver():
response = client.authorizations.assign_to_driver(
authorization_id=auth.authorization_id,
version=auth.version, # ETag from the create response
person_identity_key="vecu_alice...", # Alice accepted the job
actor_identifier="dispatcher-7@client.example.com",
)
# response.status == "ASSIGNED"
# response.revoked_credential_ids == [bob's credential, carol's credential]
Flow B — Per-driver call shape (POST→409→PATCH)
When your existing integration calls the API once per driver as drivers
are determined incrementally, use add_or_create_driver(). This SDK
helper hides the POST→409→GET→PATCH dance:
# First driver — creates the authorization
result1 = client.authorizations.add_or_create_driver(
vin="9HGBH41JXMN999999",
origin="LOC-AUCTION-MANHEIM-ATLANTA",
destination="LOC-DEALERSHIP-CARMAX-ORLANDO",
driver=AuthorizedDriver(person_identity_key="vecu_alice...", name="Alice"),
authorized_by="dispatch-system",
make_model="Honda Accord 2023",
)
assert result1.created is True
auth_id = result1.authorization.authorization_id
# Second driver — POST returns 409, SDK falls back to PATCH automatically
result2 = client.authorizations.add_or_create_driver(
vin="9HGBH41JXMN999999",
origin="LOC-AUCTION-MANHEIM-ATLANTA",
destination="LOC-DEALERSHIP-CARMAX-ORLANDO",
driver=AuthorizedDriver(person_identity_key="vecu_bob...", name="Bob"),
authorized_by="dispatch-system",
make_model="Honda Accord 2023",
)
assert result2.created is False
assert result2.added is True
assert result2.authorization.authorization_id == auth_id
# Third driver — same as second
client.authorizations.add_or_create_driver(...) # Carol
The convenience method bounds ETag-mismatch retries (default 3
attempts); persistent contention raises RetryExhaustedError.
Flow C — Modify the roster mid-flight
If a driver becomes unavailable before the authorization is assigned, remove them and add a replacement in a single round-trip:
auth = client.authorizations.get(auth_id) # Fetch current version
updated = client.authorizations.update_drivers(
authorization_id=auth_id,
version=auth.version,
add=[AuthorizedDriver(person_identity_key="vecu_dave...", name="Dave")],
remove=["vecu_bob..."], # Bob is unavailable
actor_identifier="ops-user-42@client.example.com",
)
# updated.version == auth.version + 1
Flow D — Same-driver re-assign (idempotency)
Re-assigning the same driver to an already-ASSIGNED authorization is a 200 OK no-op (with a fresh ETag). Useful when retrying after a network partition or surfacing the assignment in a UI:
# Safe even if the authorization is already ASSIGNED to Alice
response = client.authorizations.assign_to_driver(
authorization_id=auth_id,
version=current_version,
person_identity_key="vecu_alice...", # Same driver as last time
)
# response.status == "ASSIGNED" (unchanged)
# response.version was bumped (every state-affecting call increments)
Flow E — POST → 503 POOL_FANOUT_FAILURE → retry
Multi-driver POST triggers an N-sequential fan-out into the
authorization-pool service. If any pool entry fails partway, custody-
service rolls back the already-succeeded entries and raises 503
POOL_FANOUT_FAILURE. The (VIN, geohash) marker is cleared as part
of the rollback, so the entire POST is safe to retry idempotently:
from vecu_custody import CustodyClient
from vecu_custody.exceptions import PoolFanOutError
client = CustodyClient.sandbox(token="your-jwt-token")
def create_with_fanout_retry(create_kwargs: dict, max_attempts: int = 3):
for attempt in range(1, max_attempts + 1):
try:
return client.authorizations.create(**create_kwargs)
except PoolFanOutError as e:
# Custody-service already rolled back any partially-added
# pool entries — the (VIN, geohash) marker is cleared, so a
# fresh POST is safe.
if e.rollback_failures:
# Some rollback DELETEs themselves failed — orphaned
# entries left at auth-pool need ops attention. Surface
# to your alerting pipeline; do NOT retry blindly.
raise OrphanedPoolEntriesError(e.rollback_failures) from e
if attempt == max_attempts:
raise # Persistent fan-out failure — escalate to ops
time.sleep(2 ** attempt) # Exponential backoff
raise RuntimeError("unreachable")
Flow F — assign_to_driver() → 503 ASSIGN_PARTIAL_FAILURE → retry-with-bumped-version
assign_to_driver() is sync-blocking on credential-service revocation
(per ADR-042 AC-12). If revocation completes only partially, the call
returns 503 ASSIGN_PARTIAL_FAILURE — the authorization IS assigned
(version bumped), but some non-assigned drivers' credentials are still
unrevoked. Retry with the bumped version; revocation is idempotent on
the credential-service side, so already-revoked credentials are a no-op:
from vecu_custody.exceptions import AssignPartialFailureError
def assign_with_revocation_retry(
auth_id: str,
version: int,
pik: str,
max_attempts: int = 3,
):
current_version = version
for attempt in range(1, max_attempts + 1):
try:
return client.authorizations.assign_to_driver(
authorization_id=auth_id,
version=current_version,
person_identity_key=pik,
)
except AssignPartialFailureError as e:
# Authorization IS assigned — version bumped on the server.
# Failed revocations stay surfaced; retry with the new
# version pulled from the error envelope.
current_version = e.current_version
if attempt == max_attempts:
# Persistent partial failure — surface to ops with the
# specific failed credential IDs for manual revocation.
raise CredentialRevocationStuckError(
failed_revocations=e.failed_revocations,
auth_id=auth_id,
) from e
time.sleep(2 ** attempt)
raise RuntimeError("unreachable")
Event consumption
Multi-driver authorizations emit two events beyond the standard
custody.authorization.created and custody.authorization.cancelled:
custody.authorization.modified— fires after everyupdate_drivers()call. Carries the post-PATCH driver list (mappedpersonIdentityKeyvalues only, never display-onlyname), the new version, andactorIdentifierfor forensic correlation.custody.authorization.assigned— fires after everyassign_to_driver()call. CarriesassignedDriverPik,revokedCredentialIds(the credentials revoked at the credential service as part of the assign), andcancelledPoolCompositeIds(the auth-pool entries cancelled).
Sync-blocking semantics
By the time custody.authorization.assigned is published, the credentials in
revokedCredentialIds have already been revoked at the credential service
(per ADR-042 AC-12 sync-blocking contract). Consumers can rely on this — but
the verifier-side credential-status check at presentation time remains
load-bearing per ADR-042 §Risk Acknowledgment. The event tells you what
happened on the operational side; it is not a substitute for the live
presentation-time check. On ASSIGN_PARTIAL_FAILURE (503), no .assigned
event is published — the partial state is surfaced via the 503 envelope
only.
Field naming on the wire
Event payloads use camelCase field names on the wire (assignedDriverPik,
revokedCredentialIds, cancelledPoolCompositeIds, actorIdentifier) —
match the canonical event catalog. SDK method arguments
use Python snake_case; only event-payload literals follow the wire
convention.
A typical wallet-side consumer:
def handle_authorization_assigned(event_data: dict) -> None:
"""Handle custody.authorization.assigned event."""
authorization_id = event_data["authorizationId"]
revoked_credential_ids = event_data["revokedCredentialIds"]
# Mark non-assigned drivers' wallets so they don't show a stale
# credential — even though credential-service has already revoked
# them server-side, the wallet display lags until we sweep.
for credential_id in revoked_credential_ids:
wallet_db.mark_credential_revoked(
credential_id=credential_id,
reason="non_assigned_after_pool_acceptance",
)
Operational considerations
update_drivers(remove=...) retry semantics
Pool-cancel side-effects on driver removal are best-effort.
Retrying update_drivers() with the same remove list is a server-side
no-op (idempotent — already-removed drivers are not re-removed) — but the
SDK does NOT re-attempt the failed pool-cancel on retry. If a
pool-cancel fails for a driver whose credential was already issued,
surface the failure to ops via the correlation ID; the operational
state (driver removed from authorization, pool entry potentially
orphaned) is the same on first attempt and retry.
Pool fan-out lifecycle
Multi-driver POST triggers an N-sequential fan-out into the
authorization-pool service — one pool entry per driver in
authorized_drivers. The custody-service guarantees all-or-nothing
semantics:
- All N pool entries succeed → 201 Created with the full driver list.
- Any pool entry fails → custody-service rolls back the already-succeeded
entries and raises
PoolFanOutError(503POOL_FANOUT_FAILURE).
Retry the entire POST after a PoolFanOutError. The
(VIN, geohash) marker is cleared as part of the rollback, so a
subsequent identical POST creates a fresh authorization with fresh pool
entries.
If error.rollback_failures is non-empty, some pool entries are left
orphaned at the auth-pool service (the rollback DELETE call itself
failed). These need ops attention — surface the composite IDs to your
on-call alerting pipeline. Custody-service logs the failure with
correlation ID for cross-service tracing.
Credential revocation on assign
When assign_to_driver() is called:
- Pre-acceptance phase (no driver has accepted yet): non-assigned
pool entries are cancelled. No credentials issued yet, so the response
revoked_credential_idsand the event'srevokedCredentialIdsare both empty. - Mid-acceptance phase (some drivers' credentials issued, but not yet presented at a gate): non-assigned PENDING pool entries are cancelled AND non-assigned drivers' issued credentials are revoked at the credential service.
- Post-acceptance phase (a non-assigned driver already accepted): pool entries are already terminal. All non-assigned drivers' credentials are revoked.
By the time the call returns 200, the credential-service revocations
have all completed. If revocation completes only partially, the call
returns 503 ASSIGN_PARTIAL_FAILURE — the authorization IS assigned,
but some revocations failed. Retry with the bumped version (revocation
is idempotent on the credential-service side).
Audit trail
Three fields shape the audit trail:
actor_identifier(request) /actorIdentifier(event payload) — caller-asserted forensic identifier. Stored on the authorization, emitted on every event. Use this to correlate events back to specific internal users in your system. Not verified by VECU.version— monotonic counter incremented on every state-affecting call. Use to detect missed events or out-of-order delivery.custody.authorization.assignedevent payload'srevokedCredentialIdsandcancelledPoolCompositeIds— the authoritative record of what happened during the assign for downstream reconciliation.
Risk acknowledgment
Multi-driver mode preserves an attack surface that single-driver mode
forecloses (per ADR-042 §Risk Acknowledgment). Specifically: a client
with compromised credentials can mint a multi-driver authorization with
attacker-controlled drivers and use assign_to_driver() to lock in one
of them. Single-driver mode closes this surface; multi-driver mode is
opt-in for exactly this reason.
The mode is appropriate for organizations with strong internal controls:
per-user authorization scoping, MFA on modification operations, and
monitoring of the VECU event stream for anomalies. The
actor_identifier field exists to give multi-user clients a forensic
correlation hook into their own audit logs — it is not a security
primitive, since VECU does not verify it.
For a full discussion of the trust model (Tier 1 / Tier 2 boundary, the verifier-side credential-status assumption, and the offline-gate residual), see ADR-042 §Risk Acknowledgment.
Next Steps
- Custody Permits API Reference - Full method signatures and exception types
- Custody Events - Event payloads for
custody.authorization.modifiedandcustody.authorization.assigned - Migration Guide - For clients moving from the deprecated exception-mode design