@hyperscale0/adl 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -1,432 +1,52 @@
1
1
  # Writing a provider adapter in ADL
2
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.
3
+ `ProviderAdapter` declares an adapter's identity, operation bindings and subject
4
+ requirements. `BoundaryAdapter` implements dispatch and observation. The runtime
5
+ owns instruction identity and money accounts.
6
6
 
7
- ## What an adapter is
7
+ ## Declare the binding
8
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.
9
+ Use `createProviderAdapterRegistry` to validate declarations. Each operation map
10
+ key must equal its binding's `operation`. Its `resourceKind` must appear in
11
+ `bindings`. Subject requirements describe required authored fields or attachments;
12
+ they never supply an account or choose a party.
15
13
 
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. Every guess a runtime makes about money is a provider-boundary
20
- defect.
14
+ The [generic fixture](../src/boundary-fixture.ts) declares one boundary
15
+ observation operation with no subject requirements. The
16
+ [subject fixture](../conformance/subject-adapter.ts) shows required subject fields.
21
17
 
22
- Two consequences worth internalizing before you write a line:
18
+ `adapterConformanceFindings` checks the declaration. The
19
+ [manifest schema](../spec/manifest.schema.json) expresses the same public shape
20
+ for non-TypeScript authors. Neither check contacts a provider.
23
21
 
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.
22
+ ## Dispatch an instruction
32
23
 
33
- ### Adapter symmetry
24
+ `BoundaryInstruction` binds an immutable ID to the environment, tenant, Product,
25
+ retained Build, target, source and destination accounts, positive minor-unit
26
+ amount, currency and adapter. The runtime reserves the amount before dispatch.
27
+ An adapter receives that instruction unchanged.
34
28
 
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.
29
+ `BoundaryAdapter.dispatch` and `BoundaryAdapter.observe` return a
30
+ `BoundaryObservation` or `undefined`. The observation names the adapter,
31
+ instruction ID, stable external reference, explicit outcome and observation time.
32
+ Its outcome is `acknowledged`, `unknown`, `confirmed` or `rejected`.
39
33
 
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.
34
+ An acknowledgement or missing result leaves the reservation held. HTTP status
35
+ never supplies a terminal outcome. Only a matching `confirmed` observation can
36
+ authorize posting; an explicit `rejected` observation can authorize voiding.
37
+ The runtime records immutable observations and consumes settlement evidence once.
38
+ Recovery completes the same ledger intent without resending uncertain work.
42
39
 
43
- ## The four gates
40
+ ## Check the declaration
44
41
 
