ADR 0016: Split the audiences, name the transitions, keep one identity read
Context
TheOperations 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 fromGET /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 aPATCH 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.