Skip to main content

ADR 0016: Split the audiences, name the transitions, keep one identity read

Context

The Operations tag held two unrelated APIs: /v1/operations/*, the customer-facing asynchronous write tracker, and /v1/ops/*, the operator console that is view-and-suspend only. Different audiences, different authorization classes, path prefixes one letter apart. /v1/activity sat under Accounts although the projection deliberately does not attribute rows to an account. A state change reached the API in four shapes — revocation as a DELETE, suspension and card lifecycle as POST sub-paths, reveal as a collection POST. And identity was three overlapping reads: /v1/me, /v1/session, and /v1/onboarding, with email published twice, the customer embedded twice, and kyc_status duplicated between onboarding and /v1/kyc. OperationView.kind and .state were bare strings shadowing two closed vocabularies — the dispatcher’s kind set and the operation state machine.

Decision

Operator tags the four /v1/ops/* routes; Operations keeps the customer tracker; Activity stands alone. One transition rule, applied consistently: a state change the customer can request is a POST to a named transition sub-path; a resource creation is a POST to a collection. Revocation stops being a DELETE — it is a transition with an audit trail and an upstream consequence, and modelling it as a deletion implies the mandate disappears. POST /v1/mandates/{mandate_id}/revoke joins the existing suspend, freeze, unfreeze, and close transitions. Reveal stays a collection POST because it genuinely creates a short-lived session object, and the connection disconnect stays a DELETE because that row genuinely ceases to exist — the discriminator is whether the resource survives the call. Polls that read a side effect into local state are POSTs to a named poll sub-path, the shape a purchase poll inherits later. The requirement is the one rule, not this specific rule. The route string keys idempotency records, so a revocation record written before the rename replays as a fresh request. Every state in which a revocation is already in flight or complete rejects the replay through the revocation gate; the one state that passes, revocation_failed, is one where the upstream revoke definitively did not happen and a fresh revocation is the correct answer — no duplicate revocation can start. GET /v1/session stays separate — it is about the browser session and carries the CSRF token, a different concern with a different lifetime. /v1/me merges into /v1/onboarding, which already embeds the customer and is the single “who am I and what can I do” read. The duplicated kyc_status leaves onboarding: GET /v1/kyc is its single source, and onboarding keeps kyc_session_available, which is a different fact. With the field gone, the onboarding read no longer calls the provider at all — its former degrade-to-unavailable behaviour is retired, and the KYC read’s own error contract is propagation. OperationView.kind and .state become closed Literals over the dispatcher’s eleven kinds and the eight operation states, on the customer and operator views alike. The vocabulary is enforced at the write side — create_operation takes the kind Literal, and the worker fails an unlisted kind closed as operation_kind_unsupported — and two tests pin the state vocabulary to the model enum and the kind vocabulary to the worker’s dispatched set.

Consequences

Operator tooling generated from the contract no longer interleaves customer routes. The web dashboard sources identity verification from GET /v1/kyc with its own query, so a KYC read failure degrades one readiness row instead of the whole onboarding read. Clients revoking mandates send a POST to the transition path. An out-of-vocabulary operation kind now fails type-checking at the write side and the view at serialization instead of passing silently; an out-of-vocabulary state fails the view.

Rejected alternatives

Modelling every transition as a PATCH of a state field: it hides the audit-relevant intent in a request body and invites partial updates of a machine the worker owns. Keeping /v1/me for API symmetry: it duplicated two fields of a read the client already needs on every page, and nothing consumed it. Degrading KYC inside onboarding after the merge: it would re-couple the identity read to provider availability, which is the coupling the split removes.