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 with person_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) or multi_driver
  • driver_list_max: per-client driver-list cap (default 25, configurable down by request)

To opt in, contact your VECU administrator. They will:

  1. 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).
  2. Flip your client's driver_authorization_mode to multi_driver via the vecu-config admin write surface.
  3. Optionally lower your driver_list_max if 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 every update_drivers() call. Carries the post-PATCH driver list (mapped personIdentityKey values only, never display-only name), the new version, and actorIdentifier for forensic correlation.
  • custody.authorization.assigned — fires after every assign_to_driver() call. Carries assignedDriverPik, revokedCredentialIds (the credentials revoked at the credential service as part of the assign), and cancelledPoolCompositeIds (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 (503 POOL_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:

  1. Pre-acceptance phase (no driver has accepted yet): non-assigned pool entries are cancelled. No credentials issued yet, so the response revoked_credential_ids and the event's revokedCredentialIds are both empty.
  2. 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.
  3. 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.assigned event payload's revokedCredentialIds and cancelledPoolCompositeIds — 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