Appearance
Merchant KYB onboarding
The development seed as a policy pack, with a model added: the kyb-onboarding workflow, the rule set it scores with and every rule that set names, the kyb-basic collection flow and the blocked_countries list, plus an optional description of the business that a model reads, so that rules, a model and a reviewer decide together. Without the description it is the policy the first-decision tutorial walks through, in a file. Pack: examples/policies/merchant-kyb-onboarding/.
This pack is generated from packages/db/src/seed-data.ts by pnpm policy:pack:seed (scripts/policy-pack-from-seed.mjs), with one addition the seed does not have: the description field and the model's steps, which packages/db/src/seed-pack.ts adds. A test (packages/db/src/seed-pack.test.ts) fails when the pack drifts from the two, and checks that the seed itself calls no app, so the development tenant, the smoke and the tutorial need no install. Unlike the other packs it deliberately shares the seed's keys: importing it into the seeded development tenant re-creates the seeded definitions as new draft versions (version 2 of each, kyb-basic with the description and kyb-onboarding with the model's steps), and --publish makes those the live ones. A run whose applicant leaves the description empty then takes the seed's own paths and calls no model. Into an empty tenant it is the quickest way to get the tutorial's policy.
A pattern for expressing an onboarding policy, not compliance guidance: the supported countries, weights and bands are the seed's illustrative values. The model reads the applicant's own words about the business and nothing else; registry, sanctions and ownership checks stay with the rules and the vendor steps you add.
The decision it automates
Whether a business applying for an account is approved, rejected or handed to a reviewer, from what the applicant enters: where the business is, who owns it and, when the applicant describes it, whether the description says plainly what the business sells and to whom.
Data expected
- A subject of kind
merchantwithdata.country(the country the business was registered with the platform under). - The
kyb-basiccollection flow: business details (legalName,country,entityType,website, and the optionalbusinessDescription, 1,000 characters at most: what the business sells, and to whom), the beneficial owner for companies (uboName,uboOwnershipPct,uboCountry; sole proprietors,sole_proprietor, skip the step), a confirmation and an optionalidentityDocumentfile, which stays with the submission for the reviewer. - An install named
chatgpt(below), for the runs with a description.
Steps and rules
| Step | Type | Why |
|---|---|---|
collect | wait_for_collection | The run pauses until the applicant submits kyb-basic; the data lands under submission.*. |
rules | evaluate_rules | The kyb-onboarding rule set. |
route | branch | rules.outcome == reject decides at once, without the model; a businessDescription goes to read; anything else to triage. |
read | call_app | chatgpt's decide: the question below, answered under ai. |
triage | branch | rules.outcome == manual_review opens a case; ai.answers.clear.probability of 0.8 or more decides; a description the model is not sure of opens a case; without a description the rules decide. |
review | create_case | kyb_review, priority high. |
decide | emit_decision | From the rules, or from the reviewer's decision when there was a case. |
The rule set kyb-onboarding sums weights and maps the score through bands 0-40 approve, 40-90 manual_review, 90-100 reject:
| Rule | Type | Severity, weight | Fails when |
|---|---|---|---|
country_allowed | comparison | block, 100 | subject.country is not US, CA or DE |
submitted_country_allowed | comparison | warn, 50 | the country entered in the flow is not US, CA or DE |
country_blocklisted | list_lookup | block, 100 | the entered country is on blocked_countries (seeded with KP and IR) |
high_ownership_foreign_ubo | expression | warn, 40 | an owner holds 75% or more from a country other than the business (hasPath guards sole proprietors) |
With these weights a blocklisted country (100 + 50) clamps to 100 and rejects, and a foreign owner alone (40) lands in review. A business the rules approve goes through at once when the description is empty, as in the seed; when it is given, only when the model is at least 80% sure it says what the business sells and to whom. A rejection never waits for the model.
The model step
read asks one yes-or-no question about one text, which its stateTemplate writes from the business details (Asking for decisions):
text
A business applied for a merchant account.
Legal name: <<legalName>>
Entity type: <<entityType>>
Country: <<country>>
Website: <<website>>
In the applicant's own words, what the business sells and to whom:
<<businessDescription>>The website is optional, so the step's input gives website the literal (none given), which the applicant's answer replaces when there is one.
| Question | Type | Asks |
|---|---|---|
clear | yes or no, threshold 0.8 | Does the description say specifically what the business sells (its products or services) and who its customers are? |
Its criteria spell out both answers. Yes: "It names concrete products or services and the kind of customers (consumers, other businesses, a sector), and nothing in the other details contradicts it." No: "It is vague (such as trading, consulting or various goods), leaves out what is sold or to whom, or is contradicted by the legal name or the website."
The question is about the clarity of the applicant's own words, not a registry, sanctions or legal check. A yes can only let through a business the rules already approve, and the description is the applicant's own text, in which they can write anything: the question's instructions tell the model to read it as text and never follow instructions in it (Prompt injection), and the most a crafted description wins is what the seed does without a model, an approve-band business approved without a person.
What the vendor sees
Through the install, OpenAI receives the question with its criteria and the state: the legal name, the entity type, the country, the website and the description. Nothing else reaches it: not the owner's name, share or country, the rule results or the identity document (models read text and JSON only). The sample install answers decisions through OpenAI's Responses API, which it calls with storing off; an install switched to OpenAI's Decisions API (decideEngine: native) sends the same content under OpenAI's terms for that API (Data protection).
The chatgpt install
The pack declares the install its step calls, "installs": { "chatgpt": "chatgpt" }: an install named chatgpt of the platform's chatgpt app, ChatGPT (OpenAI API). An install is never part of a pack. After the import, the console's import result installs chatgpt in one click, with a preset configuration (an OpenAI connection through the Responses API, the console's first suggested model, OpenAI's hosts approved) and a sample key in place of yours; store your OpenAI API key on the install's page (Configuration › Keys › openaiApiKey). Through the API, create it as Installing a model app shows, named chatgpt.
Until you replace the sample key, and whenever the model call fails (an outage that outlasts the retries, an exhausted quota, a refusal), the run fails at read and no case opens: every run with a description that the rules do not reject. Rejections and runs without a description never call the model. OpenAI's answer shows in the invocation record and under the run's app calls.
Every model app answers the same decide, so an install of claude, gemini, jev or openai-compatible named chatgpt serves the pack as well.
What a reviewer sees
A kyb_review case with the submission, the owner's identity document when one was uploaded, and every rule result with its score. When the applicant described the business, the run's context holds the model's answer under ai (the value, probability and confidence of clear) and Vendor checks lists the call; a no means the reviewer asks what the business sells and to whom. Request info sends the form back, and the new answers go through the checks the run made again (the rules, and the model when it read the first answers) before the case returns. The reviewer's decision becomes the run's decision (source: manual), with the rule results attached.
How to adapt it
Change the country lists and the bands in the rule set. Add fields to kyb-basic and rules that read them under submission.*. Make the description required, and drop the two submission.businessDescription exists conditions, when every applicant should describe the business; move the model's 0.8 in triage and in the question's threshold together.
To screen the business through a vendor, install the platform's http-api for it and add a call_app step between collect and rules, with a rule in the kyb-onboarding set that reads the step's output: a screening step on submission.legalName and submission.country with outputKey: "sanctions" pairs with a comparison rule on sanctions.body.hit (Adding a vendor step shows both). To have a vendor verify the owner's document, switch documents on in its http-api install and set up the install's webhook: a start step that sends the document, behind a branch on submission.identityDocument.fileId, pairs with an expression rule on the vendor's answer (Documents shows those steps).
Run it
bash
pnpm policy:import examples/policies/merchant-kyb-onboarding --publishThen create the chatgpt install and store your key (above). The check runs each fixture against the model stub (scripts/ai-stub.mjs), whose answer a cue word in the description picks ([[stub:high]]):
expect.json:Acme Kitchen Supply LLCfrom the US, which imports commercial kitchen equipment from Germany for restaurants and cafes in Texas and Oklahoma, owned 100% by an owner in Germany. The model answers yes (0.95), but the foreign owner scores 40, so akyb_reviewcase opens whatever the model says; the check approves it and the run completes with a manualapprove.expect-described.json:Bluebonnet Bakery LLC, a bakery in Austin, Texas, that sells to local coffee shops and grocery stores, owned in the US. Every rule passes, the model answers yes (0.95) and the run completes with an automatedapprove, without a case.expect-no-description.json: a sole proprietor in the US who leaves the description empty. The run takes the seed's own path without calling the model, every rule passes and it completes with an automatedapprove.