45
- An adapter passes through four gates. Know which one owns which failure, or
46
- you will fix the wrong thing.
42
+ From this package:
47
43
 
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 when the runtime sends a provider 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
- conformance requires the response class to be established first. Passing that
134
- check does not grant live readiness. A `system_settlement` binding carries no
135
- response of its own, so it owes no envelope, and declaring one there would be a
136
- borrowed fact.
137
-
138
- ### Status enquiry, the read-after-write
139
-
140
- A REST command's response can be lost after the provider has already acted on
141
- it. Without an authoritative way to re-read the outcome, the runtime's only
142
- options are guessing and resubmitting, and resubmitting pays twice.
143
-
144
- ```ts
145
- statusEnquiry: {
146
- keys: "provider_reference",
147
- pathTemplate: "/v2/payments/{providerReference}",
148
- }
149
- ```
150
-
151
- `keys` says which reference the enquiry is keyed by, and the template must
152
- contain the matching placeholder, checked at load:
153
- `{instructionId}` for `instruction_id`, `{providerReference}` for
154
- `provider_reference`.
155
-
156
- Presence is a readiness commitment, not a hint. Runtimes must never infer
157
- enquiry support from a command URL, so:
158
-
159
- - a `tenant_initiated` binding on `egress: "rest"` without one is
160
- `status_enquiry_missing`;
161
- - an enquiry declared on an adapter whose `egress` is not `rest` is
162
- `status_enquiry_without_rest_egress`, because there is no transport to run it;
163
- - `sync_clearance` is exempt: the answer arrives in the same exchange.
164
-
165
- ### Egress and direction have to agree
166
-
167
- `ProviderEgressMode` is how this adapter reaches the provider at all:
168
-
169
- - `none`: the capability is learned entirely by observation;
170
- - `rest`: per-command HTTP;
171
- - `batch_reconcile`: files, reconciled after the fact;
172
- - `sync_clearance`: one synchronous exchange that carries its own truth.
173
-
174
- A `tenant_initiated` binding on `none` or `batch_reconcile` promises an
175
- outbound call the egress mode cannot make: `egress_direction_incoherent`.
176
-
177
- ## 3. Declare the webhook plans
178
-
179
- `webhookMap` is keyed by event kind, and the key must equal `eventKind` (load
180
- throws on drift, same as operations). A `ProviderWebhookTransitionPlan` says
181
- what a callback must contain before the runtime will treat it as a reason to
182
- poll: `requiredPayloadFields`, `optionalPayloadFields`,
183
- `requiredFieldGroups` (each inner array is a set where at least one member is
184
- required), `resourceIdField`, `resourceKind`, and `timestampField` from the
185
- `ProviderTimestampField` vocabulary.
186
-
187
- An adapter with no callbacks declares `webhookMap: {}`. That is complete, not
188
- missing: polling is the fact.
189
-
190
- ## 4. Declare the bindings
191
-
192
- `bindings` maps each resource kind this adapter settles to a
193
- `ProviderResourceBinding`:
194
-
195
- - `strict`: the provider's fact must match the platform's row exactly;
196
- - `claim`: the provider's fact claims an existing row;
197
- - `confirmation_only`: the provider only confirms what the platform already
198
- decided;
199
- - `evidence_only`: the fact is recorded as evidence, and settles nothing.
200
-
201
- Every `resourceKind` named by an operation binding or a webhook plan needs an
202
- entry, or conformance reports `resource_binding_missing`: a provider fact with
203
- no declared match mode has no lawful way to commit.
204
-
205
- ## 5. Declare the config
206
-
207
- ```ts
208
- config: {
209
- baseUrl: "https://api.meridian-bank.example/v2",
210
- credentialRef: { source: "environment", name: "MERIDIAN_BANK_CREDENTIALS" },
211
- }
212
- ```
213
-
214
- `ProviderCredentialReference` is a **locator**: an environment variable name,
215
- or a keychain service and account. There is no field a secret can be written
216
- into, and `validateConfig` refuses a blank locator or a relative `baseUrl`.
217
- Never put a token, certificate, or password in an adapter. The declaration is
218
- committed to a repository; the credential is not.
219
-
220
- ## 6. Declare the partner-bank profile
221
-
222
- `PartnerBankAdapterProfile` is the bank as fact. It is the longest part of the
223
- job and the part that repays care, because every field is something a runtime
224
- would otherwise guess.
225
-
226
- ### Auth and wire
227
-
228
- `wireCodec` (`rest_json`, `iso20022_xml`, `swift_mt`) and `auth`
229
- (`ProviderAuthEnvelope` plus a signing scheme). Declaring a grant the bank will
230
- not accept fails on the first provider call. Read the bank's token service and
231
- do not assume client credentials.
232
-
233
- ### The idempotency spine
234
-
235
- ```ts
236
- idempotencySpine: {
237
- dedupeKeys: ["instructionId", "paymentReference"],
238
- instructionId: { maxLength: 32 },
239
- endToEndId: { maxLength: 32 },
240
- paymentReference: { maxLength: 20 },
241
- }
44
+ ```sh
45
+ bun run conformance -- ./src/boundary-fixture.ts
46
+ bun run spec:emit
242
47
  ```
243
48
 
244
- `dedupeKeys` names which references the bank grades a duplicate against. It
245
- must be non-empty and free of duplicates (both checked at load). The three
246
- length facts are what stops the runtime minting a reference the bank will
247
- truncate, which is how two payments end up sharing an identity.
248
-
249
- ### The lifecycle vocabulary
250
-
251
- The bank's own status spellings, mapped onto canonical states:
252
-
253
- ```ts
254
- lifecycleVocabulary: {
255
- QUEUED: "received",
256
- AUTHORISED: "accepted",
257
- SETTLED: "processed",
258
- DECLINED: "failed",
259
- RECALLED: "returned",
260
- REPLAY_SETTLED: "duplicate_original_succeeded",
261
- REPLAY_DECLINED: "duplicate_original_rejected",
262
- }
263
- ```
264
-
265
- Runtimes match after trimming and upper-casing, so `ok`, `OK ` and `OK` are one
266
- entry. Four rules apply:
267
-
268
- - the vocabulary must spell `processed`, `failed`, and `returned`, or a payment
269
- ending there is unclassifiable and polls forever
270
- (`lifecycle_terminals_incomplete`);
271
- - it must spell `received` or `accepted`, because an acknowledgement proves the
272
- bank took the instruction, never that money moved
273
- (`lifecycle_intake_missing`);
274
- - a spelling that trims to nothing can never match (`lifecycle_spelling_blank`);
275
- - two spellings that normalize to the same token must agree on the canonical
276
- state, or classification becomes order-dependent
277
- (`lifecycle_spelling_conflict`).
278
-
279
- And the one authors forget: both duplicate verdicts must be spellable
280
- (`duplicate_verdicts_unmapped`). The dedupe spine means a crash-recovery
281
- resubmission comes back graded against the original's outcome. Unclassified,
282
- your own recovery reads as a fresh failure.
283
-
284
- ### Statements
285
-
286
- `statementFormats` lists what the bank can hand you (`mt940`, `mt942`,
287
- `camt_052`, `camt_053`, `obie_json`), and `windows.statements` gives each one a
288
- fetch window:
289
-
290
- - `availability: "T-1"` pages over history, so it needs positive `lookbackDays`
291
- and `statementDaysPerRequest`;
292
- - `availability: "today_only"` is intraday and owes no paging facts.
293
-
294
- A declared format with no window is `statement_window_missing`; a T-1 window
295
- without paging facts is `statement_window_incomplete`. No formats at all is
296
- `statement_formats_empty`: statements are bank truth, and reconciliation has
297
- nothing to prove the ledger against without one.
298
-
299
- `statementDebitReference` says which of the two references on a debit line
300
- names the instruction behind it: the bank's own entry reference
301
- (`bank_reference`) or the reference you sent (`customer_reference`). Banks
302
- disagree, and the wrong choice makes every payout you instructed look like an
303
- unexplained debit. Leaving it unestablished is
304
- `statement_debit_reference_unestablished`.
305
-
306
- ### Charges
307
-
308
- ```ts
309
- charges: {
310
- narratives: { charge: ["MERIDIAN FEE"], vat: ["MERIDIAN TAX"] },
311
- posting: "post_hoc",
312
- vat: "separate_line",
313
- }
314
- ```
315
-
316
- `posting` says whether the bank debits its fee separately after the payment or
317
- nets it out of the instructed amount; `vat` says whether tax arrives as its own
318
- line. Post-hoc charges and separate VAT lines are bank-initiated debits
319
- recognizable only by their wording, so each mode in play needs at least one
320
- narrative prefix (`charge_narrative_unestablished`,
321
- `vat_narrative_unestablished`). Omitting `charges` entirely is honest during
322
- authoring and refused at certification (`charges_unestablished`): the bank's own
323
- debits would land as unexplained statement lines.
324
-
325
- ### Windows
326
-
327
- ```ts
328
- paymentCutoff: {
329
- time: "16:30",
330
- timeZone: "Europe/Dublin",
331
- beforeCutoffValueDate: "D+0",
332
- afterCutoffValueDate: "D+1",
333
- }
334
- ```
335
-
336
- The cutoff is read on the **bank's** clock, so `timeZone` is an IANA name and
337
- both gates resolve it through `Intl.DateTimeFormat`. Absence is not tolerated:
338
- an undefined zone silently means the host's clock
339
- (`cutoff_clock_invalid`).
340
-
341
- Value dates are `D+<whole days>`. Missing a cutoff can only push value later,
342
- so an after-cutoff date earlier than the before-cutoff one is a
343
- `cutoff_window_regression`, and so is a `valueDateMaxDaysAhead` that cannot
344
- reach the after-cutoff date. A malformed offset is
345
- `value_date_offset_invalid` rather than a NaN that slides through every
346
- comparison after it.
347
-
348
- ### The rest
349
-
350
- `executedReadBack` (whether the executed read-back may name another rail, and
351
- when an FX rate is final), `limitFacts` (what the bank exposes about limits),
352
- `financialAddressProvisioning` (how virtual addresses are issued, priced, and
353
- quota'd), and `notificationMechanisms` (which must include `poll`).
354
-
355
- ## 7. Run conformance
356
-
357
- ```bash
358
- bun run conformance -- ./examples/meridian-bank
359
- ```
360
-
361
- ```
362
- ok meridian_bank:payout_execution
363
- ok meridian_bank:bank_credit
364
- 2 adapter(s) checked, 0 finding(s) reported
365
- ```
366
-
367
- The runner exits 1 when anything reports, so it drops straight into CI. It
368
- takes a module path or a directory, and certifies every adapter the module
369
- exports.
370
-
371
- In your own repository, call the function directly and skip the runner:
372
-
373
- ```ts
374
- import { certifyPartnerBankAdapter } from "@hyperscale0/adl";
375
-
376
- import { myAdapters } from "./src/index.js";
377
-
378
- for (const adapter of myAdapters) certifyPartnerBankAdapter(adapter);
379
- ```
380
-
381
- `partnerBankConformanceFindings` returns every finding as
382
- `{ code, path, message }`, where `path` is the dotted location inside your
383
- declaration. `certifyPartnerBankAdapter` throws with all of them listed at
384
- once, so you fix a declaration in one pass rather than one throw at a time.
385
- The codes are append-only: a released code is never renamed or removed, so you
386
- can switch on `PartnerBankConformanceCode` without a stale fallback arm.
387
-
388
- An empty result is the certification. Nothing else is.
389
-
390
- ## 8. Write the manifest without TypeScript
391
-
392
- `spec/manifest.schema.json` is JSON Schema 2020-12 for one adapter
393
- declaration, generated from `src/vocabulary.ts` by `scripts/emit-spec.ts` and
394
- drift-checked by `bun run spec:check`. Generate types, validate a
395
- hand-written manifest, or feed it to an editor.
396
-
397
- The schema is structural. Rules that compare two fields, resolve a time zone,
398
- or match a property key against a value inside it cannot be expressed in JSON
399
- Schema and stay in the validator and the conformance suite.
400
- `conformance/cases.json` says which gate owns each rule, in the `note` field of
401
- every case where they differ.
402
-
403
- ## 9. Hand it over
404
-
405
- An adapter is handed over as source, never as a running service:
406
-
407
- 1. `bun run conformance` reports zero findings.
408
- 2. Your own tests read captured provider bytes and prove the declaration
409
- matches them. `examples/meridian-bank/example.spec.ts` is the pattern:
410
- every status token in a fixture is spelled in the lifecycle vocabulary, the
411
- envelope class is how the fixtures actually read, and the declared charge
412
- narratives match the statement lines.
413
- 3. Open an issue on this repository with the certified declaration and the
414
- conformance output. Credentials are never part of a submission; the
415
- declaration names locators, and the operator fills them in.
416
-
417
- ## Stability
418
-
419
- The package is pre-1.0 and the surface can still change on a minor version
420
- until it is `1.0.0`. Two promises hold now:
421
-
422
- - conformance codes are append-only;
423
- - a value removed from a vocabulary in `src/vocabulary.ts` is a breaking change
424
- and appears in `CHANGELOG.md`.
425
-
426
- ## What is not here
427
-
428
- The declaration carries no timeouts, no retry policy, and no rate limits.
429
- Those belong to the runtime that executes adapters, not to the facts about the
430
- provider, and no field in `ProviderAdapter` accepts them. If your provider's
431
- limits are a fact the runtime must know, open an issue: under Adapter Symmetry
432
- the seam gets added here, publicly, or not at all.
49
+ The conformance command exits 1 when it reports a finding. The schema emitter
50
+ writes the declaration schema and the `BoundaryInstruction` and
51
+ `BoundaryObservation` definitions. Add a case in `conformance/cases.json` when a
52
+ new declaration rule prevents a concrete failure.
package/package.json CHANGED
@@ -1,14 +1,12 @@
1
1
  {
2
2
  "name": "@hyperscale0/adl",
3
- "version": "1.0.0-beta.5",
4
- "description": "The Adapter Declaration Language: provider adapter interfaces, registry validation, and the partner-bank conformance suite.",
3
+ "version": "1.0.0-beta.7",
4
+ "description": "The Adapter Declaration Language: generic evidence, provider declarations and conformance.",
5
5
  "keywords": [
6
6
  "adl",
7
7
  "adapter",
8
- "banking",
9
8
  "conformance",
10
9
  "fintech",
11
- "iso20022",
12
10
  "json-schema",
13
11
  "payments",
14
12
  "provider"
@@ -32,6 +30,10 @@
32
30
  "types": "./dist/index.d.ts",
33
31
  "default": "./dist/index.js"
34
32
  },
33
+ "./boundary-fixture": {
34
+ "types": "./dist/boundary-fixture.d.ts",
35
+ "default": "./dist/boundary-fixture.js"
36
+ },
35
37
  "./spec/manifest.schema.json": "./spec/manifest.schema.json",
36
38
  "./package.json": "./package.json"
37
39
  },
@@ -45,7 +47,6 @@
45
47
  "conformance",
46
48
  "dist",
47
49
  "docs",
48
- "examples",
49
50
  "spec"
50
51
  ],
51
52
  "engines": {
@@ -65,6 +66,10 @@
65
66
  "test": "bun test",
66
67
  "typecheck": "tsc -p tsconfig.json"
67
68
  },
69
+ "dependencies": {
70
+ "@hyperscale0/udl": "4.0.0",
71
+ "zod": "4.6.5"
72
+ },
68
73
  "devDependencies": {
69
74
  "bun-types": "1.4.2",
70
75
  "typescript": "7.0.2"
@@ -75,5 +80,5 @@
75
80
  "Amir Ayub",
76
81
  "Sara AlBakaawi"
77
82
  ],
78
- "gitHead": "a65150e9cc5a0d5506817f0feb9d64ff62b1d964"
83
+ "gitHead": "5fffb99534e07501367844e1453421605ee05566"
79
84
  }