Appearance
August 2026
The first month. Verity went from a risk engine and a check catalogue to something that onboards a merchant end to end: country scoping, Bahrain, contracts and signing.
31 August 2026
A Saudi merchant can register as a freelancer
Onboarding accepted a commercial registration and nothing else. A merchant trading on a freelance certificate (وثيقة العمل الحر) was either refused at intake, because the number does not match the unified-CR format, or entered with the format check switched off — which left no record of which kind of document the number even was.
Intake now asks. The two are validated differently, verified by different registries, and say different things about who is contracting; a system that infers which one it holds from the shape of a number will infer wrong eventually.
A freelance certificate is verified through Lean Tech, against the Ministry's records. The verification needs the certificate number and the holder's national ID together — neither alone identifies the document — so intake collects both, and a freelancer without a national ID is refused rather than created as a registration nothing can ever check.
See Freelancers.
Substituted, not added
A freelancer runs the certificate check where the account configured a commercial registration, not in addition to it. Sending a certificate number to the CR registry returns not-found, and that reads as "this registration failed" — a statement about the merchant — when the truth is we queried the wrong registry.
A template's wording can travel between environments
A template lived in one account's database and no screen moved it. Authoring the Saudi agreement in development and then needing it in staging, and later production, was a retyping job.
rake signing:export and signing:import move it as a file meant to be committed — so the wording travels with the repository, and the review that reads a code change reads a wording change.
The import recomputes the content digest from the file before writing anything: it either reproduces byte-identical wording or refuses. Not "probably the same contract" — the same digest or nothing.
A contract says who is asking
A signatory opened a document that said nothing about who wanted their signature. It now carries a letterhead — logo and issuer above the title, registered detail at the foot so a printed page separated from its first still says whose it is.
Supplied, never known. The signing module renders whatever letterhead it is handed and has no opinion about whose belongs there. The issuer is archived on the envelope rather than looked up when the document is read, because a name or a registered number can change and a contract has to keep the one it was presented under.
Settings that were missing on deployed environments
Six permissions existed only in db/seeds.rb, and seeds run only against a database being created. On any environment whose database already existed, the rows were absent — so nobody held them, and three settings screens plus screening adjudication were simply not there. The feature read as undeployed.
They are created and granted by migration now. See deploying a change.
Countries can be activated outside development
Settings → Country defaults lists the countries an account has and lets you edit each one, but it cannot add one — a country with no configuration never appears there. The only thing that created one ran in development only, so staging needed a console.
rake countries:activate COUNTRY=BH does it anywhere, and states what activating does not give you. See country defaults.
30 August 2026
The storefront scan cannot be pointed at our own network
The scan fetches a URL the merchant supplies and reports what came back, including which configured prohibited terms appeared in the text — an arbitrary substring oracle over the response body. Nothing restricted where it could connect.
It followed redirects and re-checked only that the target was http(s), so this needed no account at all: a merchant submits a genuine storefront whose server answers 302 to the cloud metadata address, and the scan follows it.
Every outbound request now resolves first and judges the address, refuses anything that is not public, and pins the connection to the address it checked. See what a scan may reach.
A signatory can use the browser they have
The contract signing page inherited the operator application's modern-browser requirement, so iOS Safari 15 and Chrome 100 were served a bare 406 — on the page where somebody signs a contract, with nothing saying why.
Refusing our own staff a stale browser is a policy. Refusing a customer's signatory is a lost contract.
HTTPS is configurable rather than commented out
A signing link is a bearer credential in a URL that an external signatory opens. FORCE_SSL=true now turns on HSTS, secure cookies, and https link generation behind a terminating proxy.
26 August 2026
A bilingual contract is set side by side
The Arabic and English of a contract were stacked, one after the other. The source document the legal team wrote sets its articles in two columns, and that is what a signatory comparing a clause against its translation expects to find.
The governing language leads, which in an Arabic-governed document puts it in the right-hand column. The columns re-align at every clause, so one long article cannot push the two apart for the rest of the document. Sections holding a fee table stay full width — a table cannot be halved.
What the columns exposed
English text inside an Arabic-governed document was drawing flush right, ragged left, and so was the English column of every fee table. text-align inherits: declaring a block's direction is not the same as declaring its alignment, and the second was being inherited from the language beside it.
Invisible while the two languages were stacked. Putting them side by side is what surfaced it.
19 August 2026
A contract can be signed by link
A merchant can be sent a link, read the agreement, and sign it. Nafath needs credentials and a callback hosted inside Saudi Arabia; this needs neither.
It is a weaker attestation, and is recorded as one everywhere it appears:
Accepted online — the document kept here is the one that was on screen, and the person who accepted it was not identity-verified.
That sentence is composed by the model, not by each screen, so nothing can print a bare "Signed" for a click. What it proves is that the archived document is the one that was displayed and that somebody holding the link agreed to it; what it does not prove is who that was. The signatory is told the same thing before they press the button.
The link expires after 30 days, and withdrawing a contract kills every link outstanding for it — which is the remedy if one goes astray. An expired link, a withdrawn agreement and a token that never existed all give the same response, so a stale link cannot be used to find out which merchants have live contracts.
See signing by link.
Templates have a preview
Every contract template can now be seen as a document, from the editor and from each version row. It renders through the same code that produces the bytes a signature is bound to, so it is what a signer receives rather than an approximation of it.
Values appear as conspicuous stand-ins in «guillemets». A preview filled with plausible-looking data is a document somebody screenshots and sends to a merchant; every value being visibly not real is the point. The prepared-on date is the version's own rather than today's, so the same wording always previews the same.
A draft that cannot publish still previews — otherwise you could not see what you are writing.
It found two real bugs immediately, both invisible without it.
English inside an Arabic-governed document was inheriting dir="rtl" from the document root, so the "First Party". rendered as ."the "First Party. Every language block now states its own direction.
And a declared language with no text was silently omitted, so the Saudi agreement — which declares Arabic as its governing language and has none yet — rendered as a complete-looking English contract with <html lang="ar"> on top. A missing declared language now renders a visible gap, marked in that language. An absence that renders as a finished document is the failure this product exists to refuse, and it had got into the renderer.
18 August 2026
Signing is a module of its own now
Signing was built inside Verity and had grown Verity's assumptions. A contract belonged to an entity, the placeholders read a merchant's columns, and publication was refused unless a country's clause registry was satisfied.
Two of those were merely limiting. The third was wrong: contract wording is written by a legal team, so a system refusing their document over a missing clause was Verity taking on work it does not own — and it could not have done that job correctly anyway.
The module now knows a document and a signatory, and nothing else. No merchants, no countries, no registries. Verity supplies values and says which provider to ask; the bridge is one file (Entities::MerchantAgreement) and the module never loads it. An invariant walks the module's files and fails if one names anything from Verity's domain, because a boundary maintained by intention decays on the first convenient afternoon.
What survived unchanged is everything that makes a signature mean something: immutable published versions, the digest over the exact bytes shown, the archived document, deterministic self-contained rendering, the no-send interpolation allowlist, and the token verification.
Contract templates, for any purpose
New screen: Settings → Contract templates. As many documents as you like — a merchant agreement, a data processing addendum, a settlement amendment — each declaring the values it needs filled in. There is no fixed vocabulary of merchant fields anywhere, which is what makes that possible.
Publication now refuses three mechanical problems only: a value used but not declared, a table used inside a sentence, and a document with no sections. Nothing checks what a document says.
Creating a template opens its first draft. A draft saves in any state; publishing freezes it; amending opens a new draft from the current wording. A published version can be neither edited nor discarded, because a signature may point at it.
One bug worth recording: a browser textarea submits CRLF, which broke paragraph splitting — a table value on its own line looked as though it sat inside a sentence, so a correct document reported itself unpublishable. Line endings are now normalised on save, which also matters for the digest: identical wording typed on Windows and on macOS has to produce the same content.
Documents are bilingual
A signed document can be written in Arabic and English, with one of them declared as governing — for a Saudi contract, ordinarily Arabic. Both languages live in the same document, stacked with the governing text first, each with its own direction. Arabic sets right to left, and nothing is fetched to render it.
A document that claims a language and has no text in it cannot be published. That is the point of declaring languages rather than inferring them: a half-translated document would otherwise publish quietly, and a merchant reading the Arabic would get a document with a clause missing from their side.
Values can differ per language — a merchant's Arabic name is not its English one, and a registry returns both. Supplied in one language only, a value is used in both, because falling back beats printing a gap.
Signing providers replaces signing requirements
The old screen mixed two subjects. What remains is technical and still ours — which provider obtains a signature in each country, whether it is built, and whether it is configured. What a document must contain is on the template, written by whoever owns that wording.
17 August 2026
Contract signing: the document a merchant agreed to is now recorded
Verity can prepare and archive a merchant contract, and ask Nafath for an authorization against it. Neither runs in production yet — credentials are unset and there is no signer-facing screen — but the part that decides whether the result means anything is built.
What claim changed. Nafath attests that a person approved a transaction. Its token carries aud, nbf, transId, iss, exp, iat and status, and nothing about a document. The system Verity replaces had the same provider and did not record the binding, so for contracts already signed, "what did this merchant agree to?" had no answer — the template was mutable, no rendering was kept, and signed_date fell back to the current time when no attempt had completed.
A contract now carries three things together: an immutable template version, a SHA-256 over the exact bytes rendered, and those bytes kept. Any two of the three leave a hole. The provider's claim and Verity's claim are recorded separately, because "Nafath confirms this merchant signed this contract" is a sentence neither party said.
Contract templates, with immutable versions
Content lives on versions, and a published version can never be edited. A signature points at one, so if it could change, "what did this merchant agree to?" would have a different answer every time somebody fixed a typo.
Publication was briefly gated on a per-country clause and disclosure registry. That was withdrawn the following day — see 18 August — because contract wording belongs to whoever writes contracts, not to Verity.
Provider calls record what they cost, and rates are now editable
Every provider call records its cost at the moment it happens. An unknown price is recorded as nil, never zero — Focal, Wathq GOSI, Wathq related registrations, Elm digital ID and Sijilat are not priced, and a total treating them as free would understate every merchant that used one.
A failed Watheq inquiry still bills 0.25 SAR, so cost is recorded on failures too. A reused screening made no new call and costs nothing, which is a different fact from "we do not know".
Rates were a constant needing a deploy to change. They are now editable per account at Provider rates, and setting one records a new rate from a date rather than editing the old — a run already priced keeps its amount, so overwriting the rate would make that stored number unexplainable.
The entity page gained a Cost of checks section. The caveat travels with the total: "4 of 11 provider calls have no published rate, so this total is a floor and not the whole cost."
Saudi-only party checks are no longer offered on a Bahraini merchant
A Bahraini party card offered six follow-up checks and could run one. Three rendered as live buttons the resolver would refuse on click; the other two said Identity — needs date of birth, sending an operator to find a date that would change nothing, because Elm does not operate in Bahrain at any date of birth.
That label was the worse of the two: true, and misleading. The missing input is not why the check cannot run. Checks with no provider in the country are now omitted rather than disabled, and the boundary is named once per card. Screening stays — a watchlist does not stop at a border.
Four existing tests had asserted the old behaviour on entities built in Kuwait, where those Elm checks could never have run either. They now sit in Saudi Arabia, where the behaviour they cover is real.
16 August 2026
Nested foreign parents no longer masquerade as merchants
Nesting a foreign owner (the Cayman parent on a Bahraini CR) created a side effect: the synthetic parent entity showed up in the entities list, and its country turned up in the country filter — so a single Cayman owner put a fabricated EXT- row among the merchants and "Cayman Islands" in the filter, in the web index and the API alike.
A synthetic external parent is not a merchant: it has a fabricated registration number, no provider coverage, and exists only so a real merchant's ownership can point at it. Both index scopes now exclude it (Entity.onboardable). It stays reachable by drilling in from the merchant it owns — only the merchant lists and the filter drop it.
(The absence of a Bahrain filter before this was not a bug: the filter lists only countries that have a merchant, and none existed until one was created.)
Bahrain is a configured, usable country in development
The development seed now activates Bahrain alongside Saudi Arabia for the dev account, so country scoping and both registries — Wathq for Saudi, Sijilat for Bahrain — can be exercised against real configuration rather than hand-built rows. Each country's intake checks are derived from provider coverage, so a country can never be seeded requiring a check that has no provider there.
Creating a Bahraini merchant now runs its commercial registration through Sijilat end to end: the merchant's name and status come back, the signatory becomes a person, and a foreign corporate owner nests as a parent entity.
A foreign corporate owner is now a nested parent entity
Sijilat records a Bahraini merchant's corporate owner but gives it no registration number — and for CR 170905 the owner is a Cayman Islands company, which no registry we can reach will confirm anyway. Rather than leave the ownership graph empty, the corporate owner is now nested as a parent_entity, the same way a Saudi parent from Wathq already is.
Because there is no CR to key it on, the parent is stored under a synthetic identifier — a stable hash of its name and jurisdiction, prefixed EXT- so it can never be mistaken for a real registration number. It lives as a draft in its own country (Cayman Islands, seeded for exactly this), and that country has no commercial-registration coverage, so the synthetic key can never be run against a live registry. Signatories, which Sijilat does identify by CPR, become people with an authorized-signatory relationship as before.
A partner only becomes a parent entity when Sijilat marks it corporate ("Other Company"); a person partner keeps their real identifier and is never turned into a synthetic organization. A parent in a jurisdiction we have not mapped stays evidence rather than being filed under an invented country.
The chain still stops at the foreign parent — Cayman's registry is pay-per-search and, since its 2024 regime, does not expose shareholders publicly — so ubo_chain_unresolvable reads not assessed, not clear.
Bahrain merchants can be registry-verified — through Sijilat
Commercial registration now runs for Bahrain, through Sijilat (the MOIC registry), alongside Wathq for Saudi Arabia. It is the same commercial_registration check; the merchant's country decides which registry answers, and the coverage rule shipped two days ago is what makes that safe — a Bahraini merchant is no longer sent to the Saudi registry.
What Sijilat gives, and what it does not:
- Both names in one response. Unlike Wathq, the Arabic and English trade names arrive together — no second billed request.
- Status, mapped conservatively. Only
ACTIVEwith the registry's own green light reads as valid. Liquidated, sequestered, sold, and struck-off all block; a status we have never seen blocks too. A registry that returns no registration records a failed check, never a clean one — Sijilat answers every lookup with HTTP 200 and calls a not-found "Success", so the check reads the shape of the answer, not the word. - Signatories become people; owners do not. Sijilat gives signatories a CPR, so they resolve to a person. Shareholders and establishment owners arrive with a name but no identifier, so they are kept as evidence rather than asserted as verified parties — the same rule every provider follows.
A Bahraini CPR gets its own identifier type, never national_id. A CPR held by a Saudi national — which the registry data shows happens — would otherwise have been read as needing a Hijri date and handed to a Saudi service that has no record of it.
Built on an undocumented endpoint
Sijilat publishes no API contract; this reads the JSON its public search page calls, with keys taken from that page. The response shape is captured as fixtures and guarded by tests, so a change on their side surfaces as a failure rather than as silently wrong data.
See Country defaults.
14 August 2026
A national ID is not always a Saudi national ID
Verity decided which calendar a person's date of birth belongs in from their identifier type alone: national_id meant Hijri. That held only because every person in the system had arrived from a Saudi registry, where National ID means a citizen and everyone else gets Resident ID, Passport or GCC ID.
A Bahraini national ID is still a national ID. No Saudi service will ever be asked about its holder, so their date of birth is Gregorian — and under the old rule Verity would have demanded a Hijri date they do not have, then reported the perfectly good Gregorian one their own registry supplied as being in the wrong calendar.
The calendar now comes from the provider that would be asked: a national ID and Saudi nationality means Hijri, everything else Gregorian. Nationality is matched in whichever shape a provider sends it — Wathq's numeric NIC code, an ISO code, or a name.
Where nationality is unknown the answer stays Hijri, deliberately. It is the safer of the two wrong answers: guessing Gregorian for a citizen would let a Gregorian date satisfy the check, put the identity buttons live, and fail — the "looks usable but is not" state fixed on the person page two days ago. Guessing Hijri only leaves the check blocked.
No existing person changes calendar. This is groundwork for the second registry rather than a reclassification of Saudi data — but it has to land before that registry, not after.
See Dates of birth and calendars.
A check is only offered where it can actually run
Verity has fourteen operator checks and, until now, exactly one of them knew what country it was in. The rest would run anywhere — so a merchant outside Saudi Arabia had its registration number sent to Wathq, the Saudi registry. It came back not-found and the check failed, reported as "commercial registration failed": a statement about the merchant, when the truth was that we have no registry for their country.
The country-defaults screen compounded it by offering the identical Saudi menu everywhere. Setting up a second country invited an administrator to require Elm identity verification there, and nothing said no until every merchant failed.
Coverage is now declared once and read by everything that needs it — both menus, the resolver, and the refusal message. The screen names what cannot run and why:
6 checks cannot run in Bahrain. No provider covers Bahrain for these. They are not disabled — there is nothing to call.
Saudi Arabia keeps all fourteen. A second country gets the five that genuinely travel: organization and individual screening, portfolio linkage, website content, and domain intelligence. A check absent from the coverage map raises rather than defaulting to available, because the default is what made this silent in the first place.
See Country defaults.
The registry was telling us more than we were listening to
Measured against 5232 captured commercial registration responses, Verity was discarding fields the registry returns on every call. Each of these is now normalized, because each can change a decision:
| Field | Present in | Why it matters |
|---|---|---|
| In liquidation | 100% | A company winding up cannot take on new payment obligations |
| E-commerce licensed | 100% | For a payment processor, close to the whole question |
| Declared capital and currency | 100% | Capital against expected volume is a real underwriting signal |
| Branch, and its parent CR | 100% / 11% | Branch-to-parent linkage Verity was reconstructing from nothing |
| Status confirmation date | 100% | "Active" confirmed three years ago is a weaker claim than the same word from last week |
| Licence issuer | 12% | Names the authority when the activity is licensed |
Capital was the clearest case of a pattern this codebase has corrected once already: it was being fetched and parked in provider_metadata, where an operator could open it but no risk factor was permitted to read it — exactly where ISIC activity codes sat for months.
A merchant in liquidation is now a rejecting gate. It is independent of registration status on purpose: a company in liquidation still holds an active CR, because winding up is a process rather than a cancellation, so the registration gate reads Clear for precisely the merchant this catches.
Every one of these is tri-state. A response that never mentions liquidation has not said the merchant is solvent, so the field stays empty and the gate reads not assessed — false is the flattering reading of silence.
Arabic trade names: why they are not here yet
Wathq can return them — confirmed against the live API, language=ar gives شركة اليسر للاجارة والتمويل where English gives Al Yusr Lil Ijarah Wal Tamweel Company. It is not shipped, and the reason is worth recording so nobody re-derives it.
A commercial registration carries the trade name exactly once, in whichever language the request asked for. There is no field holding the other one — checked across 5232 captured responses, and no field anywhere in a Latin-named response carries the Arabic company name. So two languages means two requests, and Wathq bills per request: on a thousand merchants that is a second invoice for the same data.
language also localises everything else. status.name becomes نشط and entityType.name becomes شركة, and the adapter matches the English words — so an Arabic response would resolve every merchant to invalid_status, which feeds hard_blocked? and blocks approval across the board. Silently, with the check reporting success.
The way out is the numeric ids that sit beside every localized string, which do not move with the language. Wathq publishes the vocabularies at /commercial-registration/lookup/{status,entityType,companyForm} — three calls, once, cached forever, exactly as Isic::VocabularySync already does for activities. With those in place the request can switch to Arabic on the single call we already pay for, and no decision changes.
An id map was written and then removed
Mapping the ids from observed data alone was tried and reverted: the captured corpus and this repository's own fixtures disagree about what the numbers mean — corpus says id 3 is Deleted, the fixtures say Expired. Getting a registry status wrong is not a display bug, so the enumeration has to come from Wathq rather than from inference.
A retried check kept blocking approval, and a failed screening read as clear
Two defects in one mechanism, pointing in opposite directions. A retry creates a new record rather than updating the one it retried, so every reader has to decide which row is current — and the ones that did not decide split evenly between the two possible mistakes.
Reported: a check that failed and was then re-run successfully still blocked approval. Not a display fault — the blockers are enforced when a decision is submitted, so the merchant was unapprovable in the page and by direct API call alike, with a good result sitting beside the stale failure. The same failure could also appear twice under What's blocking a decision, as two separate things to resolve for one problem.
Found while fixing it, and worse: a screening run that FAILED counted as evidence that screening had happened. The sanctions gate asks "has anybody looked?" and counted every run regardless of outcome, so a provider outage produced a Clear on the most decision-critical signal in a file. A gate rewritten once already to stop reporting clear before screening ran was still doing it one level down. It now reads not assessed.
And the mirror image, which had been masked: the missing-evidence list counted any historical success, so a check that had since failed reported nothing missing. It was only blocked at all because the first defect over-blocked. Fixing either one alone would have moved the bug rather than removed it, which is why all of it landed together.
Supersession is decided by row order, never by evidence age. A reused result deliberately keeps its original fetch timestamp so it cannot claim to have been fetched just now — which means a reused success legitimately carries an older timestamp than the failure it replaces, and ordering on that timestamp ranked the stale failure first.
An invariant now walks the check registry in both directions. A check with no run at all remains outstanding: collapsing to "latest" must never turn never ran into a pass.
Merchants are reviewed again on a cycle, instead of when somebody remembers
entities.last_reviewed_at had been in the schema for months and nothing read it. Periodic reviews could be created and worked fine — what did not exist was anything that opened one when a merchant came due, so the cycle depended on a person remembering. The failure mode was the quiet kind: no error, no queue, no overdue list.
A nightly job now opens a periodic review for every merchant whose cycle has come round. Opening one is idempotent — the merchant is locked and an already-open periodic review is returned rather than duplicated — so a daily pass over a yearly cycle opens each merchant exactly once.
How long each band waits is set under Settings → Risk model, beside the tier meter, because where the bands fall and what each band earns you are one policy. Defaults are annual for high, two years for medium, three for low. The form refuses a cycle where higher risk is reviewed less often than lower risk.
An unassessed merchant is not a low-risk merchant
entities.risk_level defaults to low and nothing writes it until an assessment runs. Low is reviewed least often — so reading the band directly would have given every unscored merchant the longest cycle in the configuration, looked at less often than one scored and found safe.
A merchant that has never been assessed is scheduled on the shortest configured interval, and one with no completed review on record is due immediately. This is the same rule as no score, no band, one step further downstream, and an invariant now holds it.
A country may shorten a cycle but never lengthen one: country_configurations.periodic_review_interval_days — a column that had also been in the schema unused — now acts as a cap, so a jurisdiction mandating six months tightens a three-year band while one permitting ten years does not loosen an annual band.
12 August 2026
Elm refused to run over two values that were never yours to set
Elm's usage code and operator id are fixed by the Yakeen contract. Neither is issued with the Elm credentials and neither appears in any provider portal — but both were required environment variables, so a deployment holding every credential Elm gave us was refused on every call with "ELM_USAGE_CODE is not set". The natural response, copying the placeholder out of .env.example, is how staging came to send the literal string replace-me to Yakeen and get back "replace-me is not valid usage-code": a request error for what was really a configuration error.
Both now default in code to the contract values, so a deployment that sets neither works. A placeholder base URL is likewise treated as unset rather than sent to be rejected as a malformed address.
Provider settings, and where each value comes from
Settings → Providers now holds the usage code and operator id per account, for the case where an account genuinely queries under a different registration — and so the values can be read back without shell access to a container.
The screen states which of three sources each value came from: set here, from the environment variable, or the contract default. That label matters as much as the fields, because a blank field on a working provider looks exactly like a blank field on a broken one. It reads the answer from the same resolver the outgoing request reads it from, so the screen cannot state a value that is not the one being sent.
Clearing a field returns to the default rather than sending an empty header. Changes are audited with the before and after values — neither is a secret, and the row itself only holds the current answer.
Its own permission pair, provider_configuration.view and .manage, granted by default to whoever already holds api_key.manage. Managing them needs authority in every country: what they declare applies to every request the account makes.
See Provider settings.
The person page said "DOB on file" for a date the identity check could not use
What Verity claimed changed. The person page decided whether a date of birth was on file by reading the Gregorian column. Yakeen matches Saudi citizens on a Hijri date, so for a citizen the page was answering the opposite of the question the provider would be asked: a correct Hijri date read as "DOB missing" beside a live identity button, and a Gregorian date read as "DOB on file" beside the same button dead with "Needs date of birth".
Both readings are now taken from the same predicate the check runs on. A date in the wrong calendar gets its own state — the value is shown, and the page says it cannot be used and which calendar is wanted — because telling an operator a date is "missing" when one is sitting in the field sends them to retype it unchanged. The field to correct it is now offered in that case too; previously the record was stuck with an unusable value.
The identity grade is gone
The header carried Strong / Moderate / Thin identity. It read the same Gregorian column, so it returned "Moderate identity" for every person in the database — a confident-sounding band that could not vary and that no evidence wrote. Its two inputs are already on the header as facts: whether there is a hard identifier, and whether a usable date of birth is on file. A reader can weigh those; a grade asked them to trust an arithmetic nobody could inspect.
The person page now opens with what has been checked
Previously an operator had to read seven sections to learn whether anyone had looked at a person, with the two Elm reads at the very bottom. The page now opens with Checks on this person: every person-scoped check, its outcome, and where there is none, why — with the unread ones grouped by the cause they share, under the same words the entity page uses. Not assessed is not the same as clear. A person with nothing run reads as an empty file rather than a quiet one.
Alongside it:
- Identity puts what the file holds beside what the government record returned, in one table. Before, the claim was at the top of the page and the verification of it five sections below, so the comparison that is the entire point of an identity check could not be made without scrolling. No automatic per-field match verdict: names arrive transliterated and dates in either calendar, and a string comparison would report mismatches that are only spelling.
- Registered address lists the addresses the register returned. It used to report only how many.
- Check history is folded away at the end. It is the plumbing behind the verdicts, and it used to sit above the findings it produced.
- An expiring ID is shown; the page carried it nowhere before.
Whether a check ran is visible to anyone who can see the person, on the same footing as the entity page's party chips. What a provider said when a run failed still needs provider_fetch_run.view.
See The person workspace.
11 August 2026
Screening a merchant another system already onboarded
Focal's screening endpoints onboard a customer; they are not a repeatable query. A merchant already held by the shared Focal tenant — commonly onboarded by another Moyasar system — was refused with a conflict, and Verity recorded a failed check for a merchant that had in fact been screened.
Verity now reads the screening Focal already holds instead of giving up. The evidence records that the result was retrieved rather than screened now, alongside the date Focal ran it, so a result from months ago is never presented as a check you just ran.
If the read also fails, the check still fails. Nothing is claimed either way.
Working in one country
A team member can now be confined to a single country. Their entity list, review queue and provider evidence are filtered to it, and a merchant in another country is refused.
Account-wide settings still need authority in every country, with one deliberate exception: a country-scoped administrator can tune their own country's defaults.
See Team and permissions.
Eight new roles
The role catalogue now separates preparing a review from approving one. No role except Operations Admin holds both — one person prepares a file, another signs it off.
Onboarding Officer, KYB Analyst, Compliance Officer, AML Officer (MLRO), Compliance Policy Admin, Compliance Auditor and Account Administrator join the three that existed. Nobody is moved automatically: existing members keep exactly the access they had until an administrator moves them.
Review Analyst is superseded but kept, so its current holders are unaffected.
Individual permissions
A member can hold a permission their role does not carry, for a real exception — the analyst covering the MLRO's leave. Each one records where it applies, why, who granted it, and when it expires.
You cannot grant what you do not hold, and two permissions are never grantable this way at all.
What a member can decide
A member's page now answers the question directly — can this person approve, reject, escalate, adjudicate, bar, manage the team — and names the role or grant carrying each one, rather than leaving you to read twenty-eight permission rows.
Clearer configuration errors
A provider credential left as replace-me used to be sent to the provider, whose rejection is what you saw. Placeholders now read as missing configuration, so the error names the setting instead of quoting a provider's error code.
10 August 2026
Screening lists are configured, not assumed
Screening now runs against the lists you choose, under Settings → Screening lists.
This is the significant one. A screening request that names no lists returns an empty match set that is indistinguishable from a clean subject. Verity previously sent no list selection at all.
A subject type with no list behind it now fails the check rather than returning a quiet clean result, the sanctions gate reads not assessed, and approval stays blocked. Configure both individuals and organizations before running screening.
An entity has a history
The entity page's Recent activity shows what has actually happened to a merchant — details edited, documents uploaded, checks run, provider updates, and the decisions taken on its reviews.
It was empty on every entity before this, because it asked for a kind of record nothing ever wrote.
The heading links to the full history, which needs entity.activity.view.
Standing bars
Merchants and people can be barred from future acceptance, from the entity or person page as well as from Settings → Standing bars. A strong identifier — national ID, registration number, IBAN — blocks approval outright; a name only warns, because names collide.
The form is folded behind a control on the entity and person pages: across a thousand merchants, perhaps one is barred.
Invitations last a day
An invitation link is valid for one day rather than the fifteen minutes it inherited from password resets, and an expired one can be re-sent from the team screen without removing and re-inviting the member.
IBAN verification reports a verdict
The IBAN check completed without saying whether the account checked out — it read fields the provider does not send. It now reads the provider's actual response, and the check row shows verified or not verified. An unrecognised status stays blank rather than guessing.
Website scan
Runs in the background rather than blocking, and no longer fails on pages with non-ASCII content — which included most Arabic sites.
Uploaded documents open
A document uploaded to an entity can be opened from the entity page. Only types a browser renders safely open inline; everything else downloads.
Arabic
The interface is available in Arabic, with a direction-aware layout. Translation is in progress: the app bar, entities list and party cards are done; the entity page, review cockpit and settings are still English.
Earlier
- Entities API pagination, and a sort control on the entities list
- A screen-by-screen guide with real screenshots, and the walkthrough
- Personal data filtered from application logs