@hyperscale0/adl 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORS +8 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +661 -0
- package/LICENSING.md +51 -0
- package/README.md +165 -0
- package/SECURITY.md +29 -0
- package/TRADEMARKS.md +30 -0
- package/conformance/cases.json +835 -0
- package/dist/conformance.d.ts +41 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +486 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.d.ts +219 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/vocabulary.d.ts +94 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +139 -0
- package/dist/vocabulary.js.map +1 -0
- package/docs/authoring-guide.md +431 -0
- package/examples/meridian-bank/README.md +65 -0
- package/examples/meridian-bank/example.spec.ts +162 -0
- package/examples/meridian-bank/fixtures/payout-status-replay.json +19 -0
- package/examples/meridian-bank/fixtures/payout-status-settled.json +19 -0
- package/examples/meridian-bank/fixtures/payout-submit-accepted.json +26 -0
- package/examples/meridian-bank/fixtures/payout-submit-declined.json +25 -0
- package/examples/meridian-bank/fixtures/statement-camt053.json +45 -0
- package/examples/meridian-bank/fixtures/webhook-payout-settled.json +11 -0
- package/examples/meridian-bank/index.ts +262 -0
- package/package.json +79 -0
- package/spec/manifest.schema.json +757 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# Writing a provider adapter in ADL
|
|
2
|
+
|
|
3
|
+
This is the whole job, in the order you do it. Everything here is checkable:
|
|
4
|
+
each rule names the symbol that enforces it, and `examples/meridian-bank` is a
|
|
5
|
+
complete adapter you can copy.
|
|
6
|
+
|
|
7
|
+
## What an adapter is
|
|
8
|
+
|
|
9
|
+
An adapter is a **declaration**, not a client. It is a plain object saying
|
|
10
|
+
what the provider does: which commands exist, how their responses classify,
|
|
11
|
+
what the bank's status tokens mean, when statements can be fetched, which
|
|
12
|
+
reference comes back on a debit. The runtime that executes it is generic and
|
|
13
|
+
ships with the platform. Your adapter never opens a socket, never holds a
|
|
14
|
+
credential, and never decides what a payment means.
|
|
15
|
+
|
|
16
|
+
That split is why the checks in ADL are declaration-level. Conformance
|
|
17
|
+
does not call your bank. It proves the facts you declared are complete enough
|
|
18
|
+
for a generic runtime to poll, classify, reconcile, and dedupe **without
|
|
19
|
+
guessing**, because every guess a runtime makes about money is a bug you find
|
|
20
|
+
in production.
|
|
21
|
+
|
|
22
|
+
Two consequences worth internalizing before you write a line:
|
|
23
|
+
|
|
24
|
+
- **A fact you have not established is declared absent, never borrowed.** The
|
|
25
|
+
fields that may be omitted (`charges`, `statementDebitReference`, `envelope`)
|
|
26
|
+
exist so you can be honest during authoring. Conformance then refuses to
|
|
27
|
+
certify while they are open. Absent is a finding; a wrong value is an
|
|
28
|
+
outage.
|
|
29
|
+
- **Poll-first.** A provider callback is a hint that a poll is due, never a
|
|
30
|
+
fact to post from. `validatePartnerBankProfile` refuses a profile whose
|
|
31
|
+
`notificationMechanisms` omits `poll`, whatever else it lists.
|
|
32
|
+
|
|
33
|
+
### Adapter Symmetry
|
|
34
|
+
|
|
35
|
+
Hyperscale's own bank adapters are written against this package and nothing
|
|
36
|
+
more. There is no private adapter API, no internal hook, no second interface
|
|
37
|
+
for first-party code. When a first-party adapter needs a seam ADL lacks,
|
|
38
|
+
the seam is added here, in public, or it is not added.
|
|
39
|
+
|
|
40
|
+
Practically: if the example in `examples/meridian-bank` can express your bank,
|
|
41
|
+
so can we, and if it cannot, that is a gap in ADL worth an issue.
|
|
42
|
+
|
|
43
|
+
## The four gates
|
|
44
|
+
|
|
45
|
+
An adapter passes through four gates. Know which one owns which failure, or
|
|
46
|
+
you will fix the wrong thing.
|
|
47
|
+
|
|
48
|
+
| Gate | Symbol | Catches |
|
|
49
|
+
| --------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
50
|
+
| Type check | `ProviderAdapter<Vocabulary>` | Names outside your closed vocabulary, wrong shapes, missing required fields. |
|
|
51
|
+
| Registry load | `createProviderAdapterRegistry` | Cross-field structure at import time: key drift, blank locators, an unreadable clock, a duplicate identity. Throws. |
|
|
52
|
+
| Conformance | `partnerBankConformanceFindings`, `certifyPartnerBankAdapter` | Operational completeness. Returns findings, or throws with all of them listed. |
|
|
53
|
+
| Manifest schema | `spec/manifest.schema.json` | The same structure for authors who are not writing TypeScript. |
|
|
54
|
+
|
|
55
|
+
`conformance/cases.json` records, for every rule, which gates see it. Read it
|
|
56
|
+
when you want the exact division of labor.
|
|
57
|
+
|
|
58
|
+
## 1. Declare your vocabulary
|
|
59
|
+
|
|
60
|
+
Start by naming the closed unions your product uses. `ProviderAdapterVocabulary`
|
|
61
|
+
is nine string fields, and you narrow every one of them:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import type { ProviderAdapterVocabulary } from "@hyperscale0/adl";
|
|
65
|
+
|
|
66
|
+
interface MeridianVocabulary extends ProviderAdapterVocabulary {
|
|
67
|
+
readonly capability: "payout_execution" | "bank_credit";
|
|
68
|
+
readonly domain: "payouts" | "deposits";
|
|
69
|
+
readonly meter: "external_payout";
|
|
70
|
+
readonly obligation: "payout_submission" | "bank_credit";
|
|
71
|
+
readonly operation: "payout.submit" | "deposit.record";
|
|
72
|
+
readonly resource: "payout" | "deposit";
|
|
73
|
+
readonly webhookOperation: "payout.complete";
|
|
74
|
+
readonly webhookResource: "payout";
|
|
75
|
+
readonly webhookResourceId: "payoutId";
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The names come from the published operation catalog for the product you are
|
|
80
|
+
integrating with, not from your provider's documentation. Your provider calls
|
|
81
|
+
it `POST /v2/payments`; the platform calls it `payout.submit`. Translating
|
|
82
|
+
between the two is the adapter's whole reason to exist.
|
|
83
|
+
|
|
84
|
+
Then bind the vocabulary once and keep the returned function:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const defineMeridianAdapters =
|
|
88
|
+
createProviderAdapterRegistry<MeridianVocabulary>();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`createProviderAdapterRegistry` returns a validator that preserves your exact
|
|
92
|
+
literal types (`const Adapters extends readonly ProviderAdapter<Vocabulary>[]`),
|
|
93
|
+
so downstream code sees the operations you actually declared, not `string`.
|
|
94
|
+
|
|
95
|
+
## 2. Declare the operations
|
|
96
|
+
|
|
97
|
+
`operationMap` is keyed by operation name, and the key must equal the binding's
|
|
98
|
+
`operation` field. Drift throws at load:
|
|
99
|
+
`operationMap key submit must match operation payout.submit`.
|
|
100
|
+
|
|
101
|
+
Each `ProviderOperationBinding` carries:
|
|
102
|
+
|
|
103
|
+
| Field | Contract |
|
|
104
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `operation` | The platform operation this binding serves. Equal to its map key. |
|
|
106
|
+
| `direction` | `tenant_initiated` when the runtime sends the provider a command and reads an answer. `system_settlement` when the operation records something observation already saw. This single field decides which rules apply below. |
|
|
107
|
+
| `obligationKind` | Which obligation the operation discharges. Your product's vocabulary. |
|
|
108
|
+
| `resourceKind` | The platform resource the provider's answer commits onto. Must appear in `bindings`. |
|
|
109
|
+
| `resourceIdPath` | Where the resource id is read from. Non-blank, checked at load. |
|
|
110
|
+
| `meter` | Optional. The usage meter this operation feeds. |
|
|
111
|
+
| `envelope` | How **this** operation's response carries its outcome. Required on a live command, see below. |
|
|
112
|
+
| `statusEnquiry` | The authoritative read-after-write. Required on a REST command, see below. |
|
|
113
|
+
|
|
114
|
+
### The envelope class, per operation
|
|
115
|
+
|
|
116
|
+
`ProviderResponseEnvelope` has three inhabitants:
|
|
117
|
+
|
|
118
|
+
- `http_200_body_status` -- every response is HTTP 200 and the outcome is a
|
|
119
|
+
status token in the body. A rejection arrives inside a success.
|
|
120
|
+
- `http_status_error_body` -- the HTTP status carries the outcome and a failure
|
|
121
|
+
body is prose.
|
|
122
|
+
- `unconfirmed_until_live` -- the provider publishes no error sample, so the
|
|
123
|
+
class is honestly unknown.
|
|
124
|
+
|
|
125
|
+
Declare it **per operation**, never per provider. Real providers answer in more
|
|
126
|
+
than one shape across their own endpoints, and inheriting a class from a
|
|
127
|
+
sibling endpoint is how a rejection gets read as a success.
|
|
128
|
+
|
|
129
|
+
Conformance requires the class on any binding where the runtime sends a command
|
|
130
|
+
and reads an answer, meaning `direction: "tenant_initiated"` on an `egress` of
|
|
131
|
+
`rest` or `sync_clearance`. Missing it is `response_envelope_unclassified`; a
|
|
132
|
+
declared `unconfirmed_until_live` is `response_envelope_unconfirmed`, because
|
|
133
|
+
certification is the licence to serve live and the class must be established
|
|
134
|
+
first. A `system_settlement` binding carries no response of its own, so it owes
|
|
135
|
+
no envelope, and declaring one there would be a borrowed fact.
|
|
136
|
+
|
|
137
|
+
### Status enquiry, the read-after-write
|
|
138
|
+
|
|
139
|
+
A REST command's response can be lost after the provider has already acted on
|
|
140
|
+
it. Without an authoritative way to re-read the outcome, the runtime's only
|
|
141
|
+
options are guessing and resubmitting, and resubmitting pays twice.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
statusEnquiry: {
|
|
145
|
+
keys: "provider_reference",
|
|
146
|
+
pathTemplate: "/v2/payments/{providerReference}",
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`keys` says which reference the enquiry is keyed by, and the template must
|
|
151
|
+
contain the matching placeholder, checked at load:
|
|
152
|
+
`{instructionId}` for `instruction_id`, `{providerReference}` for
|
|
153
|
+
`provider_reference`.
|
|
154
|
+
|
|
155
|
+
Presence is a readiness commitment, not a hint. Runtimes must never infer
|
|
156
|
+
enquiry support from a command URL, so:
|
|
157
|
+
|
|
158
|
+
- a `tenant_initiated` binding on `egress: "rest"` without one is
|
|
159
|
+
`status_enquiry_missing`;
|
|
160
|
+
- an enquiry declared on an adapter whose `egress` is not `rest` is
|
|
161
|
+
`status_enquiry_without_rest_egress`, because there is no transport to run it;
|
|
162
|
+
- `sync_clearance` is exempt: the answer arrives in the same exchange.
|
|
163
|
+
|
|
164
|
+
### Egress and direction have to agree
|
|
165
|
+
|
|
166
|
+
`ProviderEgressMode` is how this adapter reaches the provider at all:
|
|
167
|
+
|
|
168
|
+
- `none` -- the capability is learned entirely by observation;
|
|
169
|
+
- `rest` -- per-command HTTP;
|
|
170
|
+
- `batch_reconcile` -- files, reconciled after the fact;
|
|
171
|
+
- `sync_clearance` -- one synchronous exchange that carries its own truth.
|
|
172
|
+
|
|
173
|
+
A `tenant_initiated` binding on `none` or `batch_reconcile` promises an
|
|
174
|
+
outbound call the egress mode cannot make: `egress_direction_incoherent`.
|
|
175
|
+
|
|
176
|
+
## 3. Declare the webhook plans
|
|
177
|
+
|
|
178
|
+
`webhookMap` is keyed by event kind, and the key must equal `eventKind` (load
|
|
179
|
+
throws on drift, same as operations). A `ProviderWebhookTransitionPlan` says
|
|
180
|
+
what a callback must contain before the runtime will treat it as a reason to
|
|
181
|
+
poll: `requiredPayloadFields`, `optionalPayloadFields`,
|
|
182
|
+
`requiredFieldGroups` (each inner array is a set where at least one member is
|
|
183
|
+
required), `resourceIdField`, `resourceKind`, and `timestampField` from the
|
|
184
|
+
`ProviderTimestampField` vocabulary.
|
|
185
|
+
|
|
186
|
+
An adapter with no callbacks declares `webhookMap: {}`. That is complete, not
|
|
187
|
+
missing: polling is the fact.
|
|
188
|
+
|
|
189
|
+
## 4. Declare the bindings
|
|
190
|
+
|
|
191
|
+
`bindings` maps each resource kind this adapter settles to a
|
|
192
|
+
`ProviderResourceBinding`:
|
|
193
|
+
|
|
194
|
+
- `strict` -- the provider's fact must match the platform's row exactly;
|
|
195
|
+
- `claim` -- the provider's fact claims an existing row;
|
|
196
|
+
- `confirmation_only` -- the provider only confirms what the platform already
|
|
197
|
+
decided;
|
|
198
|
+
- `evidence_only` -- the fact is recorded as evidence, and settles nothing.
|
|
199
|
+
|
|
200
|
+
Every `resourceKind` named by an operation binding or a webhook plan needs an
|
|
201
|
+
entry, or conformance reports `resource_binding_missing`: a provider fact with
|
|
202
|
+
no declared match mode has no lawful way to commit.
|
|
203
|
+
|
|
204
|
+
## 5. Declare the config
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
config: {
|
|
208
|
+
baseUrl: "https://api.meridian-bank.example/v2",
|
|
209
|
+
credentialRef: { source: "environment", name: "MERIDIAN_BANK_CREDENTIALS" },
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`ProviderCredentialReference` is a **locator**: an environment variable name,
|
|
214
|
+
or a keychain service and account. There is no field a secret can be written
|
|
215
|
+
into, and `validateConfig` refuses a blank locator or a relative `baseUrl`.
|
|
216
|
+
Never put a token, certificate, or password in an adapter. The declaration is
|
|
217
|
+
committed to a repository; the credential is not.
|
|
218
|
+
|
|
219
|
+
## 6. Declare the partner-bank profile
|
|
220
|
+
|
|
221
|
+
`PartnerBankAdapterProfile` is the bank as fact. It is the longest part of the
|
|
222
|
+
job and the part that repays care, because every field is something a runtime
|
|
223
|
+
would otherwise guess.
|
|
224
|
+
|
|
225
|
+
### Auth and wire
|
|
226
|
+
|
|
227
|
+
`wireCodec` (`rest_json`, `iso20022_xml`, `swift_mt`) and `auth`
|
|
228
|
+
(`ProviderAuthEnvelope` plus a signing scheme). Declaring a grant the bank will
|
|
229
|
+
not accept fails on the first live call, so read the bank's token service, do
|
|
230
|
+
not assume client credentials.
|
|
231
|
+
|
|
232
|
+
### The idempotency spine
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
idempotencySpine: {
|
|
236
|
+
dedupeKeys: ["instructionId", "paymentReference"],
|
|
237
|
+
instructionId: { maxLength: 32 },
|
|
238
|
+
endToEndId: { maxLength: 32 },
|
|
239
|
+
paymentReference: { maxLength: 20 },
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`dedupeKeys` names which references the bank grades a duplicate against. It
|
|
244
|
+
must be non-empty and free of duplicates (both checked at load). The three
|
|
245
|
+
length facts are what stops the runtime minting a reference the bank will
|
|
246
|
+
truncate, which is how two payments end up sharing an identity.
|
|
247
|
+
|
|
248
|
+
### The lifecycle vocabulary
|
|
249
|
+
|
|
250
|
+
The bank's own status spellings, mapped onto canonical states:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
lifecycleVocabulary: {
|
|
254
|
+
QUEUED: "received",
|
|
255
|
+
AUTHORISED: "accepted",
|
|
256
|
+
SETTLED: "processed",
|
|
257
|
+
DECLINED: "failed",
|
|
258
|
+
RECALLED: "returned",
|
|
259
|
+
REPLAY_SETTLED: "duplicate_original_succeeded",
|
|
260
|
+
REPLAY_DECLINED: "duplicate_original_rejected",
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Runtimes match after trimming and upper-casing, so `ok`, `OK ` and `OK` are one
|
|
265
|
+
entry. Four rules apply:
|
|
266
|
+
|
|
267
|
+
- the vocabulary must spell `processed`, `failed`, and `returned`, or a payment
|
|
268
|
+
ending there is unclassifiable and polls forever
|
|
269
|
+
(`lifecycle_terminals_incomplete`);
|
|
270
|
+
- it must spell `received` or `accepted`, because an acknowledgement proves the
|
|
271
|
+
bank took the instruction, never that money moved
|
|
272
|
+
(`lifecycle_intake_missing`);
|
|
273
|
+
- a spelling that trims to nothing can never match (`lifecycle_spelling_blank`);
|
|
274
|
+
- two spellings that normalize to the same token must agree on the canonical
|
|
275
|
+
state, or classification becomes order-dependent
|
|
276
|
+
(`lifecycle_spelling_conflict`).
|
|
277
|
+
|
|
278
|
+
And the one authors forget: both duplicate verdicts must be spellable
|
|
279
|
+
(`duplicate_verdicts_unmapped`). The dedupe spine means a crash-recovery
|
|
280
|
+
resubmission comes back graded against the original's outcome. Unclassified,
|
|
281
|
+
your own recovery reads as a fresh failure.
|
|
282
|
+
|
|
283
|
+
### Statements
|
|
284
|
+
|
|
285
|
+
`statementFormats` lists what the bank can hand you (`mt940`, `mt942`,
|
|
286
|
+
`camt_052`, `camt_053`, `obie_json`), and `windows.statements` gives each one a
|
|
287
|
+
fetch window:
|
|
288
|
+
|
|
289
|
+
- `availability: "T-1"` pages over history, so it needs positive `lookbackDays`
|
|
290
|
+
and `statementDaysPerRequest`;
|
|
291
|
+
- `availability: "today_only"` is intraday and owes no paging facts.
|
|
292
|
+
|
|
293
|
+
A declared format with no window is `statement_window_missing`; a T-1 window
|
|
294
|
+
without paging facts is `statement_window_incomplete`. No formats at all is
|
|
295
|
+
`statement_formats_empty`: statements are bank truth, and reconciliation has
|
|
296
|
+
nothing to prove the ledger against without one.
|
|
297
|
+
|
|
298
|
+
`statementDebitReference` says which of the two references on a debit line
|
|
299
|
+
names the instruction behind it: the bank's own entry reference
|
|
300
|
+
(`bank_reference`) or the reference you sent (`customer_reference`). Banks
|
|
301
|
+
disagree, and the wrong choice makes every payout you instructed look like an
|
|
302
|
+
unexplained debit. Leaving it unestablished is
|
|
303
|
+
`statement_debit_reference_unestablished`.
|
|
304
|
+
|
|
305
|
+
### Charges
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
charges: {
|
|
309
|
+
narratives: { charge: ["MERIDIAN FEE"], vat: ["MERIDIAN TAX"] },
|
|
310
|
+
posting: "post_hoc",
|
|
311
|
+
vat: "separate_line",
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`posting` says whether the bank debits its fee separately after the payment or
|
|
316
|
+
nets it out of the instructed amount; `vat` says whether tax arrives as its own
|
|
317
|
+
line. Post-hoc charges and separate VAT lines are bank-initiated debits
|
|
318
|
+
recognizable only by their wording, so each mode in play needs at least one
|
|
319
|
+
narrative prefix (`charge_narrative_unestablished`,
|
|
320
|
+
`vat_narrative_unestablished`). Omitting `charges` entirely is honest during
|
|
321
|
+
authoring and refused at certification (`charges_unestablished`): the bank's own
|
|
322
|
+
debits would land as unexplained statement lines.
|
|
323
|
+
|
|
324
|
+
### Windows
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
paymentCutoff: {
|
|
328
|
+
time: "16:30",
|
|
329
|
+
timeZone: "Europe/Dublin",
|
|
330
|
+
beforeCutoffValueDate: "D+0",
|
|
331
|
+
afterCutoffValueDate: "D+1",
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The cutoff is read on the **bank's** clock, so `timeZone` is an IANA name and
|
|
336
|
+
both gates resolve it through `Intl.DateTimeFormat`. Absence is not tolerated:
|
|
337
|
+
an undefined zone silently means the host's clock
|
|
338
|
+
(`cutoff_clock_invalid`).
|
|
339
|
+
|
|
340
|
+
Value dates are `D+<whole days>`. Missing a cutoff can only push value later,
|
|
341
|
+
so an after-cutoff date earlier than the before-cutoff one is a
|
|
342
|
+
`cutoff_window_regression`, and so is a `valueDateMaxDaysAhead` that cannot
|
|
343
|
+
reach the after-cutoff date. A malformed offset is
|
|
344
|
+
`value_date_offset_invalid` rather than a NaN that slides through every
|
|
345
|
+
comparison after it.
|
|
346
|
+
|
|
347
|
+
### The rest
|
|
348
|
+
|
|
349
|
+
`executedReadBack` (whether the executed read-back may name another rail, and
|
|
350
|
+
when an FX rate is final), `limitFacts` (what the bank exposes about limits),
|
|
351
|
+
`financialAddressProvisioning` (how virtual addresses are issued, priced, and
|
|
352
|
+
quota'd), and `notificationMechanisms` (which must include `poll`).
|
|
353
|
+
|
|
354
|
+
## 7. Run conformance
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
bun run conformance -- ./examples/meridian-bank
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
```
|
|
361
|
+
ok meridian_bank:payout_execution
|
|
362
|
+
ok meridian_bank:bank_credit
|
|
363
|
+
2 adapter(s) checked, 0 finding(s) reported
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The runner exits 1 when anything reports, so it drops straight into CI. It
|
|
367
|
+
takes a module path or a directory, and certifies every adapter the module
|
|
368
|
+
exports.
|
|
369
|
+
|
|
370
|
+
In your own repository, call the function directly and skip the runner:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { certifyPartnerBankAdapter } from "@hyperscale0/adl";
|
|
374
|
+
|
|
375
|
+
import { myAdapters } from "./src/index.js";
|
|
376
|
+
|
|
377
|
+
for (const adapter of myAdapters) certifyPartnerBankAdapter(adapter);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`partnerBankConformanceFindings` returns every finding as
|
|
381
|
+
`{ code, path, message }`, where `path` is the dotted location inside your
|
|
382
|
+
declaration. `certifyPartnerBankAdapter` throws with all of them listed at
|
|
383
|
+
once, so you fix a declaration in one pass rather than one throw at a time.
|
|
384
|
+
The codes are append-only: a released code is never renamed or removed, so you
|
|
385
|
+
can switch on `PartnerBankConformanceCode` without a stale fallback arm.
|
|
386
|
+
|
|
387
|
+
An empty result is the certification. Nothing else is.
|
|
388
|
+
|
|
389
|
+
## 8. Write the manifest without TypeScript
|
|
390
|
+
|
|
391
|
+
`spec/manifest.schema.json` is JSON Schema 2020-12 for one adapter
|
|
392
|
+
declaration, generated from `src/vocabulary.ts` by `scripts/emit-spec.ts` and
|
|
393
|
+
drift-checked by `bun run spec:check`. Generate types, validate a
|
|
394
|
+
hand-written manifest, or feed it to an editor.
|
|
395
|
+
|
|
396
|
+
The schema is structural. Rules that compare two fields, resolve a time zone,
|
|
397
|
+
or match a property key against a value inside it cannot be expressed in JSON
|
|
398
|
+
Schema and stay in the validator and the conformance suite.
|
|
399
|
+
`conformance/cases.json` says which gate owns each rule, in the `note` field of
|
|
400
|
+
every case where they differ.
|
|
401
|
+
|
|
402
|
+
## 9. Hand it over
|
|
403
|
+
|
|
404
|
+
An adapter is handed over as source, never as a running service:
|
|
405
|
+
|
|
406
|
+
1. `bun run conformance` reports zero findings.
|
|
407
|
+
2. Your own tests read captured provider bytes and prove the declaration
|
|
408
|
+
matches them. `examples/meridian-bank/example.spec.ts` is the pattern:
|
|
409
|
+
every status token in a fixture is spelled in the lifecycle vocabulary, the
|
|
410
|
+
envelope class is how the fixtures actually read, and the declared charge
|
|
411
|
+
narratives match the statement lines.
|
|
412
|
+
3. Open an issue on this repository with the certified declaration and the
|
|
413
|
+
conformance output. Credentials are never part of a submission; the
|
|
414
|
+
declaration names locators, and the operator fills them in.
|
|
415
|
+
|
|
416
|
+
## Stability
|
|
417
|
+
|
|
418
|
+
The package is `1.0.0-alpha.1` and the surface can still change on a minor
|
|
419
|
+
version until it is `1.0.0`. Two promises hold from now:
|
|
420
|
+
|
|
421
|
+
- conformance codes are append-only;
|
|
422
|
+
- a value removed from a vocabulary in `src/vocabulary.ts` is a breaking change
|
|
423
|
+
and appears in `CHANGELOG.md`.
|
|
424
|
+
|
|
425
|
+
## What is not here
|
|
426
|
+
|
|
427
|
+
The declaration carries no timeouts, no retry policy, and no rate limits.
|
|
428
|
+
Those belong to the runtime that executes adapters, not to the facts about the
|
|
429
|
+
provider, and no field in `ProviderAdapter` accepts them. If your provider's
|
|
430
|
+
limits are a fact the runtime must know, open an issue: under Adapter Symmetry
|
|
431
|
+
the seam gets added here, publicly, or not at all.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Meridian Bank
|
|
2
|
+
|
|
3
|
+
A complete, certified ADL adapter for a bank that does not exist. Every endpoint,
|
|
4
|
+
status token, charge narrative, and account number here is invented, and the
|
|
5
|
+
domain `meridian-bank.example` is reserved by RFC 2606 so nothing in this
|
|
6
|
+
directory can point at a real institution.
|
|
7
|
+
|
|
8
|
+
It exists for two reasons: it is the thing to copy when you write your own,
|
|
9
|
+
and it is the proof that a third party can pass conformance with nothing but
|
|
10
|
+
the published package.
|
|
11
|
+
|
|
12
|
+
## What is here
|
|
13
|
+
|
|
14
|
+
| File | What it is |
|
|
15
|
+
| ----------------- | ------------------------------------------------------------------------------------------- |
|
|
16
|
+
| `index.ts` | The adapter: vocabulary, profile, config, two capabilities. Read it top to bottom. |
|
|
17
|
+
| `fixtures/*.json` | Meridian's own bytes: two command responses, two status enquiries, a statement, a callback. |
|
|
18
|
+
| `example.spec.ts` | Conformance, plus the checks that keep the declaration honest against the fixtures. |
|
|
19
|
+
|
|
20
|
+
## Meridian, briefly
|
|
21
|
+
|
|
22
|
+
An imaginary European bank with a REST API:
|
|
23
|
+
|
|
24
|
+
- **Errors ride the HTTP status.** A rejected payment is a 4xx with prose in
|
|
25
|
+
the body, which is `envelope: "http_status_error_body"`. A bank that answered
|
|
26
|
+
200 with a rejection token in the body would be `http_200_body_status`
|
|
27
|
+
instead, and the class is declared per operation because real providers are
|
|
28
|
+
inconsistent across their own endpoints.
|
|
29
|
+
- **A payment is re-read by the reference Meridian minted**, so
|
|
30
|
+
`statusEnquiry.keys` is `provider_reference` and the path template carries
|
|
31
|
+
`{providerReference}`. That is the read-after-write: a submit whose response
|
|
32
|
+
we never saw is re-read, never resubmitted.
|
|
33
|
+
- **Incoming credits are learned from statements**, so the `bank_credit`
|
|
34
|
+
capability declares `egress: "none"` and records what observation saw.
|
|
35
|
+
- **Fees and tax post separately** as `MERIDIAN FEE ...` and `MERIDIAN TAX ...`
|
|
36
|
+
debits, which is why both narrative prefixes are declared: unrecognized, they
|
|
37
|
+
would land as unexplained statement lines.
|
|
38
|
+
- **The statement echoes our reference**, so `statementDebitReference` is
|
|
39
|
+
`customer_reference`. Reading Meridian's own entry id instead would make
|
|
40
|
+
every payout we instructed look like an unexplained debit.
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
From the package root:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bun run conformance -- ./examples/meridian-bank
|
|
48
|
+
bun test examples/meridian-bank
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Copying it
|
|
52
|
+
|
|
53
|
+
1. Copy the directory and rename `meridian` throughout. The provider key is
|
|
54
|
+
lowercase and machine-shaped (`^[a-z][a-z0-9_.-]{0,79}$`).
|
|
55
|
+
2. Replace the vocabulary with the operations and resources your product
|
|
56
|
+
actually uses.
|
|
57
|
+
3. Replace every profile fact with what your bank does. Do not carry a
|
|
58
|
+
Meridian value across because it looked plausible: a fact you have not
|
|
59
|
+
established is declared absent, and conformance will tell you which ones
|
|
60
|
+
are still open.
|
|
61
|
+
4. Replace the fixtures with real captures from your provider's sandbox, and
|
|
62
|
+
keep `example.spec.ts` pointed at them. A declaration that certifies but
|
|
63
|
+
cannot read its own sandbox capture is not finished.
|
|
64
|
+
|
|
65
|
+
`../../docs/authoring-guide.md` explains every field.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The example's own test, and the shape yours should take: conformance is
|
|
3
|
+
* the gate, and the fixtures keep the declaration honest against the bytes
|
|
4
|
+
* the bank actually sends. A declaration that certifies but cannot read its
|
|
5
|
+
* own sandbox capture is not finished.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
import { partnerBankConformanceFindings } from "@hyperscale0/adl";
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { meridianAdapters, meridianProfile } from "./index.js";
|
|
14
|
+
|
|
15
|
+
interface ExchangeFixture {
|
|
16
|
+
readonly kind: "command" | "enquiry";
|
|
17
|
+
readonly operation: string;
|
|
18
|
+
readonly response: {
|
|
19
|
+
readonly httpStatus: number;
|
|
20
|
+
readonly body: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface StatementFixture {
|
|
25
|
+
readonly format: string;
|
|
26
|
+
readonly entries: readonly {
|
|
27
|
+
readonly amount: string;
|
|
28
|
+
readonly customerReference: string | null;
|
|
29
|
+
readonly narrative: string;
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface NotificationFixture {
|
|
34
|
+
readonly eventKind: string;
|
|
35
|
+
readonly payload: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fixture<T>(name: string): T {
|
|
39
|
+
return JSON.parse(
|
|
40
|
+
readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), "utf8"),
|
|
41
|
+
) as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const exchanges = [
|
|
45
|
+
fixture<ExchangeFixture>("payout-submit-accepted"),
|
|
46
|
+
fixture<ExchangeFixture>("payout-submit-declined"),
|
|
47
|
+
fixture<ExchangeFixture>("payout-status-settled"),
|
|
48
|
+
fixture<ExchangeFixture>("payout-status-replay"),
|
|
49
|
+
];
|
|
50
|
+
const statement = fixture<StatementFixture>("statement-camt053");
|
|
51
|
+
const notification = fixture<NotificationFixture>("webhook-payout-settled");
|
|
52
|
+
|
|
53
|
+
const [payoutAdapter] = meridianAdapters;
|
|
54
|
+
if (!payoutAdapter) throw new Error("payout adapter missing");
|
|
55
|
+
|
|
56
|
+
/** The same normalization a runtime uses to match a status token. */
|
|
57
|
+
function normalize(token: string): string {
|
|
58
|
+
return token.trim().toUpperCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const canonicalStateFor = new Map(
|
|
62
|
+
Object.entries(meridianProfile.lifecycleVocabulary).map(
|
|
63
|
+
([spelling, state]) => [normalize(spelling), state as string],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
describe("meridian bank adapter", () => {
|
|
68
|
+
test("every capability certifies with zero findings", () => {
|
|
69
|
+
for (const adapter of meridianAdapters) {
|
|
70
|
+
expect({
|
|
71
|
+
capability: adapter.capability,
|
|
72
|
+
findings: partnerBankConformanceFindings(adapter),
|
|
73
|
+
}).toEqual({ capability: adapter.capability, findings: [] });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the lifecycle vocabulary spells every token the bank sends", () => {
|
|
78
|
+
for (const exchange of exchanges) {
|
|
79
|
+
const token = exchange.response.body["status"];
|
|
80
|
+
expect(typeof token).toBe("string");
|
|
81
|
+
expect(canonicalStateFor.get(normalize(token as string))).toBeDefined();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the declared envelope class is how the fixtures actually read", () => {
|
|
86
|
+
// http_status_error_body: the HTTP status carries the outcome and a
|
|
87
|
+
// failure body is prose. Reading the body of a 2xx for a rejection, or
|
|
88
|
+
// treating a 4xx as a lost response, would strand the payment.
|
|
89
|
+
expect(payoutAdapter.operationMap["payout.submit"]?.envelope).toBe(
|
|
90
|
+
"http_status_error_body",
|
|
91
|
+
);
|
|
92
|
+
for (const exchange of exchanges) {
|
|
93
|
+
if (exchange.kind !== "command") continue;
|
|
94
|
+
const state = canonicalStateFor.get(
|
|
95
|
+
normalize(exchange.response.body["status"] as string),
|
|
96
|
+
);
|
|
97
|
+
if (state === "failed") {
|
|
98
|
+
expect(exchange.response.httpStatus).toBeGreaterThanOrEqual(400);
|
|
99
|
+
expect(typeof exchange.response.body["message"]).toBe("string");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
expect(exchange.response.httpStatus).toBeLessThan(400);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the statement format is declared and has a fetch window", () => {
|
|
107
|
+
const formats: readonly string[] = meridianProfile.statementFormats;
|
|
108
|
+
expect(formats).toContain(statement.format);
|
|
109
|
+
const windows: Record<string, unknown> = meridianProfile.windows.statements;
|
|
110
|
+
expect(windows[statement.format]).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("the charge narratives recognize the bank's own debits", () => {
|
|
114
|
+
const startsWithAny = (narrative: string, prefixes: readonly string[]) =>
|
|
115
|
+
prefixes.some((prefix) => normalize(narrative).startsWith(prefix));
|
|
116
|
+
|
|
117
|
+
const { charge, vat } = meridianProfile.charges.narratives;
|
|
118
|
+
const chargeLines = statement.entries.filter((entry) =>
|
|
119
|
+
startsWithAny(entry.narrative, charge),
|
|
120
|
+
);
|
|
121
|
+
const vatLines = statement.entries.filter((entry) =>
|
|
122
|
+
startsWithAny(entry.narrative, vat),
|
|
123
|
+
);
|
|
124
|
+
expect(chargeLines).toHaveLength(1);
|
|
125
|
+
expect(vatLines).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an instructed payout is attributable on the statement", () => {
|
|
129
|
+
// statementDebitReference says which reference comes back. Every debit
|
|
130
|
+
// that is not one of the bank's own charge lines must carry it, or the
|
|
131
|
+
// ledger cannot prove the debit was ours.
|
|
132
|
+
expect(meridianProfile.statementDebitReference).toBe("customer_reference");
|
|
133
|
+
const chargePrefixes = [
|
|
134
|
+
...meridianProfile.charges.narratives.charge,
|
|
135
|
+
...meridianProfile.charges.narratives.vat,
|
|
136
|
+
];
|
|
137
|
+
const instructedDebits = statement.entries.filter(
|
|
138
|
+
(entry) =>
|
|
139
|
+
entry.amount.startsWith("-") &&
|
|
140
|
+
!chargePrefixes.some((prefix) =>
|
|
141
|
+
normalize(entry.narrative).startsWith(prefix),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
expect(instructedDebits).not.toHaveLength(0);
|
|
145
|
+
for (const debit of instructedDebits) {
|
|
146
|
+
expect(typeof debit.customerReference).toBe("string");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the webhook plan describes the callback the bank sends", () => {
|
|
151
|
+
const plan = Object.entries(payoutAdapter.webhookMap).find(
|
|
152
|
+
([eventKind]) => eventKind === notification.eventKind,
|
|
153
|
+
)?.[1];
|
|
154
|
+
expect(plan).toBeDefined();
|
|
155
|
+
if (!plan) return;
|
|
156
|
+
for (const field of plan.requiredPayloadFields) {
|
|
157
|
+
expect(notification.payload[field]).toBeDefined();
|
|
158
|
+
}
|
|
159
|
+
expect(notification.payload[plan.resourceIdField]).toBeDefined();
|
|
160
|
+
expect(notification.payload[plan.timestampField]).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "enquiry",
|
|
3
|
+
"operation": "payout.submit",
|
|
4
|
+
"request": {
|
|
5
|
+
"method": "GET",
|
|
6
|
+
"path": "/v2/payments/MER-9F3K2"
|
|
7
|
+
},
|
|
8
|
+
"response": {
|
|
9
|
+
"httpStatus": 200,
|
|
10
|
+
"body": {
|
|
11
|
+
"providerReference": "MER-9F3K2",
|
|
12
|
+
"paymentReference": "HS-PAY-0007",
|
|
13
|
+
"status": "REPLAY_SETTLED",
|
|
14
|
+
"originalProviderReference": "MER-9F3K2",
|
|
15
|
+
"valueDate": "2026-03-03",
|
|
16
|
+
"settledAt": "2026-03-03T09:04:41Z"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|