Appearance
What is not built yet
An honest account of the gaps, checked against the code rather than against the design notes. The same principle the product runs on applies to its own documentation: a roadmap that quietly lists shipped work as pending, or omits a known hole, is the same failure as a green tick over an unrun check.
Verified on 2026-08-13, against the code.
The short version
| Gap | Blocked on | Size |
|---|---|---|
| Exposure fields | A product decision | Small build, large value |
| Applicant identity at intake | Nothing | Small |
| Operator-attested VAT check | Nothing | Small |
| Controls output | Exposure fields first | Medium |
| Gates enforcing | An AML policy decision | Small build |
| Four provider checks | Specs and agreements | Blocked |
| Entity and check webhooks | Nothing | Small |
| Arabic is half-done | Nothing | Medium, mechanical |
| Person country scoping | A product decision | Small build |
| MATCH and VMSS connection | Card-scheme membership | Medium |
| Contract signing | Signing by link works; Nafath needs staging and credentials | Medium |
| Mobile number ownership | Nothing | Small |
Exposure fields
Nothing exists. No expected monthly volume, average ticket, delivery lead time, refund policy, recurring-versus-one-off, cross-border acceptance or prior PSP termination.
This is the largest hole in the risk model, and it is a hole of a different kind from the others. Every factor Verity scores today answers "is this merchant who they claim to be?". None answers "how much do we lose if they are not?"
A verified, clean, ten-year-old merchant selling event tickets with ninety-day delivery is a larger exposure to a PSP than an unverified corner shop — and Verity currently scores the corner shop higher.
Delivery lead time deserves singling out: prepayment for future delivery is weeks of chargeback liability, and travel and events are where PSPs die. It is one field.
Self-declared, and that is fine
These are values a merchant supplies, so under provenance they may raise risk freely and can never lower it below neutral. That is the correct treatment — a merchant claiming low volume should not earn a discount for saying so.
Blocked on deciding what to ask a merchant, which is a product decision rather than an engineering one. Four fields — volume, average ticket, delivery lead time, refund policy — would carry most of the value; a perfect form is not required to start.
Applicant identity
Verity records no applicant at all — nothing captures who submitted an application.
This is why the signer_not_verified_signatory gate can only ever report not assessed: there is no identity to match against the registered signatories. The risk design calls signatory identity assurance "the highest-value signal Verity owns", and it is unreachable for want of one intake field.
Capturing an applicant identity is small. Nafath authentication would make it strong, because Nafath proves the human is present rather than that someone knows their details.
VAT registration
No ZATCA lookup API exists — confirmed by research, not assumed. The only lookup is a CAPTCHA-gated human page, and ZATCA's published APIs are Fatoora e-invoicing, which cannot answer "is this merchant VAT-registered". Verity will not automate around a CAPTCHA: that means defeating an access control on a government system, and it breaks the moment they rotate it.
But there is a buildable version nobody has built: an operator-attested check. It records the VAT number, who verified it against the portal, when, and the outcome — the same shape as any other check, so it appears in coverage and expires on a review cycle, while being honest that a human read the page. The certificate can hang off it as an entity document.
The signal is worth having. VAT registration is mandatory above SAR 375,000 of revenue, so a merchant projecting SAR 2M while not registered is a contradiction worth escalating.
Controls output
entity_risk_controls is designed and does not exist.
"Medium risk" tells an underwriter nothing actionable. A PSP's mitigations are financial — rolling reserve percentage, settlement delay, volume cap, per-transaction cap, EDD, re-screening interval, manual review of the first N transactions.
The strongest argument for building it is that recommending controls makes the score falsifiable. You can check afterwards whether the controls were right. A band nobody can check is a band nobody learns from.
Worth building after exposure fields, because controls are largely a function of exposure. A reserve percentage recommended from data that cannot size a loss is a guess wearing a number.
Gates are advisory
Risk::Gates is never consulted by EntityReview. A triggered prohibited-activity gate recommends rejection and blocks nothing. The only hard block remains an unadjudicated sanctions match.
This is a deliberate open decision, and an AML policy call rather than an engineering one. The current direction is the safe default: making a gate enforcing later is straightforward, while making it advisory later would mean unblocking merchants who were already approved under the stricter rule.
Blocked provider checks
Four, each blocked on something engineering cannot obtain:
| Check | Blocked on |
|---|---|
investment_registration (Wathq MoI) | An OpenAPI spec, or one captured response |
power_of_attorney (Wathq) | Same |
vat_registration (ZATCA) | An enterprise agreement — no public API exists at all |
ecommerce_registration (Maroof) | A Maroof API, or confirmation none exists |
Without a contract an adapter would be invented rather than written, which is why these are stopped rather than attempted.
Two further Saudi sources — municipal_licence (Balady) and sector_licence (SFDA, CST) — have unconfirmed availability. The ISIC vocabulary already carries a regulating authority per activity code, which covers much of what those were wanted for.
Entity and check webhooks
Outbound webhooks cover review lifecycle events only. Nothing is emitted when a check completes or fails, or when an entity's risk band changes.
An integrator therefore has to poll to learn that a check finished.
Polling at least works now: the entities endpoint takes an updated_since filter, so a recently changed merchant can be found. The id ordering stays, deliberately — it is unique and immutable, so paging is total, and wanting recent changes is a filter rather than an ordering.
What is still missing is the better answer: an event when a check completes or fails, or when a risk band changes, so nobody has to poll at all.
Arabic is half-done
Verity ships a language switcher and a direction-aware layout, so it presents as bilingual. The chrome, the navigation, the settings screens and the entity page's structure are translated. What an operator reads when something actually happens is largely not.
Counted against the code:
| File | Translated | English literals |
|---|---|---|
app/views/entities/show.html.erb | 48 t() calls | — |
app/helpers/entities_helper.rb | 35 t() calls | ~55 |
app/lib/risk/gates.rb | none | ~50 |
app/models/entity_check.rb | 1 | ~22 |
app/models/person_workspace.rb | none | ~83 |
app/views/people/show.html.erb | none | ~45 |
risk/gates.rb is the one that matters most. It is every sentence explaining why a gate triggered — the reasoning a compliance officer is meant to act on and an auditor is meant to read back. An Arabic operator gets the page in Arabic and the reason in English.
The person workspace is untranslated end to end, including the check readout added on 12 August.
This is mechanical rather than hard: the pattern is established, the locale files exist, and the entity page's first tranche shows the shape. It is listed because a half-translated product is a claim — the switcher says Arabic is supported — and the untranslated half is exactly the half that carries meaning.
Two things that are not mechanical
Arabic AML terminology needs a compliance review rather than a translator's best guess; the words for sanction, freeze, and beneficial owner are regulated language. And identifiers, IBANs and UUIDs need dir="ltr" inside an RTL page or the digits reorder on screen — correct in the places touched so far, unaudited everywhere else.
Person country scoping
The person workspace uses the global permission check, not the country-scoped one, deliberately and with the reason recorded in PeopleController#show_context.
A Person has no country. They are reached through parties on entities that may sit in several, so "which country is this person in" has no answer — and the two readings differ in what they leak. Scope by the entity you arrived from, and the same person shows different evidence depending on the route taken. Scope by any party you may see, and a Bahrain-only operator infers the existence of a Saudi merchant from a person's linked-entity count.
The current behaviour is the conservative reading: a country-scoped operator sees the person without the cross-border evidence, rather than seeing evidence from a country they are not entrusted with.
Country scoping is a pillar of the access model, and this is the one surface that does not honour it. The design is written up in docs/country-scoped-access-design.md; what is missing is the decision, which is a compliance judgement about what a scoped operator may infer, not an engineering one.
Card scheme listings
Standing bars are built and enforcing: a barred national ID, registration number or IBAN blocks approval, and the data model already distinguishes an internal decision from a Mastercard MATCH or Visa VMSS listing.
What does not exist is a connection to either scheme. Listings are recorded by hand.
Access comes with card-scheme membership rather than with a payment-institution licence — Moyasar holds PI licences in Saudi Arabia and Bahrain, with the UAE following, and scheme access is being arranged. Until it lands, a compliance officer enters what an acquirer reports, which is honest about who established the fact.
Two things arrive with a real connection, and the second is easy to overlook:
- Inbound — query before boarding, and import listings as entries
- Outbound — MATCH is not read-only. An acquirer is obliged to add terminated merchants within a set window, so Verity's reason codes would need mapping onto Mastercard's, and lifting an internal bar stops being a purely internal act
Deliberately not built
Not everything absent is a gap.
registry_ownership_assertion (Wathq /v2/owns) — dropped as redundant. It takes no CR parameter, so it only says a person owns something; /v2/related answers the same question per-entity and with the relation type.
Wathq Ministry of Investment and Power of Attorney — deferred by decision as well as by missing specs. The commercial registration and contract payloads already carry parties, ownership percentages, manager authorities and representation rules.
A country risk table and a legal form ranking — these ship empty on purpose. Ranking legal forms is a credit judgement and a country risk table is a compliance artifact; an invented default would be worse than an obviously empty one. See factors and weights.
Contract signing
What exists.
The regulatory registry, at settings/signing: which country can be signed in and by what (SigningCoverage), the clauses a regulator requires in a merchant agreement (Contracts::MandatoryClauses), and the disclosures required before a merchant uses the service (Contracts::DisclosureSets).
Templates with immutable versions. Content lives on a ContractTemplateVersion; a published one cannot be edited, and amending means publishing a new version. Publication is where the registries stop being reference material: Contracts::TemplateValidation refuses a document that omits a mandatory clause, fails to declare a required disclosure, or does not state the fees. Interpolation goes through an allowlist (Contracts::Placeholders) rather than the legacy record.send on a database string.
The contract record, bound to what was shown. A Contract carries the version it was rendered from, a SHA-256 over the exact bytes, and those bytes kept as an attachment. The renderer is deterministic — no clock reading, no unordered iteration — and self-contained, with no external reference of any kind, checked rather than intended. Voiding keeps the document.
The authorization, for Saudi Arabia. Providers::Nafath::Client implements the MFA request, status and JWK endpoints from the Elm integration guide. SigningAttempt records each attempt against the document digest that was on screen, with consent timestamped by the server rather than the browser, and keeps refusal, expiry and failure as three distinct endings. Providers::Nafath::TokenVerifier checks algorithm, key id, signature, issuer, audience, expiry, not-before and the transaction — every one of which the legacy verifier omitted. The callback at /callbacks/nafath is idempotent and refuses what it cannot verify instead of returning 200.
What does not exist.
A staging deployment. Nafath cannot be exercised locally. Elm posts the callback to us, so it needs a reachable HTTPS endpoint, and the guide requires that endpoint to be hosted inside Saudi Arabia. Testing means a deployed staging environment with the callback URL registered with Elm.
Credentials. NAFATH_APP_ID, NAFATH_APP_KEY and NAFATH_SERVICE_TYPE are unset, so SigningCoverage.operable?("SA") is false and no attempt can begin. The refusal names them rather than claiming the integration is missing. NAFATH_SERVICE_PROVIDER_NAME is also needed — it is the aud claim, and verification refuses rather than skipping the check when it is absent.
A service type from Elm. The service field decides what the person sees in the app and whether biometrics are required, and Elm publishes a closed list of about twenty — every one a banking action. None means "sign a merchant agreement." Which one has been provisioned for this is a question for Elm, so it is configuration and an unlisted value is refused rather than sent.
A page that shows the merchant the document. Built. See signing by link: a tokenised page shows the archived document and records an acceptance against its digest. It is where a Nafath challenge number would be shown, and it is what stops consented_at recording a consent nobody gave against bytes the signer never saw.
That page also signs on its own, without a provider — a materially weaker attestation, recorded as one. It does not remove the need for Nafath where SAMA requires it.
A template to sign. Built for Saudi Arabia. The merchant agreement is transcribed, bilingual, and moves between environments as a committed file (rake signing:import). There is an authoring UI with a preview.
A compliant Bahraini document is still outstanding — and it could not be signed there anyway, because Bahrain needs eKey with BENEFIT/OneSign and neither is integrated.
Provider identity. contract_identity.sa / .bh are unset in credentials, so no contract can be presented at all — a licence number left blank prints as a gap in a sentence that reads as filled in.
Bahrain. eKey and BENEFIT/OneSign remain unintegrated, and no commercial terms exist.
The problem the design has to solve. Nafath attests that a person approved a transaction. It does not attest which document they approved — nothing about the document is sent to it. In the legacy system that gap is compounded by templates that can be edited after signing and no archived copy of what was shown, so "what did this merchant agree to?" has no answer even for contracts already signed. Because SAMA mandates Nafath, this cannot be fixed by choosing a different Saudi provider. Verity has to bind the document itself: record a digest of the exact bytes presented, keep them, and be precise that the provider attests the person's authorization while Verity attests what was presented. Those are two different claims.
Known defects in the legacy system, which the replacement must not inherit: signed_date falls back to Time.now when no attempt completed; attribution uses the caller-supplied name rather than what Nafath returned; the callback JWT is signature-checked only, with no exp/nbf/iss/aud/ alg/jti/sub validation; the callback swallows errors and returns 200; the polling fallback raises NameError and is therefore dead; every non-completion collapses to rejected; consent timestamps are authored by client JavaScript; reset! destroys evidence unconditionally; PDF layout depends on a live CDN; production forwards live Nafath callbacks to staging; and the fee schedule never appears in the contract document at all.
Bahrain is a different stack, not a different endpoint. Nafath's single role splits in two: eKey (iGA, bound to CPR) for identity, and BENEFIT/OneSign — the first TRA-licensed Trust Service Provider — for the qualified signature. Neither is integrated and no commercial terms exist. Law 54/2018 is tiered, so a court assesses whether the method used was trusted; the CBB's PSP module says nothing about signing form. Retention is at least 5 years, and records must be accessible from inside Bahrain, which constrains where signed contracts may be stored.
Migration is a real work item, not a footnote. The new system replaces the old completely, so contracts already signed under the weaker scheme become Verity's to account for. Their evidence gap cannot be closed retroactively — nobody archived the bytes. The honest options are to re-sign under the new scheme or to mark them explicitly as signed under a weaker attestation. Presenting them as equivalent would be the same failure as a green tick over an unrun check.
Open before building further: whether the CBB expects a qualified signature specifically for merchant contracts or accepts a lower tier, the authoritative rule number for the double-swiping clause and the complete mandatory-clause list for card acquirers, the SAMA clause and disclosure requirements for Saudi Arabia (currently unestablished, and recorded as such), and BENEFIT/Beyon commercial and API terms.
Mobile number ownership
Elm publishes a mobile-number ownership match, priced at 1.50 SAR per call on the approved package: given a national ID, it confirms the mobile number belongs to that person. Verity does not call it.
It is worth having because every identifier Verity currently checks is a government record, and a phone number is the one contact detail an operator can act on and cannot verify today. It is also the cheapest identity signal on the rate card.
Not blocked on anything but a decision about where it sits — a party-level follow-up check alongside the other Elm services, most likely, which means it needs a CheckCoverage entry (Saudi only) and a rate-card row.
Suggested order
- Exposure fields — unblocked, highest value, needs a product decision on which fields
- Applicant identity at intake — unlocks a gate that is currently decorative
- Operator-attested VAT check — small, and closes a real signal without fighting a CAPTCHA
- Controls output — after exposure, or it recommends reserves from nothing
Arabic runs alongside rather than in this queue. It is mechanical, it touches files nothing else here touches, and it is the one item that can be worked in parallel without colliding — starting with risk/gates.rb, because gate reasoning is the highest-value English left in the product.