Skip to main content

ADR 0014: Speak one beneficiary vocabulary in the contract

Context

The beneficiary domain published two vocabularies. The neobank resource was beneficiary-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 no recipient-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 why perflo-connections and its views keep the provider’s name — the customer is consenting to Perflo by name.