@dossier-wiki/os-sdk 0.7.0 → 1.4.0

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/README.md CHANGED
@@ -22,7 +22,7 @@ const os = new DossiersOSAPIService({
22
22
 
23
23
  const customer = await os.individuals.get('cus_123', { isExternal: true });
24
24
 
25
- await os.individuals.identificationDocuments.add(customer.id, {
25
+ await os.individuals.identificationDocuments.create(customer.id, {
26
26
  type: 'passport',
27
27
  number: 'X1234567',
28
28
  country: 'USA',
@@ -42,25 +42,341 @@ The service composes resource namespaces — call them off the root client:
42
42
  | Namespace | Covers |
43
43
  | --- | --- |
44
44
  | `os.auth` | Invitations, session tokens, API keys, admin password reset |
45
+ | `os.sessions` | Read and manage transaction-monitoring sessions resolved during ingestion |
46
+ | `os.channelEndpoints` | Read and manage POS terminal, page, API client, and other endpoint identities resolved during ingestion |
45
47
  | `os.individuals` | Individual customers + nested `identificationDocuments`, `faceImages` |
46
48
  | `os.entities` | Entity customers + nested `stakeholders` |
47
- | `os.customers` | Cross-cutting per-customer: `notes`, `files`, `screening`, `riskRating`, `riskQuestionnaires`, `expectedActivity`, `devices` |
49
+ | `os.customers` | Cross-cutting per-customer: `notes`, `files`, `screening`, `riskRating`, `riskAssessments`, `expectedActivity`, `devices`, `assignment` (assign / unassign / assignMany) |
50
+ | `os.teams` | Domain teams: list/get/create/update/archive/delete, plus `addMembers` / `removeMembers`. Teams carry catalogue-governed `team.tags`, and `list({ tags })` filters on them |
48
51
  | `os.accounts` | Per-customer accounts, including `freeze` / `unfreeze` |
49
52
  | `os.counterparties` | Counterparties + nested `riskAssessments`, `files` |
50
- | `os.devices` | Direct device lookup |
51
- | `os.transactions` | Ingest transactions, patch terminal status |
52
- | `os.events` | Event ingestion |
53
+ | `os.partyRelationships` | Define, list, create, and end audited named relationships between Customers and Counterparties |
54
+ | `os.devices` | Safe canonical Device lookup and audited merge; raw fingerprint/IP identity material is never returned |
55
+ | `os.paymentInstruments` | Explicit Payment Instrument CRUD and temporal Account/Customer relationships |
56
+ | `os.transactions` | Ingest transactions with canonical Payment Instrument, channel endpoint, device, and session context (the response carries the resolved `transaction`; optionally with catalogue-governed `tags`, e.g. `['top-up']`, readable by rules as `txn.tags` and filterable inside aggregates); patch terminal status; `get(reference)` to read one back (by your reference, or `{ by: 'id' }` for the internal UUID) |
57
+ | `os.transactionAttempts` | Ingest, filter, paginate, and read failed, blocked, or unknown payment-operation attempts |
58
+ | `os.behaviorSettings` | Attempt retention and ingestion coverage |
59
+ | `os.events` | Event ingestion — every event names the single `resource` it is about |
60
+ | `os.riskSignals` | Add time-decaying risk evidence to a resource; read a resource's signals and their aggregate effective weight |
61
+ | `os.employees` | People acting inside the institution's ecosystem — list/get/create/update |
62
+ | `os.branches` | The institution's own physical or organizational units — list/get/create/update |
53
63
  | `os.exchangeRates` | Push exchange rates |
54
- | `os.config` | `rules`, `constants`, `products`, `currencies`, `eventDefinitions`, `webhooks`, `tagCatalogue`, `sync`, plus org-wide `reset` |
64
+ | `os.states` | Read and atomically set, increment, or decrement resource state; read immutable change history |
65
+ | `os.alerts` | Raise alerts by hand (`create` — free-form, or against an optional custom alert type; org-scoped `externalRequestId` idempotency), read any alert (`get`), edit markdown `notes` (`updateNotes`), link records (`resources.list/add/remove`), plus `comments` and `attemptEvidence`. Needs the `alerts:read` / `alerts:write` API-key permissions |
66
+ | `os.config` | `rules` (incl. `rules.backtest` — dry-run simulation over historical or synthetic transactions), `constants`, `states`, `alertWorkflows` (one per alert type — `SCREENING`, `RULE_TRIGGER`, `CUSTOM`: list/get/save), `assignmentPolicies` (read-only legacy archive), `products`, `currencies`, `eventDefinitions`, `customAlertTypes` (org-defined alert types with a typed-field schema), `webhooks`, `tagCatalogue`, `sync`, plus org-wide `reset` |
55
67
  | `os.setup` | Org setup status + initialization |
56
68
  | `os.settings` | Sync snapshots + drift |
69
+ | `os.reports` | On-demand PDF reports: `alert`, `customerProfile`, `counterpartyProfile`, `resourceProfile` — each resolves to the PDF bytes (`Uint8Array`); needs the `report:read` grant |
70
+ | `os.customLists` | Custom screening lists: list/entry CRUD, ≤500-row bulk import (per-row rejection + `externalRef` upsert), enable/disable, archive/unarchive. Enabled LIVE lists are checked by every screening run alongside external providers. |
57
71
 
58
72
  See [docs/REFERENCE.md](./docs/REFERENCE.md) for the full method list and endpoint mapping.
59
73
 
74
+ ### Transaction attempts
75
+
76
+ Use a transaction attempt when an operation did not become a transaction in
77
+ the source/core system. Attempts are idempotent behavioural records; ingesting
78
+ one does not run rules and does not contribute to transaction financial totals.
79
+
80
+ ```ts
81
+ await os.transactionAttempts.ingest({
82
+ attemptId: 'auth-20260828-0042',
83
+ timestamp: '2026-08-28T10:30:00Z',
84
+ operation: 'CARD_AUTHORIZATION',
85
+ outcome: {
86
+ type: 'FAILED',
87
+ code: 'DO_NOT_HONOUR',
88
+ retryable: true,
89
+ final: true,
90
+ },
91
+ amount: { amountMinor: 12500, currency: 'FIAT:USD', precision: 2 },
92
+ paymentInstrument: {
93
+ type: 'CARD',
94
+ iin: '424242',
95
+ last4: '4242',
96
+ // A fingerprint lookup miss is retained as an unresolved attempt;
97
+ // it never creates a Payment Instrument.
98
+ fingerprint: { type: 'CARD_PAN', value: '4242424242424242' },
99
+ },
100
+ });
101
+
102
+ const failedAuthorizations = await os.transactionAttempts.list({
103
+ outcomes: ['FAILED', 'BLOCKED'],
104
+ operation: 'CARD_AUTHORIZATION',
105
+ limit: 50,
106
+ });
107
+
108
+ await os.behaviorSettings.update({
109
+ attemptRetentionDays: 180,
110
+ attemptCoverage: 'ALL',
111
+ });
112
+ ```
113
+
114
+ Raw fingerprint values are accepted only in the typed `fingerprint` field and
115
+ are protected in memory for lookup; they are never retained in the attempt.
116
+ Other snapshot objects must contain safe descriptors only.
117
+
118
+ For a resolved transaction, `paymentInstrument` uses the same identity shape
119
+ and creates the Payment Instrument on a deterministic lookup miss.
120
+
121
+ ```ts
122
+ await os.transactions.ingest({
123
+ transactionId: 'txn-20260828-0043',
124
+ timestamp: '2026-08-28T10:31:00Z',
125
+ transaction,
126
+ paymentInstrument: {
127
+ type: 'CARD',
128
+ fingerprint: { type: 'CARD_PAN', value: '4242424242424242' },
129
+ },
130
+ device: {
131
+ fingerprint: 'visitor-91f2',
132
+ ipSessions: [{ ipAddress: '203.0.113.8', seenAt: '2026-08-28T10:31:00Z' }],
133
+ location: { latitude: 6.9271, longitude: 79.8612, accuracyMeters: 25, source: 'GEOLOCATION' },
134
+ },
135
+ });
136
+ ```
137
+
138
+ ## Events and Risk Signals
139
+
140
+ ### Every event names its resource
141
+
142
+ An Event is about exactly one resource, named explicitly:
143
+
144
+ ```ts
145
+ const { id, resource, riskSignalIds } = await os.events.ingest({
146
+ eventId: 'evt-20260829-teller-001',
147
+ type: 'security.pin_reset',
148
+ timestamp: '2026-08-29T02:14:33.000Z',
149
+ resource: { type: 'EMPLOYEE', id: 'emp_9f21' },
150
+ fields: [{ key: 'channel', type: 'TEXT', value: 'BRANCH_TERMINAL' }],
151
+ });
152
+ ```
153
+
154
+ `resource.type` is one of `CUSTOMER`, `ACCOUNT`, `COUNTERPARTY`, `EMPLOYEE`,
155
+ `BRANCH`, `PAYMENT_INSTRUMENT` — the closed set of attachable resources. There
156
+ is no `INSTRUMENT` spelling; `PAYMENT_INSTRUMENT` is the only instrument
157
+ concept.
158
+
159
+ A bare top-level `customerId` is still accepted as the **legacy spelling** of
160
+ `resource: { type: 'CUSTOMER', id }`, so existing integrations keep working:
161
+
162
+ ```ts
163
+ // legacy — still accepted, equivalent to resource: { type: 'CUSTOMER', id }
164
+ await os.events.ingest({ type: 'customer.failed_login', timestamp, customerId: 'cus_123' });
165
+ ```
166
+
167
+ `resource` wins when both are sent, and alongside a non-customer `resource` the
168
+ `customerId` is context that customer-scoped event aggregates read. An event
169
+ naming neither is rejected with 400. Prefer `resource` in new integrations —
170
+ it is the only way to attach an event to an account, counterparty, employee,
171
+ branch, or payment instrument.
172
+
173
+ The response echoes the resolved `resource` and the `riskSignalIds` the event
174
+ emitted. `deviceId` and `sessionId` may still qualify an event but never
175
+ substitute for its primary resource.
176
+
177
+ ### Risk Signals — evidence, not alerts
178
+
179
+ A Risk Signal is a piece of time-decaying evidence attached to one resource.
180
+ Adding one creates **no** alert, notification, assignment or task, and does not
181
+ change the resource's Risk Rating. Rules read the aggregate and decide.
182
+
183
+ ```ts
184
+ await os.riskSignals.create({
185
+ externalId: 'case-note:2026-08-29:customer-2847:1',
186
+ type: 'analyst.concerning_account_behaviour',
187
+ resourceType: 'CUSTOMER',
188
+ resourceId: 'cus_2847',
189
+ weight: 35,
190
+ halfLife: 'P14D',
191
+ note: 'Third unexplained cash deposit this month.',
192
+ });
193
+
194
+ const signals = await os.riskSignals.list({
195
+ resourceType: 'CUSTOMER',
196
+ resourceId: 'cus_2847',
197
+ asOf: '2026-09-12T00:00:00Z',
198
+ type: ['analyst.concerning_account_behaviour'],
199
+ });
200
+
201
+ signals.aggregateEffectiveWeight; // decayed sum at `asOf`
202
+ ```
203
+
204
+ The signal records the strength it had at `occurredAt` and how fast that
205
+ strength halves:
206
+
207
+ ```
208
+ effectiveWeight = weight * 2 ^ (-ageSeconds / halfLifeSeconds)
209
+ ```
210
+
211
+ So weight 40 with half-life `P7D` reads 20 after seven days and 10 after
212
+ fourteen. The aggregate is computed on read as of `asOf` (defaulting to server
213
+ time) — it is never a cached score column, so a historical `asOf` returns what
214
+ the resource looked like at that moment. `type` / `sourceType` filter which
215
+ records contribute; they never change how far a contributing signal has
216
+ decayed.
217
+
218
+ `externalId` makes retries safe. The server always sets the source to `MANUAL`
219
+ and captures the authenticated actor — a caller cannot claim a signal came from
220
+ an Event or a Rule. Half-lives are positive ISO 8601 durations; calendar units
221
+ (Y/M) are rejected because they have no fixed length.
222
+
223
+ ### Emitting signals from event definitions
224
+
225
+ An event definition can declare the signals every valid occurrence emits. This
226
+ is tenant configuration — an integration pushing an event cannot add, change,
227
+ or suppress an effect.
228
+
229
+ ```ts
230
+ await os.config.eventDefinitions.create({
231
+ name: 'security.pin_reset',
232
+ category: 'AUTHENTICATION',
233
+ riskSignalEffects: [
234
+ { type: 'authentication.pin_reset', weight: 20, halfLife: 'P3D' },
235
+ ],
236
+ });
237
+ ```
238
+
239
+ `target` defaults to `EVENT_RESOURCE` — the signal attaches to the event's own
240
+ primary resource. The event and its signals are written in one transaction, and
241
+ the event's idempotency covers them: replaying an `eventId` echoes the original
242
+ `riskSignalIds` rather than emitting a second set.
243
+
244
+ ### Emitting signals from rules
245
+
246
+ `ADD_RISK_SIGNAL` records evidence when a rule fires:
247
+
248
+ ```ts
249
+ await os.config.rules.create({
250
+ name: 'Rapid counterparty fan-out',
251
+ subject: 'TRANSACTION',
252
+ severity: 'MEDIUM',
253
+ actions: [
254
+ {
255
+ type: 'ADD_RISK_SIGNAL',
256
+ signalType: 'behaviour.counterparty_fan_out',
257
+ weight: 25,
258
+ halfLife: 'P7D',
259
+ target: { resource: 'counterparty' },
260
+ },
261
+ ],
262
+ });
263
+ ```
264
+
265
+ `target.resource` is a closed selector over the evaluation, never a field
266
+ expression: `customer`, `account`, `counterparty`, or `paymentInstrument`.
267
+ `employee` and `branch` are **not** accepted — no evaluation envelope resolves
268
+ either slot yet, so such a rule could never fire; both are rejected at save
269
+ time with a 400 explaining why.
270
+
271
+ Unlike other action types, `ADD_RISK_SIGNAL` may appear more than once on a
272
+ rule (one rule can record several kinds of evidence, or the same evidence
273
+ against several resources). What is rejected is the same `signalType` on the
274
+ same `target.resource` twice — that would double-count in every aggregate.
275
+
276
+ ### Employees and Branches
277
+
278
+ Employees and Branches are monitored domain data describing the institution's
279
+ own operating structure, and are attachable resources for Events and Risk
280
+ Signals. An Employee is deliberately **not** a Dossiers organization member: an
281
+ Employee need not have a Dossiers login, and an Employee id is never an
282
+ org-member id. `orgMemberId` is an optional link for the case where the two
283
+ happen to be the same person.
284
+
285
+ ```ts
286
+ const branch = await os.branches.create({ name: 'Colombo Fort', externalBranchId: 'BR-014', country: 'LK' });
287
+
288
+ const teller = await os.employees.create({
289
+ fullName: 'Aruni Fernando',
290
+ externalEmployeeId: 'STAFF-10982',
291
+ jobTitle: 'Branch Teller',
292
+ branchId: branch.id,
293
+ });
294
+
295
+ await os.riskSignals.create({
296
+ type: 'staff.override_pattern',
297
+ resourceType: 'EMPLOYEE',
298
+ resourceId: teller.id,
299
+ weight: 15,
300
+ halfLife: 'P30D',
301
+ });
302
+ ```
303
+
304
+ `externalEmployeeId` / `externalBranchId` are unique within the organization
305
+ and let ingestion resolve a record without a Dossiers UUID round-trip.
306
+
307
+ ## Customer creation (BREAKING)
308
+
309
+ `individuals.create` and `entities.create` now use the same canonical inputs as
310
+ GraphQL. The SDK exports `CreateIndividualInput` and `CreateEntityInput`;
311
+ the former `*Dto` names remain deprecated aliases.
312
+
313
+ ```ts
314
+ await os.individuals.create({
315
+ externalId: 'USR-10042',
316
+ fullName: 'Ahmed Al-Rashidi',
317
+ status: 'ONBOARDING',
318
+ aliases: ['Ahmed Rashidi'],
319
+ nationality: ['KWT'],
320
+ countryOfResidence: ['USA'],
321
+ emails: [{ value: 'ahmed@example.com', primary: true }],
322
+ phones: [{ value: '+1-555-0142', primary: true }],
323
+ addresses: [{ category: 'HOME', line1: '4521 Maple Drive', country: 'USA' }],
324
+ });
325
+
326
+ await os.entities.create({
327
+ externalId: 'ENT-30021',
328
+ legalName: 'Meridian Holdings Ltd',
329
+ tradingNames: ['Meridian Holdings'],
330
+ websites: ['https://meridian.example'],
331
+ locations: [{ category: 'REGISTERED', line1: '42 Makarios III Avenue', country: 'CYP' }],
332
+ stakeholders: [{ name: 'Viktor Petrov', role: 'UBO', ownershipPercentage: 45 }],
333
+ });
334
+ ```
335
+
336
+ ### Synchronous initial screening
337
+
338
+ By default `create` queues the customer's INITIAL screening and returns at
339
+ once. Pass `{ screening: 'sync' }` to run it before the call returns; the
340
+ outcome and every match it surfaced arrive under `screening`, so an onboarding
341
+ flow can branch without polling `screening.history` or waiting for a webhook.
342
+
343
+ ```ts
344
+ const created = await os.individuals.create(
345
+ { externalId: 'USR-10043', fullName: 'Ahmed Al-Rashidi', nationality: ['KWT'] },
346
+ { screening: 'sync' },
347
+ );
348
+
349
+ if (created.screening?.status === 'FAILED') {
350
+ // No provider configured or every provider failed. The customer exists;
351
+ // rerun with os.customers.screening.trigger(created.id).
352
+ } else if (created.screening?.alertId) {
353
+ // Matches found: created.screening.matches holds each hit and its
354
+ // disposition; auto-dismissed hits arrive as FALSE_POSITIVE.
355
+ } else {
356
+ // Clear run — no matches, no alert.
357
+ }
358
+ ```
359
+
360
+ The same option works on `os.entities.create`. Screening failure never rolls
361
+ back the customer, and the response without the option is unchanged.
362
+
363
+ Migration:
364
+
365
+ - Individual names are one required `fullName`; remove `firstName`/`lastName`.
366
+ - Prefer `emails`/`phones` contact-entry arrays over the scalar `email`/`phone`.
367
+ - `nationality` and `countryOfResidence` are arrays of alpha-3 country codes.
368
+ - Entities use `tradingNames[]` and `locations[]` (not `tradingName`/`addresses`).
369
+ - On an identification document, prefer `country` over `issuingCountry`.
370
+ - The older spellings — scalar `email`/`phone`, `issuingCountry`, create-time
371
+ `createdAt`, entity `addresses`, and entity `parentEntityId` — are still
372
+ accepted as deprecated aliases; the canonical field wins when both are sent.
373
+ - Use `status` for the initial customer lifecycle state. New records default to
374
+ `ONBOARDING`; `monitoringStatus` and `riskRating` are not create inputs.
375
+
60
376
  ## Tenant sync — config-as-code (BREAKING in v0.7.0)
61
377
 
62
378
  `os.config.sync` drives declarative tenant config (products, constants, event
63
- definitions, webhooks, rules, currencies). You build a `SyncConfig` and the
379
+ definitions, webhooks, rules, currencies, and tenant behaviour settings). You build a `SyncConfig` and the
64
380
  **server** validates, diffs, and applies it — clients hold no diff/apply logic.
65
381
  This backs the `dossiers-sync` CLI.
66
382
 
@@ -75,6 +391,10 @@ const result = await os.config.sync.apply(config); // validate → di
75
391
  const live = await os.config.sync.export({ includeUnsynced: true }); // → SyncConfig
76
392
  ```
77
393
 
394
+ `behaviorSettings` is an optional, singleton section. Omit it to leave the
395
+ target tenant unmanaged; supplied fields update partially. Exports and sync
396
+ snapshots include the full effective policy.
397
+
78
398
  **Breaking:** `os.settings.getSyncExport()` (opaque zip) was **removed** — use
79
399
  `os.config.sync.export()`, which returns a typed `SyncConfig`. The `SyncExportDto`
80
400
  type was removed accordingly. Snapshot/drift methods on `os.settings` are
@@ -96,15 +416,21 @@ await os.config.rules.create({
96
416
  });
97
417
  ```
98
418
 
99
- A per-org **tag catalogue** defines the available vocabulary per field-key and,
100
- when a field-key's enforcement is `strict`, rejects off-catalogue writes (422):
419
+ A per-org **tag catalogue** defines the available vocabulary per field-key. The
420
+ catalogue is **closed**: a write whose value is not in the catalogue is rejected,
421
+ always. There is no enforcement setting to relax — register the tag first.
101
422
 
102
423
  ```ts
103
424
  await os.config.tagCatalogue.create({ fieldKey: 'rule.tags', label: 'AML' });
104
- await os.config.tagCatalogue.setEnforcement({ fieldKey: 'rule.tags', enforcement: 'strict' });
105
425
  const entries = await os.config.tagCatalogue.list({ fieldKey: 'rule.tags' });
426
+ const { tokens } = await os.config.tagCatalogue.palette(); // valid `color` values
106
427
  ```
107
428
 
429
+ > **Breaking change:** `setEnforcement()` / `enforcement()` and the
430
+ > `TagCatalogueEnforcement`, `TagFieldEnforcement`, and `SetTagEnforcementInput`
431
+ > types are removed. Tag vocabulary is now managed exclusively through catalogue
432
+ > entries and tenant config. Ad-hoc, per-record detail belongs in notes.
433
+
108
434
  ## Typed Fields (v0.2.0+)
109
435
 
110
436
  > **v0.3.0 breaking change:** `IngestEventDto.data` has been removed in
@@ -115,8 +441,10 @@ const entries = await os.config.tagCatalogue.list({ fieldKey: 'rule.tags' });
115
441
 
116
442
  Accounts, products, and event ingestion accept the platform's Typed Fields
117
443
  primitive — a typed key/value bag (`fields`) plus an untyped escape hatch
118
- (`otherProperties`). Seven types: `TEXT`, `NUMBER`, `BOOLEAN`, `DATE`, `SET`,
119
- `CURRENCY`, `MONETARY`.
444
+ (`otherProperties`). Collection fields are explicit: `LIST` is open-ended,
445
+ while `SINGLE_SELECT` and `MULTI_SELECT` are constrained by the declared
446
+ `options` on the parent schema (`Product.accountFieldConfig` or
447
+ `EventDefinition.properties`). Legacy `SET` remains supported but is deprecated.
120
448
 
121
449
  ```ts
122
450
  import type { TypedFieldValueInput } from '@dossier-wiki/os-sdk';
@@ -130,7 +458,7 @@ await os.accounts.create('cus_123', {
130
458
  { key: 'rate', type: 'NUMBER', value: 0.045 },
131
459
  { key: 'opened', type: 'DATE', value: '2026-03-01' },
132
460
  { key: 'limit', type: 'MONETARY', value: '5000.00@FIAT:USD' }, // denominated literal
133
- { key: 'tags', type: 'SET', value: ['retail', 'priority'] },
461
+ { key: 'aliases', type: 'LIST', value: ['everyday', 'current'] },
134
462
  ],
135
463
  otherProperties: [
136
464
  { key: 'sourceImportBatch', value: { id: 'batch-42', source: 'csv' } },
@@ -138,10 +466,11 @@ await os.accounts.create('cus_123', {
138
466
  });
139
467
  ```
140
468
 
141
- When a product declares a `typedAccountFieldConfig` schema (Tier A), the
469
+ When a product declares an `accountFieldConfig` template (Tier A), the
142
470
  runtime validator enforces type match, required keys, and unknown-key
143
471
  rejection. Otherwise the same input shape passes through self-declaring
144
472
  validation (Tier B) — same semantics as `txn.metadata`.
473
+ `typedAccountFieldConfig` remains as a deprecated API alias.
145
474
 
146
475
  Updates apply with **merge semantics**: each supplied key overwrites; absent
147
476
  keys preserved. Pass an empty array to clear.
@@ -151,6 +480,25 @@ pre-resolved `amountMinor` + `currency` + `precision`.
151
480
 
152
481
  Spec: [`docs/specs/typed-fields/README.md`](../../docs/specs/typed-fields/README.md).
153
482
 
483
+ ### Party relationships
484
+
485
+ ```ts
486
+ const employer = await os.partyRelationships.createType({
487
+ key: 'employer',
488
+ label: 'Employer',
489
+ direction: 'DIRECTED',
490
+ });
491
+
492
+ await os.partyRelationships.create({
493
+ typeId: employer.id,
494
+ sourceType: 'CUSTOMER',
495
+ sourceId: customerId,
496
+ targetType: 'COUNTERPARTY',
497
+ targetId: employerCounterpartyId,
498
+ description: 'Declared during onboarding',
499
+ });
500
+ ```
501
+
154
502
  ## Configuration
155
503
 
156
504
  ```ts
@@ -179,3 +527,42 @@ try {
179
527
  ## License
180
528
 
181
529
  MIT
530
+
531
+ Screening-result dispositions stay `PENDING | CONFIRMED | FALSE_POSITIVE`. A match a workflow reviewer marked inconclusive is still undecided, so the REST API reports and filters it as `PENDING`; the inconclusive state itself is visible only in the app and GraphQL.
532
+
533
+ ### Alert workflows
534
+
535
+ The application uses the same disposition cards, reason field, and optional catalogue tags for closing Simple alerts and resolving workflow alerts. Workflow resolution still requires a disposition and respects the pinned stage’s comment requirement. The GraphQL `transitionAlertReview` input accepts optional `tags: [{ value, label }]` for `RESOLVE`; tags are validated against the tenant’s `alert.dispositionTags` catalogue, stored with the disposition, and cleared on reopening.
536
+
537
+ New workflow saves require one shared **Resolved** stage. Set its `next` to a router or an automatic `action` node for follow-ups. Routers can combine `resolution.outcome` with alert, customer, screening or rule fields; after resolution, `$end` means no further action. Action nodes support `CREATE_TASK` (linked to the alert) and `NOTIFY` (in-app notification), with current alert owners or specific members/teams as recipients. Follow-ups run atomically with closure using the saved verdict and leave the alert resolved. Reopen remains a separate review action. Reopening and resolving again can run follow-ups again; retrying the same transition cannot duplicate them. Existing alerts retain their pinned definition.
538
+
539
+ Each organization has one workflow per alert type: `SCREENING` and `RULE_TRIGGER`. Initial assignment and later handovers are stage settings; routers provide ordered conditions and a fallback. Router branches may have an optional `name`, and the router may have a `fallbackName` (nonblank, up to 200 characters). These names appear in the workflow graph and routing history; they do not change branch IDs, order, or destinations. Omitting them preserves the numbered condition and fallback labels. Read the current revision before saving:
540
+
541
+ ```ts
542
+ const workflow = await client.config.alertWorkflows.get('SCREENING');
543
+ await client.config.alertWorkflows.save('SCREENING', {
544
+ id: workflow.id,
545
+ name: workflow.name,
546
+ expectedRevision: workflow.revision,
547
+ definition: workflow.draft,
548
+ publish: false, // true publishes for future alerts
549
+ });
550
+ ```
551
+
552
+ Requires `config:read` / `config:write`. Stale saves return HTTP 409. Existing alerts keep their published version. `config.assignmentPolicies` remains available to inspect archived rules, but its write methods now return HTTP 410. The `assignmentPolicies` tenant-sync section is retired; use `config.alertWorkflows` instead. Existing assignment rules are converted automatically to the initial workflow graph.
553
+
554
+ Set `definition.restrictEditingToOwners` to `true` to make alerts read-only for everyone except currently assigned people and members of active assigned teams, including administrators. This covers assignments, match decisions, alert comments and attachments, task links, and every stage transition (including return and reopen), overriding the progression-only policy. Omitting it or setting it to `false` preserves existing editing permissions. The policy is pinned when the alert is created; publishing does not change older alerts or Simple alerts. Linked tasks retain their own editing permissions. Organization admins and owners can explicitly add themselves through the application’s **Assign to me** action (`takeOverAlert` in GraphQL). This administrative recovery preserves all other assignees, status and pinned workflow settings, and is recorded in activity history; it also supports resolved alerts so an administrator can assume ownership before reopening.
555
+
556
+ Set `definition.restrictProgressionToOwners` to `true` to limit forward progression (including resolution) to currently assigned people and members of active assigned teams. Administrators must also be assigned. Set it to `false` to allow other organization members with `alert:update` permission to progress alerts. Stage requirements still apply. Return and reopen permissions are unchanged. Omitting the field preserves the original current-reviewers-or-administrators behavior for older API clients and stored definitions. The builder defaults the policy to enabled when editing; publishing affects new alerts only.
557
+
558
+ For Screening workflows, `definition.matchAssessment` sets shared `{ note, attachment }` booleans for `CONFIRMED`, `INCONCLUSIVE`, and `FALSE_POSITIVE`. These apply to every Initial Review stage and to Secondary Review when `definition.allowSecondaryReviewMatchOverrides` is `true`; set each stage's legacy `screening` field to `null`. Enabling overrides requires a shared `matchAssessment` policy. Initial Review reviewers can replace individual match verdicts directly without resetting to pending. Authorized secondary reviewers can then also replace a match verdict directly, with the required evidence and an audit record of the previous verdict. Existing reviewer and editing permissions still apply; resolved alerts remain read-only for match decisions. Overrides default to off, and non-screening workflows must omit both settings. Older definitions that omit `matchAssessment` retain their stage-specific requirements. The builder's General policy tab consolidates older stage requirements into a shared draft, preserving the strictest requirement where they differ. Publishing never updates existing alerts.
559
+
560
+ ### Linking comments to screening matches
561
+
562
+ Attach screening matches explicitly with `links: { matchIds: ["<match UUID>"] }` when creating or updating an alert comment. A comment can attach multiple matches belonging to that alert. Comment responses include `matchReferences` with each match's ID, category and saved display name. Editing the body preserves attachments unless `links` is supplied; `links: { matchIds: [] }` detaches all matches. Mentions in the body do not create associations. Existing saved associations and historical match labels are preserved.
563
+
564
+ ### Alert experience
565
+
566
+ Use `client.config.alertWorkflows.getExperience()` to read the tenant's choice and revision. `saveExperience({ experience: 'SIMPLE' | 'DEFINED', expectedRevision })` changes only newly created alerts, across both supported alert types. Simple uses direct assignment and Open/Closed resolution; Defined uses the published workflow for the alert type. Existing alerts retain their process and policy version. Saved definitions are retained in Simple mode, and publishing a definition does not enable Defined mode. The initial default is Simple. Select Defined explicitly to enable the published workflow for new alerts. Existing saved choices are preserved when upgrading.
567
+
568
+ Published workflow history is available through `config.alertWorkflows.versions(alertType)` (newest first, with publisher and date). `config.alertWorkflows.restore(alertType, { version, expectedRevision })` copies a version into the draft without changing the published workflow or existing alerts. Review the returned draft, then call `save` with `publish: true` and its updated revision to publish it as a new version. Old versions with no recoverable definition return `definition: null` and cannot be restored. Both operations are organization-scoped and require `config:read` / `config:write` respectively.
@@ -0,0 +1,115 @@
1
+ /** Shared JSON wire contract. The backend validates this structure before saving. */
2
+ export type WorkflowAlertType = "SCREENING" | "RULE_TRIGGER" | "CUSTOM";
3
+ export type WorkflowStageGroup = "INITIAL_REVIEW" | "SECONDARY_REVIEW" | "RESOLVED";
4
+ export interface WorkflowCondition {
5
+ field: string;
6
+ operator: string;
7
+ value: string;
8
+ /** Compare to another available field instead of a literal. */
9
+ valueField?: string;
10
+ whenMissing?: string;
11
+ }
12
+ export interface WorkflowConditions {
13
+ logic: "AND" | "OR";
14
+ conditions: WorkflowCondition[];
15
+ }
16
+ export interface WorkflowAssignment {
17
+ mode: "KEEP" | "HANDOVER" | "ADD";
18
+ source: "SPECIFIC" | "CUSTOMER_MEMBERS" | "CUSTOMER_TEAMS" | "CUSTOMER_OWNERS" | "ORIGINAL";
19
+ userIds: string[];
20
+ teamIds: string[];
21
+ }
22
+ export interface ScreeningDecisionPolicy {
23
+ note: boolean;
24
+ attachment: boolean;
25
+ }
26
+ export type MatchAssessmentPolicy = Record<"CONFIRMED" | "FALSE_POSITIVE" | "INCONCLUSIVE", ScreeningDecisionPolicy>;
27
+ export interface WorkflowStage {
28
+ id: string;
29
+ kind: "stage";
30
+ name: string;
31
+ group: WorkflowStageGroup;
32
+ next: string | null;
33
+ actionLabel: string;
34
+ assignment: WorkflowAssignment;
35
+ conditions: WorkflowConditions;
36
+ requireComment: boolean;
37
+ requiredReviews: ("CUSTOMER_RISK" | "COUNTERPARTY_RISK")[];
38
+ /** Legacy stage policy. New definitions use matchAssessment and set this to null. */
39
+ screening: MatchAssessmentPolicy | null;
40
+ }
41
+ export interface WorkflowRouter {
42
+ id: string;
43
+ kind: "router";
44
+ name: string;
45
+ branches: {
46
+ id: string;
47
+ /** Optional display name (1–200 characters). Omission uses the condition number. */
48
+ name?: string;
49
+ conditions: WorkflowConditions;
50
+ target: string;
51
+ }[];
52
+ fallback: string;
53
+ /** Optional display name (1–200 characters). Omission displays Fallback. */
54
+ fallbackName?: string;
55
+ }
56
+ /** Automatic follow-up after resolution. It never changes the alert's stage or owners. */
57
+ export interface WorkflowAction {
58
+ id: string;
59
+ kind: "action";
60
+ name: string;
61
+ action: "CREATE_TASK" | "NOTIFY";
62
+ next: string | null;
63
+ title: string;
64
+ body: string;
65
+ recipients: {
66
+ source: "ALERT_OWNERS" | "SPECIFIC";
67
+ userIds: string[];
68
+ teamIds: string[];
69
+ };
70
+ }
71
+ export type WorkflowNode = WorkflowStage | WorkflowRouter | WorkflowAction;
72
+ export interface WorkflowDefinition {
73
+ schemaVersion: 1;
74
+ alertType: WorkflowAlertType;
75
+ /** Forward progression only. True requires a current assigned person or active-team member, including admins; false allows any organization member with alert:update. Omission preserves the original reviewers-or-admins policy. */
76
+ restrictProgressionToOwners?: boolean;
77
+ /** All alert editing requires current ownership, including administrators. Organization admins/owners may explicitly take over by adding themselves as an assignee. Overrides progression permissions. Omission/false preserves existing editing permissions. */
78
+ restrictEditingToOwners?: boolean;
79
+ /** Screening-only requirements shared by Initial Review and enabled Secondary Review overrides. Omission preserves legacy stage policies. */
80
+ matchAssessment?: MatchAssessmentPolicy;
81
+ /** Screening only. Allow reviewers to override match verdicts in Secondary Review using matchAssessment requirements. Omission/false keeps decisions in Initial Review. */
82
+ allowSecondaryReviewMatchOverrides?: boolean;
83
+ entry: string;
84
+ /** Exactly one Resolved stage. Its optional next step runs automatically after closure.
85
+ * Routers and actions after resolution may use "$end" to finish without another action. */
86
+ nodes: WorkflowNode[];
87
+ }
88
+ export interface WorkflowPublication extends WorkflowDefinition {
89
+ publication?: {
90
+ name: string;
91
+ };
92
+ }
93
+ export interface WorkflowField {
94
+ key: string;
95
+ label: string;
96
+ type: "enum" | "number" | "boolean" | "set" | "string";
97
+ values?: string[];
98
+ operators: string[];
99
+ creation: boolean;
100
+ }
101
+ export interface WorkflowExecution {
102
+ version: 2;
103
+ workflowId: string;
104
+ workflowName: string;
105
+ publishedVersion: number;
106
+ definition: WorkflowDefinition;
107
+ stage: WorkflowStageGroup;
108
+ nodeId: string;
109
+ revision: number;
110
+ history: string[];
111
+ enteredAt: string;
112
+ initialUserIds: string[];
113
+ initialTeamIds: string[];
114
+ }
115
+ //# sourceMappingURL=alert-workflow-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alert-workflow-types.d.ts","sourceRoot":"","sources":["../src/alert-workflow-types.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AACxE,MAAM,MAAM,kBAAkB,GAC1B,gBAAgB,GAChB,kBAAkB,GAClB,UAAU,CAAC;AACf,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AACD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,UAAU,EAAE,iBAAiB,EAAE,CAAC;CACjC;AACD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,KAAK,CAAC;IAClC,MAAM,EACF,UAAU,GACV,kBAAkB,GAClB,gBAAgB,GAChB,iBAAiB,GACjB,UAAU,CAAC;IACf,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AACD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,EAAE,OAAO,CAAC;CACrB;AACD,MAAM,MAAM,qBAAqB,GAAG,MAAM,CACxC,WAAW,GAAG,gBAAgB,GAAG,cAAc,EAC/C,uBAAuB,CACxB,CAAC;AACF,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,kBAAkB,CAAC;IAC1B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,kBAAkB,CAAC;IAC/B,UAAU,EAAE,kBAAkB,CAAC;IAC/B,cAAc,EAAE,OAAO,CAAC;IACxB,eAAe,EAAE,CAAC,eAAe,GAAG,mBAAmB,CAAC,EAAE,CAAC;IAC3D,qFAAqF;IACrF,SAAS,EAAE,qBAAqB,GAAG,IAAI,CAAC;CACzC;AACD,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,oFAAoF;QACpF,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,UAAU,EAAE,kBAAkB,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC;KAChB,EAAE,CAAC;IACJ,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AACD,0FAA0F;AAC1F,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,aAAa,GAAG,QAAQ,CAAC;IACjC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE;QACV,MAAM,EAAE,cAAc,GAAG,UAAU,CAAC;QACpC,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;CACH;AACD,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,cAAc,GAAG,cAAc,CAAC;AAC3E,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,qOAAqO;IACrO,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC,iQAAiQ;IACjQ,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,6IAA6I;IAC7I,eAAe,CAAC,EAAE,qBAAqB,CAAC;IACxC,2KAA2K;IAC3K,kCAAkC,CAAC,EAAE,OAAO,CAAC;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd;+FAC2F;IAC3F,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AACD,MAAM,WAAW,mBAAoB,SAAQ,kBAAkB;IAC7D,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC;AACD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,CAAC;IACvD,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;CACnB;AACD,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,kBAAkB,CAAC;IAC/B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=alert-workflow-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alert-workflow-types.js","sourceRoot":"","sources":["../src/alert-workflow-types.ts"],"names":[],"mappings":""}