Error taxonomy
Every error response has the same shape (see common/errors.py):
{
"errors": [
{ "code": "auth.invalid_credentials", "description": "Invalid credentials." }
]
}
Codes are namespaced by domain (auth., account., ...) and this list grows
as new missions add their own — this is the first real taxonomy, seeded by
the Accounts & Authentication mission.
Generic (pre-existing scaffold)
| Code |
HTTP status |
Meaning |
http_{status_code} |
matches status |
Framework-native HTTPException with no domain-specific code yet. |
validation_error |
422 |
Request body/query failed Pydantic validation. |
internal_server_error |
500 |
Unhandled exception — logged server-side, no internal detail leaked. |
Auth / Accounts (this mission)
| Code |
HTTP status |
Meaning |
auth.invalid_credentials |
401 |
Wrong email/password, revoked/expired API key, or malformed/invalid bearer token. Deliberately generic — see enumeration note below. |
auth.account_not_active |
403 |
Account exists and credentials are correct, but its status isn't active (e.g. still pending_activation). |
auth.token_expired |
400 |
Activation or password-reset token is invalid, already used, or past its TTL. |
auth.rate_limited |
429 |
Sensitive endpoint (register/login/password-reset-request/oauth-token) hit its Redis-backed rate limit. Response includes a Retry-After header. |
auth.locked |
423 |
Account temporarily locked after repeated failed login attempts (progressive backoff). |
auth.insufficient_scope |
403 |
Bearer token decoded fine but lacks the scope the endpoint requires (e.g. an account:manage token used where accounts:read is needed, or vice versa). |
auth.unsupported_grant_type |
400 |
POST /v1/oauth/token called with anything other than grant_type=client_credentials. |
account.invalid_external_identifier |
409 |
The external_identifier supplied at registration is already used by another account. Safe to be specific here — it's the caller's own namespace, not an email-enumeration vector. |
account.weak_password |
400 |
Password fails the strength policy (minimum length and/or common-password blocklist — see core/common_passwords.py). |
account.not_found |
404 |
An account-scoped resource (API key, OAuth client) doesn't exist or doesn't belong to the authenticated account. |
account.email_already_exists |
— |
Reserved, never raised today. Exists in the taxonomy for a future context where confirming an email's existence carries no enumeration risk. POST /v1/accounts/register always returns a generic 202 regardless of whether the email is new, to avoid letting a caller enumerate registered accounts. |
account.subaccount_credentials_incomplete |
400 |
A subaccount's email/password were provided one without the other, on create or update (prompt 4). They must be given together or both left unset. |
Freight Documents (prompt 3)
| Code |
HTTP status |
Meaning |
freight_document.invalid_transition |
409 |
POST /v1/freightdocuments/{id}/issue (or any other call into FreightDocumentStateMachine) requested a transition not in the explicit allow-list — e.g. issuing a document that's already ISSUED. |
freight_document.external_identifier_already_exists |
409 |
POST /v1/freightdocuments supplied an external_identifier already used by another document — enforced by a DB unique constraint, not just an application check. |
freight_document.not_found |
404 |
Superseded for read/issue/role endpoints as of prompt 4 — those now return freight_document.permission_denied (403) uniformly, including for a document that doesn't exist at all, per the anti-enumeration principle below. Kept in the taxonomy as a defensive/internal-invariant code, not expected to be reachable through normal API use today. |
freight_document.invalid_role_assignment |
400 |
A role in the creation (or post-creation addition) payload has neither account_id nor email, has an empty/missing name in party_snapshot, or explicitly sets role_type: SUBMITTER (that role is derived automatically from the authenticated caller). |
Roles & Permissions (prompt 4)
| Code |
HTTP status |
Meaning |
freight_document.permission_denied |
403 |
The authenticated principal (account or subaccount) has no role on this freight document granting the permission the endpoint requires — including when the document doesn't exist at all. Deliberately never 404; see the anti-enumeration note below. |
freight_document.role_already_exists |
409 |
POST /v1/freightdocuments/{id}/roles tried to add a role type that's a singleton on this document (every type except SUBSEQUENTCARRIER) and one already exists. |
freight_document.invalid_role_addition |
400 |
POST /v1/freightdocuments/{id}/roles was called on a document not in ISSUED/TRANSIT status. |
Freight Document CRUD (prompt 5)
| Code |
HTTP status |
Meaning |
freight_document.status_immutable_via_put |
400 |
PUT /v1/freightdocuments/{id} body included a status different from the document's current one. Transitions go exclusively through the dedicated endpoints (issue, ...). |
freight_document.immutable_field |
400 |
PUT body included a value for hosting_type, external_host_platform, external_document_id, external_sync_status, submitter_account_id, or external_identifier that differs from the document's current one. Omitting these fields, or echoing back the current value, is fine. |
freight_document.optimistic_lock_conflict |
409 |
PUT/PATCH's version field didn't match the document's current version — either the application-level check caught a stale read, or the DB-level compare-and-swap (version_id_col) caught a race between the check and the write. Refetch and retry. |
freight_document.version_required |
400 |
PUT/PATCH was called without a version field. Deliberately a dedicated code, not a generic validation_error — version is intentionally optional at the Pydantic level so this specific, common mistake gets a clear message. |
freight_document.invalid_filter |
400 |
GET /v1/freightdocuments's filters query param was malformed, named an unknown field, or used an operator not supported for that field. |
freight_document.invalid_sort_field |
400 |
GET /v1/freightdocuments's sort query param named a field outside the whitelist (created_at, updated_at, status). |
Delegation & External Access (prompt 6)
| Code |
HTTP status |
Meaning |
freight_document.delegation_depth_exceeded |
400 |
Creating a delegation would produce a depth greater than settings.max_delegation_depth (default 4). |
freight_document.delegation_not_permitted |
403 |
The caller doesn't hold DELEGATE on this exact role (directly or via an active delegation), or the role_id/freight_document_id pair in the URL don't match — same generic code either way, no distinguishing signal. Also returned for delegation/access-code revocation when the caller isn't the delegation's own delegator nor anyone upstream in its chain. |
freight_document.delegation_chain_access_denied |
403 |
GET /v1/freightdocuments/{id}/roles/{role_id}/delegations called by a principal not part of that specific role's delegation chain — including the document's own SUBMITTER/CONSIGNOR if they aren't a chain member. |
freight_document.access_code_invalid |
401 |
POST /v1/access-codes/redeem was called with a code that doesn't match any issued carrierAccessCode, or matches one that's been explicitly revoked. |
freight_document.access_code_expired |
401 |
The code matches an issued, non-revoked carrierAccessCode, but expires_at is in the past. |
Directory (prompt 7)
| Code |
HTTP status |
Meaning |
partner.not_found |
404 |
The partner_id in a URL path, or on a Freight Document role, doesn't exist, belongs to another account, or is soft-deleted. Deliberately identical for all three cases — never a distinguishing signal about another account's directory. |
partner.external_identifier_already_exists |
409 |
The account already has a Partner with this external_identifier — scoped to (owner_account_id, external_identifier), not global (contrast with account.invalid_external_identifier, which IS global). |
partner.invalid_bulk_import |
400 |
POST /v1/accounts/me/partners/bulk's array itself is malformed — currently only raised when it exceeds the per-request entry cap. A single entry's own failure (e.g. a duplicate external_identifier) is reported per-element in the response instead, not via this code. |
packaging_method.not_found |
404 |
The packaging method doesn't exist, or belongs to another account. |
packaging_method.external_identifier_already_exists |
409 |
Same scoping as partner.external_identifier_already_exists — not in the mission brief's literal error list, added by extension since the same DB constraint is mandated on this table too. |
vehicle.not_found |
404 |
The vehicle doesn't exist, belongs to another account, or is soft-deleted. |
vehicle.external_identifier_already_exists |
409 |
Same scoping and rationale as packaging_method.external_identifier_already_exists. |
vehicle.invalid_subaccount_link |
400 |
linked_subaccount_id doesn't reference a VEHICLE-type subaccount belonging to the same account — same generic code whether the type is wrong or the subaccount belongs to someone else. |
driver.not_found |
404 |
The driver doesn't exist, belongs to another account, or is soft-deleted. |
driver.external_identifier_already_exists |
409 |
Same scoping and rationale as packaging_method.external_identifier_already_exists. |
driver.invalid_subaccount_link |
400 |
linked_subaccount_id doesn't reference a DRIVER-type subaccount belonging to the same account. |
Anti-enumeration note
Per the mission brief, no endpoint may reveal whether a given email is
registered, nor whether a given freight document exists to a caller who
isn't authorized to see it:
POST /v1/accounts/register always returns the same 202 response, whether
the email is new or already registered (the existing-email case is a silent
no-op — no second account, no second email).
POST /v1/accounts/login returns auth.invalid_credentials for both "no
such account" and "wrong password" — same status code, same message. A
dummy password hash is verified even when no account is found, so the
response time doesn't leak which case it was.
POST /v1/accounts/password/reset-request always returns the same generic
200 message.
POST /v1/accounts/subaccounts/login (prompt 4) applies the same pattern:
an unknown email, a subaccount with no password set (a traceability-only
record), and a wrong password all return the identical
auth.invalid_credentials.
GET /v1/freightdocuments/{id}, GET /v1/freightdocuments/ext/{external_identifier},
POST /v1/freightdocuments/{id}/issue, and POST /v1/freightdocuments/{id}/roles
(prompt 4) all return freight_document.permission_denied (403), never
404, whether the document/identifier doesn't exist or exists but the
caller has no role on it.
- The delegation/access-code endpoints (prompt 6) apply the same principle
to the
{id}/{role_id} pair in their URLs: a role_id that doesn't
exist, one that belongs to a different document than {id}, and one
that exists on the right document but the caller has no DELEGATE
access to, all return the identical freight_document.delegation_not_permitted
(or delegation_chain_access_denied for the chain-read endpoint).
- The directory (prompt 7) applies the same principle to every
GET/PATCH/DELETE on a Partner/Vehicle/Driver/Packaging Method: an ID
that doesn't exist and one that exists but belongs to a different account
both return the identical *.not_found — never a signal that a resource
exists in someone else's private directory. partner_id on a Freight
Document role follows the same rule (partner.not_found either way).