ADR 0014: Speak one beneficiary vocabulary in the contract
Context
The beneficiary domain published two vocabularies. The neobank resource wasbeneficiary-named while its metadata routes and schemas were
recipient-named, mirroring the provider’s fiat routes. One fact appeared
under two names with inverted polarity — own_account on write,
is_external on read. The join between a create request and the schema
catalogue crossed the term boundary under two names, with the write side
aliased to schema, a word that also shadows the JSON Schema keyword.
purpose_code was accepted unvalidated and failed at the provider as a
never-retryable error after the local operation was persisted. A projection
emitted optional fields only when the provider row carried them, so a null
was ambiguous. And BankDetails.beneficiary_name used the resource’s noun
for the opposite party — the holder of the customer’s own account.
Decision
One noun.BeneficiaryCountry, BeneficiarySchemaView, and
BeneficiaryField replace the Recipient* schemas, and the metadata routes
nest under the resource as /v1/beneficiaries/countries and
/v1/beneficiaries/schemas. Both static sub-paths are declared before the
parameterized single read, and a test pins the ordering.
payout_schema_id names the join on both sides — attribute and wire — with
the schema alias gone. is_external is the only spelling of ownership;
the provider mapping lives in the adapter’s create call. The stored column
renames with the model, and a revision carries the rename.
GET /v1/beneficiaries/{beneficiary_id} exists and serves the same enriched
list read as the collection, filtered to one row. The upstream single read
returns only the identifier, provider context, and status, so serving the
route from it would reintroduce the null ambiguity this record deletes; that
call stays internal to mandate grant verification.
BeneficiaryView is a total projection: every field is always emitted and
null means the provider has communicated no value, never that a read
omitted the field. destination_masked and is_external persist on the
row — is_external from the create itself, both thereafter under the same
three-state reconciliation as every other provider-owned field — so the
guarantee holds on the single read as well as the collection, and an
omitting provider read keeps the persisted values.
The internal three-state reconciliation protocol — value sets, explicit
null clears, absent keeps — is deliberately untouched, because collapsing
absent into clear silently erases persisted fields on read.
The schema catalogue itself never carries purpose codes: the provider
publishes a rail’s accepted codes behind its withdrawal-limits read, and an
empty or unreadable answer there is advisory. purpose_codes on
BeneficiarySchemaView is therefore nullable — null means the provider
declared no codes and stays the enforcer; a non-empty list is
authoritative. Creation
requires the recipient_metadata capability, because purpose codes are
validated server-side against the chosen schema after the idempotency early
return, so a replay of an already-succeeded create never issues a provider
call. Four outcomes are distinguished: an unknown schema, a supplied code a
declared list does not accept, an absent code where a declared list
requires one, and an unreadable schema list, which fails the create closed.
BankDetails.beneficiary_name becomes account_holder_name, resolving the
noun collision this rename would otherwise deepen.
Worker shims accept the previous payload and context keys so operations
enqueued before this change drain; they are removed once no pre-rename
beneficiary operation remains in a non-terminal state.
Consequences
The contract carries norecipient-named path, operation, parameter,
schema, or property, and no property or parameter named schema; a
scanning test enforces the ban with one documented allowlist entry — the
recipient_metadata property of PerfloCapabilitiesView, which names a
provider capability rather than a contract noun. The generated inventory no
longer classifies the three provider routes the adapter never calls as
used, and it classifies the withdrawal-limits read as used. Beneficiary
creation costs at most two provider reads before the operation is persisted
— the schema catalogue and, when the chosen rail’s codes are undeclared
there, one withdrawal-limits read for that rail alone. The catalogue route
resolves undeclared rails’ codes in parallel. The single
beneficiary read shares the collection’s cost profile: it performs the full
provider list read and commits reconciliation, so a provider failure
surfaces as a provider error rather than a local 404.
Rejected alternatives
Emitting every key unconditionally from the adapter normalizer: it is the obvious fix for null ambiguity and causes silent data loss, because the reconciliation loop treats an absent key as “keep” and an explicit null as “clear” — unconditional emission collapses the two and one list read would permanently null persisted fields. The ambiguity is fixed in the public projection instead. Serving the single read from the provider’s one-row endpoint: it returns only identifier, context, and status, which would make the single read less total than the list it claims to filter. Renaming the provider connection lifecycle nouns as part of the same sweep: ADR 0013 records whyperflo-connections and its views keep the provider’s
name — the customer is consenting to Perflo by name.