/template/e1e594b8931a41c5b171cf004ced597d

Nkwurite · For institutions evaluating the runtime

Nkwurite Evaluation Guide

For an institution assessing Nkwurite. Three teams usually ask three different sets of questions, so this is organised the way the evaluation actually runs: engineering first, model and risk second, security and compliance third. Each section is something you execute yourself against your own sandbox tenant, not something we demonstrate to you. You need about an hour, an API key, and a terminal with curl, jq, openssl 3.x and xxd. Run the commands in order, in one directory: a later command reads the files an earlier one saves, and every file a command reads is written by a command before it.

About one hourAPI key and a terminalSynthetic data onlyUpdated 2026-09-24

0 / What you were given

What you were given

If you are reading this before a sandbox exists, ask for one: email partnerships@uuamni.com with the institution's name, the people who will run the evaluation, and whether you want the fraud and AML surfaces switched on. What comes back is the four items below.

ItemLooks likeWhere it goes
Tenant API keynkw_…Authorization: Bearer nkw_…, or X-API-Key: nkw_…
Base URLhttps://api.<host>everything below
Portal URLhttps://portal.<host>browser
Sign-in linkhttps://portal.<host>/tenant#signin=…browser, once: it signs you in as yourself

The sign-in link makes you the owner of your tenancy. From People in the portal you invite colleagues by email with a role (owner, admin, analyst, viewer); each of them gets their own one-time link and signs in as themselves. The API key is for your systems, and API Keys in the portal lets you make more, each with a label, a scope and an expiry.

The key identifies your tenant on every call. Treat it as a credential: not in client-side code, not in a repository, not in a URL query string.

Set it once:

bash
export NKW_KEY=nkw_your_key_here
export NKW_API=https://api.<host>

The Base URL is the scheme and host only: no trailing slash and no path. The …/v1 URL on the portal's Models page is only for the OpenAI SDK's base_url; do not use it as NKW_API.

Confirm you are who you think you are:

bash
curl -s $NKW_API/api/portal/me -H "X-API-Key: $NKW_KEY" | jq

That returns your tenant record: name, type, status, allocation summary. If it returns 401, the key is wrong. If it returns a tenant that is not yours, stop and tell us immediately, because that would be a finding worth having.

1 / Engineering: does the API work

Engineering: does the API work

1.1It is an OpenAI-compatible endpoint

The gateway is mounted at /v1 with no prefix, so it is a drop-in base URL for the standard OpenAI SDKs. Your existing tooling works with two lines changed.

bash
curl -s $NKW_API/v1/models -H "Authorization: Bearer $NKW_KEY" | jq

Take the first model id from that list and send it a message:

bash
export NKW_MODEL=$(curl -s $NKW_API/v1/models -H "Authorization: Bearer $NKW_KEY" | jq -r '.data[0].id')
echo "$NKW_MODEL"

jq -n --arg model "$NKW_MODEL" \
  '{model: $model, messages: [{role: "user", content: "Say hello in one sentence."}]}' \
  > chat.json
curl -s $NKW_API/v1/chat/completions \
  -H "Authorization: Bearer $NKW_KEY" \
  -H "Content-Type: application/json" \
  -d @chat.json | jq

Same call from the Python SDK (pip install openai), unchanged except base_url and api_key, reading the same three variables:

python
import os
from openai import OpenAI
client = OpenAI(base_url=os.environ["NKW_API"] + "/v1", api_key=os.environ["NKW_KEY"])
print(client.chat.completions.create(
    model=os.environ["NKW_MODEL"], messages=[{"role": "user", "content": "hello"}]
).choices[0].message.content)

1.2Authentication fails closed

Worth doing, because it is cheap and it is the kind of thing that is usually assumed rather than checked:

bash
curl -s -o /dev/null -w "no key: %{http_code}\n" $NKW_API/v1/models
curl -s -o /dev/null -w "bad key: %{http_code}\n" \
  $NKW_API/v1/models -H "Authorization: Bearer nkw_not_a_real_key"

Both must be 401, in OpenAI error shape. Neither endpoint is ever anonymous.

1.3Health and dependencies

bash
curl -s $NKW_API/health | jq
curl -s -o /dev/null -w "ready: %{http_code}\n" $NKW_API/health/ready

/health takes no key and answers with the overall status and nothing else: {"status": "healthy"}, "degraded" or "unhealthy", with 503 when a critical dependency is down and 200 otherwise. Your tenant key does not change that answer: the per-component detail is for our operators and needs an operator credential, and a tenant key sent as Authorization: Bearer is refused with 403 rather than ignored. /health/ready is the one to point a load balancer at: 200 when the environment can take traffic, 503 when it cannot. There is no /api/health: the health router is mounted at the root.

2 / Risk and model: are the scores usable

Risk and model: are the scores usable

Everything in this section is also on screen: sign in to the portal with the same key and open Fraud Scoring and AML Monitoring in the sidebar. The screens call the endpoints below on your key, so what your risk analyst sees in the browser and what your engineer gets from curl is the same object.

A fraud score nobody can explain is not usable in a regulated process, so the evaluation here is less about the number and more about what travels with it.

2.1Get a sample transaction

Save one synthetic transaction to a file, then read it:

bash
curl -s $NKW_API/api/portal/fraud/sample -H "X-API-Key: $NKW_KEY" > transaction.json
jq . transaction.json

Synthetic, shaped to a Nigerian retail payment profile. The same seed returns the same transaction; add ?seed= with any other whole number for a different one. is_fraud is the generator's own label, there so you can compare it with the band the scorer gives. The scorer ignores it.

2.2Score it

Post the file you just saved:

bash
curl -s $NKW_API/api/portal/fraud/score \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d @transaction.json | jq

The score call takes one transaction at a time. To score a different one, fetch it with another seed and post that file:

bash
curl -s "$NKW_API/api/portal/fraud/sample?seed=7" -H "X-API-Key: $NKW_KEY" > transaction-7.json
curl -s $NKW_API/api/portal/fraud/score \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d @transaction-7.json | jq

Each result carries a risk score between 0 and 1 and reason codes: the specific contributing factors, so an analyst can say why a transaction was held and an examiner can follow the reasoning. This is the part to scrutinise. Ask whether the reasons your team would give match the reasons the scorer gives.

What the scorer is, precisely. An additive logit with every coefficient written down in a single table. That buys two things a tree ensemble does not: the attribution travelling with each score is exact rather than approximated, and there is no model artefact to version, explain or defend to an examiner. The contract is what we are asking you to evaluate here, and it is the part that does not change: a score, ranked reasons, and a policy band you can move.

The coefficients themselves are hand-set from known fraud patterns (balance drain, ticket anomaly, fresh beneficiary, new device, odd hour, velocity). They are a prior, not a fit. Nothing in this scorer is calibrated on real data, no training step has run, and no number it returns is a production detection rate. A trained scorer over your confirmed cases is what a pilot produces, and it arrives behind this same response shape, so an integration you build against the sandbox survives the swap.

2.3See the threshold tradeoff before you pick one

bash
curl -s $NKW_API/api/portal/fraud/thresholds -H "X-API-Key: $NKW_KEY" | jq

Approve, step-up and hold bands against false-positive and catch rates, so the trade-off a threshold buys is visible and both lines are yours to move. The right threshold is a business decision about how much friction you will accept, not a model output.

Where those two rates come from, because it decides what they are worth to you. The endpoint generates a synthetic corpus, scores it with the hand-set coefficients described above, and grades the result against the generator's own labels. That generator writes the same patterns those coefficients weight, so what comes back is the agreement between two tables we wrote, not a measurement of detection on Nigerian retail traffic. Read it as the mechanism working end to end, and as the shape of the curve a threshold moves along. Do not carry the rates themselves into a threshold decision for your book. Those numbers come from a pilot, scored against fraud you confirmed.

2.4AML typologies

The AML surface detects structuring and related typologies and produces an explainable alert: the typology matched, the contributing indicators, and a draft investigation narrative. Yours to run, on your own key, like the fraud surface above.

Save one case to a file, and look at the whole monitoring queue:

bash
curl -s $NKW_API/api/portal/aml/sample -H "X-API-Key: $NKW_KEY" > case.json
jq . case.json
curl -s $NKW_API/api/portal/aml/feed -H "X-API-Key: $NKW_KEY" | jq

typology in the case is the generator's own label, there so you can compare it with what the detector matches. The detector ignores it.

Match the saved case to a typology, then draft the write-up an investigator would file:

bash
curl -s $NKW_API/api/portal/aml/detect \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d @case.json | jq

curl -s $NKW_API/api/portal/aml/narrate \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d @case.json | jq

The narrative is the part worth reading with your MLRO beside you. It is drafted from the indicators that fired, not written freely, so what it says can be traced to what the detector saw. Same caveat as the scorer in 2.2: the corpus is synthetic and the typologies are hand-specified, so this is the contract and the explanation to assess, not a detection rate.

AML Monitoring in the portal sidebar is the same surface on screen.

2.5Parties: your customers and counterparties

A party is one customer or counterparty in your tenancy, filed under your own reference number. Cases, screening results and report records attach to a party, so this is where your AML work starts. Only synthetic parties are accepted in this environment: the API refuses anything else, and so does the database.

bash
curl -s $NKW_API/api/portal/aml/parties \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d '{"external_ref": "CUST-0001", "role": "CUSTOMER", "party_type": "INDIVIDUAL",
       "full_name": "Adaeze Okonkwo", "country": "NG"}' | jq

Search your parties with GET /api/portal/aml/parties?q=.... Open one with GET /api/portal/aml/parties/{party_id}, and change its details or risk rating with PATCH on the same path. A rating other than UNRATED needs a reason.

What this gives you as evidence: every create and every change is a row in your own audit trail (3.2). The row records which fields changed, and the old and new rating when the rating moves. It never records the customer's name or identifiers, because the trail is append-only and cannot be edited afterwards. The party view also lists linked cases, screening hits and reports. A count reads null where the platform does not yet record that kind of item, so an empty list is never mistaken for a check that found nothing. The rating is your institution's; the platform prescribes no risk-rating method.

Parties in the portal sidebar is the same surface on screen.

2.6Cases: from an alert to a decision

A case is one investigation. It is opened from a monitoring alert, or by an investigator about a party already on file. It is assigned to a named person in your organisation, escalated with a reason if needed, and closed with a disposition (FALSE_POSITIVE, NOT_SUSPICIOUS or SUSPICIOUS) and a reason. A closed case cannot be edited: new facts open a new case.

Load the synthetic demo caseload (every alert in the generated corpus becomes a case, with its subject), or open one from an alert yourself. The alert route runs the detector on the activity you post and opens a case only if it alerts:

bash
curl -s -X POST $NKW_API/api/portal/aml/cases/seed-demo \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" -d '{"n": 50}' | jq
curl -s $NKW_API/api/portal/aml/cases/from-alert \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" -d @case.json | jq

Work a case: GET /api/portal/aml/cases lists your caseload (filter by state, owner_user_id, subject_party_id or overdue=true), and GET /api/portal/aml/cases/{case_id} opens one. Act on it with POST to /api/portal/aml/cases/{case_id}/assign, /api/portal/aml/cases/{case_id}/escalate, /api/portal/aml/cases/{case_id}/close, /api/portal/aml/cases/{case_id}/notes and /api/portal/aml/cases/{case_id}/links. POST /api/portal/aml/cases opens one by hand.

What this gives you as evidence:

  • The decision and its basis. A case keeps the alert it was opened on as the detector produced it: the typology, the confidence, the model version and each indicator with its contribution. It also keeps the transactions it covers, the investigators' notes, which cannot be edited, and the disposition with its reason.
  • Who did what, when. The case history is your audit trail itself (3.2). Each assignment, escalation, note, link and closure is an audit row naming the person or key that acted. The history view reads those rows back rather than keeping a separate copy that could disagree with them.
  • Timeliness. Two clocks per case: assignment is due 24 hours after the case opens, and resolution is due 120 hours after first assignment. These are the platform's defaults, not a regulatory period. A breach stays on record after the case moves on.
  • Figures on your own caseload.GET /api/portal/aml/cases/metrics gives counts by state, source and disposition, the number of open cases past a deadline, median and mean turnaround from opening to closure, and the false-positive and suspicious shares of closed cases. You define and state how you calculate your own metrics; these are the raw figures.

A case that is escalated, or closed as SUSPICIOUS, is where a suspicious-transaction report record is raised from (2.8).

Cases in the portal sidebar is the same surface on screen.

2.7Sanctions screening

Every party is screened against the official sanctions lists loaded on this environment: when it is added, when a transaction names it as a counterparty, and whenever you ask. The lists are the UN Security Council Consolidated List, OFAC's SDN list, the UK Sanctions List, the EU Consolidated Financial Sanctions List and the Nigeria Sanctions List, each loaded from the publisher's own file. Each hit opens a case (2.6). It joins the case already open for that party and that listed target rather than opening another. A target you have already closed as a false positive is recorded on the next screen and not reopened.

See what screening compares against, and what it does not:

bash
curl -s $NKW_API/api/portal/aml/screening/lists -H "X-API-Key: $NKW_KEY" | jq

POST /api/portal/aml/screening/parties/{party_id}/screen screens a party again now. GET /api/portal/aml/screening/parties/{party_id} returns every screening of it and every hit. To screen the counterparty on a transaction:

bash
curl -s $NKW_API/api/portal/aml/screening/transactions \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d '{"subject_party_id": "...", "transaction_ref": "TX-1",
       "counterparty": {"external_ref": "CP-1", "full_name": "...", "party_type": "ORGANISATION"}
      }' | jq

What this gives you as evidence:

  • Why a hit matched. Each hit records which of the party's names was compared with which listed name, the similarity of each word pair, and the score. Date of birth and nationality each add or subtract a stated amount, and are marked "not compared" when one side lacks them. The arithmetic is additive and the reasons carry every term, so an investigator can check a hit rather than trust it.
  • What was screened against, on the day. Every screening is a recorded run naming the exact list versions used (the publisher's own timestamp, when the file was fetched, and its SHA-256). It also names every source that was not compared and why, so a screen can be reproduced later.
  • What is not covered, stated. Politically exposed persons are not screened: there is no free authoritative PEP list, and a licensed bulk dataset has not been procured. Every run lists PEP as absent with that reason, and screening never reports "no PEP hits". The Nigeria Sanctions List is published as a web page that carries names and reference numbers only, so screening against it compares names only, and its hits say so. Screening runs on this environment: no name is sent to an outside service.

The threshold (0.85) is a starting point that favours catching a designated person over sparing an extra case. Setting it for your risk appetite is your institution's decision.

Parties in the portal shows each party's screening on its detail view.

2.8Report records: STR and CTR

A report record is what your institution drafts, reviews and keeps before it files. It is not a filing. Filing happens in the NFIU's goAML, and the platform does not produce goAML submissions (below).

  • STR, from a case.POST /api/portal/aml/reports/from-case/{case_id} raises a suspicious-transaction report record from a case that is escalated or closed as SUSPICIOUS. Its first draft is the investigation narrative from 2.4, rebuilt from the transactions the case holds. A case with no transactions (a screening hit, a referral) gets a draft built from the case's own evidence, and the version says which. There is one STR per case.
  • CTR, from the threshold. Post the customer's transactions, marking the cash ones, and a currency-transaction report record is generated when the cash total is at or above the statutory reporting threshold: ₦5,000,000 for an individual and ₦10,000,000 for a body corporate (Money Laundering (Prevention and Prohibition) Act, 2022, s. 11(1)). Below the threshold nothing is generated, and the refusal cites the section.
bash
curl -s $NKW_API/api/portal/aml/reports/ctr \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d '{"subject_party_id": "...", "period": "2026-09-28",
       "transactions": [{"transaction_ref": "C1", "amount_ngn": 3000000, "cash": true},
                        {"transaction_ref": "C2", "amount_ngn": 2500000, "cash": true}]}' | jq
curl -s $NKW_API/api/portal/aml/reports -H "X-API-Key: $NKW_KEY" | jq

GET /api/portal/aml/reports/{report_id} returns a report with every version of its text. POST /api/portal/aml/reports/{report_id}/revise edits it, and the edit becomes a new version with your reason; the earlier text is kept. POST /api/portal/aml/reports/{report_id}/ready marks it ready for your filer, after which it is not edited.

What this gives you as evidence: each report is linked to the case and the transactions it came from. Every version of its text is kept, with who changed it and why. Raising, each revision and the ready mark are rows in your audit trail, which records what happened to the report, never its text. GET /api/portal/aml/cases/metrics now also counts STR records and their ratio to closed cases. That is one raw input to the "STR conversion rate" an institution defines for itself. It counts records raised here, not filings.

Not built: goAML export.GET /api/portal/aml/reports/{report_id}/goaml answers 501 and says why. The goAML schema version is set on the NFIU's side and is not published in a form we can build against, and since 1 February 2026 goAML rejects improperly formatted submissions. It is unblocked by the schema version and XSD as shown in a reporting entity's own goAML account; with those, the exporter is built and validated against the XSD in the same change that turns it on.

Cases in the portal raises an STR from a case and shows its versions.

2.9Model governance: thresholds as recorded decisions

The thresholds detection uses are your institution's recorded decisions, not constants in our code. GET /api/portal/aml/models shows, for each detector (the AML typology detector and the fraud scorer), its code version, the parameters it uses for you now, and whether they are the platform default or a change you approved.

To change one, propose it with a reason:

bash
curl -s $NKW_API/api/portal/aml/models/changes \
  -H "X-API-Key: $NKW_KEY" -H "Content-Type: application/json" \
  -d '{"model": "AML_TYPOLOGY", "parameters": {"alert_threshold": 0.6},
       "reason": "Fewer weak mule alerts"}' | jq

The proposal is backtested at once: what the proposed parameters, and the current ones, would do to the synthetic corpus. The result is stored with the corpus seed and size and a SHA-256, so it can be re-run and compared. Someone other than the proposer then decides it: POST /api/portal/aml/models/changes/{entry_id}/decision with approve and a note. The same person or key cannot approve its own change. Approval supersedes the previous one, and from then on the AML alert threshold decides whether activity opens a case (2.6). The fraud bands decide a score's band whenever a request brings no policy of its own. A policy in the request is still honoured as a what-if. GET /api/portal/aml/models/changes lists every proposal with its decision, and GET /api/portal/aml/models/changes/{entry_id} returns one with its backtest.

What this gives you as evidence: every threshold change is on record with who proposed it, why, what it was expected to do, who decided and their note, and the detector version it was approved against. An approval does not carry to a new detector version: the default applies until you re-approve against the new one. Each step is a row in your audit trail.

What it is not: validation. The backtest runs on the platform's synthetic corpus, and it says so. That corpus is hand-specified, and every alert in it scores 1.00, so an AML threshold backtest shows no trade-off there. The figures describe the corpus, not your customers. How often you review a model, and who validates it, are your institution's governance.

Model Governance in the portal sidebar is the same surface on screen.

2.10The whole path in one call: the AML sandbox

Sections 2.4 to 2.9 take the AML features one at a time. To see how they connect without building the data by hand, build the sandbox:

bash
curl -s -X POST $NKW_API/api/portal/aml/sandbox -H "X-API-Key: $NKW_KEY" | jq

Or press Build the AML sandbox on the Cases screen.

In your tenancy, on synthetic data, it:

  • loads the demo caseload: a case for every alert in the generated corpus, each with its synthetic subject (the same as seed-demo in 2.6);
  • takes one case from alert to report record: assigns it to the first active person in your organisation, escalates it with a reason, and raises an STR record from it;
  • assigns and closes a second case as a false positive, so the metrics have an outcome to count;
  • generates a CTR record for the escalated case's subject from four synthetic transactions. Three are cash deposits totalling ₦5.45m, and one is a ₦3m transfer the CTR does not count;
  • screens that subject against whatever lists are loaded, and names the ones that are not.

The response gives the ID of each record it made, so you can open them with the calls in 2.5 to 2.8 or on the Parties and Cases screens. Nothing is special about these records. They are made through the same service calls as the routes your investigators use, with the same validation, and each step is a row in your audit trail (3.2), attributed to your key. Calling the sandbox a second time returns the same walkthrough rather than a second one.

If your tenancy has no active person yet, the sandbox loads the caseload and stops, and says why: a case cannot be assigned to nobody. Add someone under People, then call it again.

What this gives you as evidence: one worked example of data flowing from an alert, to a case, to a report record, which is the "alert → case → regulatory reporting" flow the CBN's implementation roadmap template asks an institution to be able to show. It runs on synthetic data, so it shows the flow and the records, not detection on your customers.

2.11The AML features, heading by heading

The CBN AML Baseline Standards list fifteen headings under Section 5.1. The table below says, for each one, what you can do here and where. The fuller version, with what each feature gives your institution as evidence and what is not built, is section 8 of the control mapping (docs/compliance/cbn-2027-control-mapping.md).

Neither this guide nor the mapping makes any claim about Nkwurite's standing against the Standards or any CBN requirement, and neither should be read as one. The CBN does not recognise vendor claims of that kind. It assesses compliance within your institution's own implementation, and using a vendor does not transfer your accountability (CBN Guidance Note, 31 March 2026, §3 and §6). What the platform gives you is evidence to use when your institution makes its own case.

HeadingWhat you can do hereWhere
Customer Due Diligence (CDD), Know Your Customer (KYC) and Know Business (KYB)Keep a party record per customer or counterparty. Identity is not verified2.5
Sanction ListsScreen parties and transaction counterparties against loaded UN, OFAC SDN, UK, EU and Nigeria list versions2.7
PEP ScreeningSee PEP named as an absent source in every result, with its reason. No PEP data is loaded2.7
Risk AssessmentSee a party's rating, cases, hits and reports together. The rating is yours to set2.5
Transaction MonitoringRun the typology detectors on activity you post, and open a case from an alert2.4, 2.6
Risk-Based AnalysesSet the detection threshold your institution uses, as a recorded decision2.9
Case ManagementWork a case from open to a disposition, with owner, reasons, notes and clocks2.6
Regulatory ReportingRaise STR records and generate CTR records. Export to goAML is not built2.8
Management Information ReportingPull caseload, turnaround, outcome and STR figures2.6
Audit and GovernancePull your audit trail; propose, backtest and decide threshold changes3.2, 2.9
System Integration & ScalabilityCall the API from your systems. No connectors are built1, 2
Security & Data ProtectionVerify isolation, location, encryption and backup yourself3
User Interface & CustomisationUse the Parties, Cases, Model Governance and AML Monitoring screens2
Vendor/Third-Party Risk ManagementUse this guide, the mapping and the evidence package in your own due diligence3.2.1
Fraud Monitoring and DetectionScore transactions with reasons, and sweep thresholds2.1 to 2.3

3 / Security and compliance: the long pole

Security and compliance: the long pole

This is the section that decides evaluations, so it is the one with the most to check.

3.1Tenant isolation is enforced by the database

Application-layer checks protect against the code paths someone remembered to guard. Nkwurite additionally runs Postgres row-level security over the tables that hold each tenant's allocations, jobs, usage, billing, SSH keys, webhook deliveries, model deployments, AML parties and AML cases, with their links and notes, sanctions screening runs and hits, AML report records with their versions, and each institution's model-governance decisions. A query that arrives without a tenant context sees zero rows rather than all rows, and the policies apply to the table owner too.

The practical consequence: a service written next year that forgets to filter by tenant still cannot read across tenants, because the database refuses rather than the application remembering.

You cannot test another tenant's isolation from inside your own tenancy, by construction. Ask us to walk you through the isolation test in our suite, which creates two tenants and asserts the refusal, and note that a wrong-tenant resource id returns 404 rather than 403: an authorisation error that distinguishes "not yours" from "does not exist" is an enumeration oracle.

3.2Pull your own audit trail

bash
curl -s "$NKW_API/api/portal/audit" -H "X-API-Key: $NKW_KEY" | jq

Yours to pull, whenever you want, without asking us. The trail is immutable and covers actions you performed plus operator and system actions recorded against your account, including ours.

Score a transaction in section 2, then pull this: the call is there as an INFERENCE_CALL row whose resource_type is fraud_score, with the score and band it produced in detail. What you sent to be scored is not: the transaction is yours and the trail records the outcome, not your data.

Narrow it to a period. Encode the timestamp: the + in an ISO offset becomes a space when a URL is decoded, so paste the Z form or percent-encode the plus.

bash
curl -s "$NKW_API/api/portal/audit?since=2026-08-01T00:00:00Z&limit=1000" \
  -H "X-API-Key: $NKW_KEY" | jq

Read scope_note on the response. It states what the export does and does not reach, and it travels with the data on purpose: an export that silently omits rows is worse than one with a stated boundary. Ask for the full trail for a formal audit and it will be produced.

The trail includes what we did to your resources, not only what we did to your account record: releasing one of your allocations is your row and appears here, attributed to uuamni-operator. Rows that belong to no tenancy, a platform configuration change, an operator signing in, are in no customer's trail. Rows written before 21 September 2026 carry the tenancy where it could be reconstructed from the row itself; ask and we will say which of yours those are.

What you will not see, deliberately: our operator context field, and the IP address on rows we performed rather than you. Those are internal, and the endpoint that exists to demonstrate isolation is a poor place to break it.

3.2.1Take the evidence package

The paged trail above answers "show me my trail". When your auditor wants a file to keep, take the package:

bash
curl -s "$NKW_API/api/portal/audit/package?since=2026-09-01T00:00:00Z" \
  -H "X-API-Key: $NKW_KEY" -o nkwurite-audit-package.ndjson

One JSON object per line, schema nkwurite.audit.package.v1. Line 1 is a manifest: what the package covers, how many rows matched, whether it was truncated, what it proves and what it does not. Then the environment's signed locality statement, then every audit checkpoint sealing the period you asked for, then your rows, oldest first.

Check the file is the one we issued, without calling us:

bash
# The digest over everything after the manifest, and the digest the
# manifest claims. These must match.
tail -n +2 nkwurite-audit-package.ndjson | shasum -a 256
head -n 1 nkwurite-audit-package.ndjson | jq -r .body_sha256

Then verify the signature over that digest. It is Ed25519 under the public key the manifest carries (raw, 32 bytes, hex), the same key that signs the checkpoints. The fixed prefix below is the standard DER header for an Ed25519 public key, which turns the raw key into one openssl reads:

bash
head -n 1 nkwurite-audit-package.ndjson > manifest.json
jq -r .body_sha256 manifest.json | xxd -r -p > package.digest
jq -r .signature.value_base64 manifest.json | base64 -d > package.sig
{ printf '302a300506032b6570032100'; jq -r .signature.public_key manifest.json; } \
  | xxd -r -p | openssl pkey -pubin -inform DER -out audit-pub.pem
openssl pkeyutl -verify -pubin -inkey audit-pub.pem \
  -rawin -in package.digest -sigfile package.sig

Signature Verified Successfully is the answer you want. The key's name is checkable too: signature.key_id is the first 16 hex characters of the SHA-256 of the raw public key.

bash
jq -r .signature.public_key manifest.json | xxd -r -p | shasum -a 256 | cut -c1-16
jq -r .signature.key_id manifest.json

We keep our own implementation of both steps in the codebase, under test, so these instructions have something that fails when they drift.

Read does_not_prove before you rely on the file. Two entries matter most. The checkpoints seal the raw rows in our database and the entries in your package are the tenant-visible projection of them, so you cannot recompute a checkpoint hash from the package: the checkpoints are evidence about the trail, the signature is evidence about the file. And each checkpoint line states its row_schema. Seals under nkwurite.audit.row.v2 include the row's tenancy, so moving a row between two institutions' trails breaks the seal. Seals under nkwurite.audit.row.v1 were written before 22 September 2026, when tenancy was still outside the row digest; the file says so for those, rather than a footnote here.

3.3Metering and billing are inspectable

Every inference call is metered per token and priced. Check that what you were charged matches what you called:

bash
curl -s $NKW_API/api/portal/inference/usage -H "X-API-Key: $NKW_KEY" | jq
curl -s $NKW_API/api/portal/billing -H "X-API-Key: $NKW_KEY" | jq
curl -s $NKW_API/api/portal/billing/current -H "X-API-Key: $NKW_KEY" | jq

There is also a spend cap: a reservation is held for an in-flight request and released when it completes, so a runaway integration cannot silently run past a ceiling.

bash
curl -s $NKW_API/api/portal/inference/spend-cap -H "X-API-Key: $NKW_KEY" | jq

A stream you abandon mid-response is not billed. That is a stated policy, not an accident of implementation.

3.4Data residency

Ask us where this environment runs, and get the answer in writing.

From 1 January 2027, the CBN requires payments transaction data generated within Nigeria to be "stored and managed in Nigeria" (circular PSS/DIR/PUB/CIR/001/004, 15 June 2026), and NITDA's cloud guideline requires regulated financial institutions' financial data to be "primarily hosted, processed and stored" within Nigeria. Nkwurite is built so you never have to guess: our contracts keep Nigerian payment transaction data in Nigeria from 1 January 2027, de-identified or not, and each workload carries a signed statement of where it ran that you can verify in a browser.

So the question to put to any vendor, including us, is not "where is the data stored" but "where does inference execute". If the answer is a region name, ask which region, and ask what the accompanying control plane touches.

We will tell you exactly where the environment you are evaluating runs and what the production commitment is. If those two are different at the time you evaluate, we will say so plainly rather than let the evaluation imply otherwise.

3.4.1Verify it yourself

The environment will tell you where it declares it runs, signed, and you do not have to take our word for the location. Ask it:

bash
curl -s "$NKW_API/api/locality?nonce=$(openssl rand -hex 16)" -o response.json
jq . response.json

No API key. The nonce is yours: pick anything, and it comes back inside the signed bytes, so a response recorded from some other environment on some other day cannot be handed to you as an answer to this question.

You get back a declared site, facility, country, provider, region and zone; the public address the environment answers on and the organisation that address is registered to, with the date somebody checked; the host clock, its uptime (host.uptime_ms) and its time source with the clock offset (host.ntp_offset_us, whole microseconds); your nonce; and an Ed25519 signature over all of it.

Verify the signature, over the response.json you just saved:

bash
jq -c -S '.attestation' response.json | tr -d '\n' > attestation.canonical
jq -r '.signature.value_base64' response.json | base64 -d > attestation.sig
jq -r '.signature.public_key_pem' response.json > locality-pub.pem
openssl pkeyutl -verify -pubin -inkey locality-pub.pem \
  -rawin -in attestation.canonical -sigfile attestation.sig

Signature Verified Successfully is the answer you want. (jq -c -S produces exactly the bytes we sign: the attestation object, keys sorted, no insignificant whitespace, UTF-8. Needs OpenSSL 3.x for -rawin. Works on jq 1.6 and 1.7: the signed object holds only strings, integers, booleans and nulls, because jq versions disagree on how to print a float, and attestation.version is an integer for the same reason. Keep the nonce to letters, digits, ., _, ~ and -, which is what openssl rand -hex gives you; anything else is refused with a 422 rather than signed into a document your tools would print differently from ours.)

Then check the provider's word for the zone (attestation version 3). On a cloud host the signed object carries provider_attestation: the provider's own identity document for the instance and the provider's RSA-2048 PKCS7 signature over it. That signature is the provider's, not ours, and you verify it against the provider's published certificate for the region, so the zone is attested by a third party rather than declared by us. For AWS:

bash
# -j, not -r: the provider signed the exact bytes, and -r would append a newline.
jq -j '.attestation.provider_attestation.document_raw' response.json > identity.json
{ echo "-----BEGIN PKCS7-----"; jq -r '.attestation.provider_attestation.pkcs7_rsa2048' response.json | fold -w 64; echo "-----END PKCS7-----"; } > identity.p7
# The RSA-2048 certificate for the PARENT region (af-south-1 for the Lagos
# Local Zone) is AWS's, published in the Amazon EC2 User Guide under
# "Regions certificates" (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/regions-certs.html).
# The API serves our vendored copy; a reader who distrusts us fetches AWS's
# copy and compares the two. Checked 2026-09-18 against the live Lagos
# environment.
curl -sS -o af-south-1-rsa2048.pem $NKW_API/api/locality/region-certificate
openssl smime -verify -in identity.p7 -inform PEM -content identity.json -certfile af-south-1-rsa2048.pem -noverify > /dev/null
jq '.availabilityZone, .instanceId, .accountId' identity.json

Verification successful, and a zone equal to the declared one, is what you want:

bash
jq -r '.attestation.declared.zone' response.json

On UUAMNI-owned hardware there is no provider document and the field is null; the facility's attestation and an address registered in Nigeria take its place, outside this document.

Then check the key is ours, not just self-consistent. A document that verifies against a public key printed inside the same document proves only that whoever wrote the document had a key. Compare signature.public_key_sha256 with the fingerprint we publish for that environment out of band, and check signature.key_is_persistent is true:

bash
jq -r '.signature.key_id, .signature.public_key_sha256, .signature.key_is_persistent' response.json

A false in that last line means the environment could not read its own signing key and minted a throwaway one, which verifies perfectly and means nothing.

Published signing-key fingerprints.signature.public_key_sha256 is SHA-256 over the raw 32-byte Ed25519 public key, hex. The values below were read from the key file on each host, not copied from an endpoint response, which is what makes this table independent of the thing it checks. If the fingerprint you receive is not in this table, either you reached an environment we did not publish, or the key was rotated and this table is stale: ask us before you rely on the answer either way.

Environmentsignature.key_idsignature.public_key_sha256Read from the host on
nk-lagos-eval-01 (AWS Lagos Local Zone)nk-lagos-eval-01af6302b01d4fce1dbe635aeae531121cbb4b1b369cc964c45950a2b58d7438f82026-09-13
nk-trial-01 (AWS Europe, Frankfurt)nk-trial-013ba0f3ca0b7aeee98a922e4e38ab23281540d8b51f96bbad4037749db460690f2026-09-13
Local development stackdevelopment-ephemeralnot published on purpose: the key is minted at process start and key_is_persistent is falsen/a

To reproduce a row, the operator reads the environment's signing key file on the host and runs it through openssl pkey -pubout -outform DER | tail -c 32 | sha256sum, with the key file on standard input.

What the signature proves: authorship (a holder of that environment's private key produced this), integrity (nothing changed after signing), and freshness (your nonce is inside the signed bytes).

What it does not prove: location. Every declared field is what the deployment told the host it is. A signature over a false claim is a signed false claim. That is why the endpoint publishes a does_not_prove list in its own response rather than leaving you to work it out, and why the checks below matter more than the signature does.

What it does not prove: that every operation on the host happens in Lagos. The Lagos evaluation host runs in the AWS Lagos Local Zone, af-south-1-los-1a. The instance and the traffic it serves are there. AWS handles some control-plane operations for a Local Zone instance, such as the API calls that start, stop and configure it, in the zone's parent Region, af-south-1, in Cape Town. The statement from a Local Zone host says so in its own does_not_prove list, naming the zone and the parent Region. It is a property of renting a Local Zone, and it is part of why UUAMNI-owned GPUs in a Lagos facility matter: on owned hardware there is no parent Region, and the statement from that environment carries no such entry.

Check the location independently. We cannot do these for you, which is the point. Take the host and the address from what you already have:

bash
export NKW_HOST=${NKW_API#https://}
export NKW_ADDR=$(jq -r '.attestation.network.public_ip' response.json)
echo "$NKW_HOST $NKW_ADDR"
traceroute "$NKW_HOST"
whois "$NKW_ADDR"
dig -x "$NKW_ADDR" +short

What each one shows:

CheckWhat you runWhat it shows
Path and latencytraceroute "$NKW_HOST" from inside your own networkA Lagos-hosted endpoint reached from a Lagos bank shows single-digit to low-tens millisecond RTT and a path that stays on domestic infrastructure. A Frankfurt or Cape Town box cannot fake that from your side of the link
Independent probesA RIPE Atlas ping or traceroute measurement from Nigerian probes to the address (atlas.ripe.net)Third-party vantage points we do not operate, measuring the same address
Address registrationwhois "$NKW_ADDR"The RIR record: which organisation holds the address and which registry it sits in. Compare it to network.rir_org in the attestation. Expect this to be less local than the box: AWS's Lagos Local Zone addresses are ARIN-registered to Amazon.com, Inc. with a US organisation country (checked 2026-09-12 for 96.0.47.175), and the PTR record names the parent region, af-south-1. Neither contradicts a Lagos location; both are why the latency checks above carry more weight than the registry does. An AFRINIC record appears only on capacity we own
Reverse DNSdig -x "$NKW_ADDR" +shortCloud providers encode region in PTR records more often than they intend to

Latency is the strongest of these and it is still evidence, not proof: a low RTT bounds how far away something can be, it does not tell you which building it is in. Taken together with a signed declaration that we are accountable for, it is what an honest answer to this question looks like before you have walked into the facility yourself.

What we measure for you, continuously. RIPE Atlas, a measurement network run by the RIPE NCC and not by us, pings the environment's public address every day and traceroutes it every week, from probes in Nigeria and from six anchors abroad (Cape Town, Johannesburg, Frankfurt, London, Virginia and Mumbai). The newest result is inside the statement you already have, under the same signature, and the last 30 days are one call away:

bash
jq '.attestation.independent_evidence' response.json
curl -s "$NKW_API/api/locality/evidence" > evidence.json
jq '.measurements[] | {atlas_measurement_id, kind, measured_at, nigerian_probes_answered, min_rtt_nigeria_ms, nearest_foreign_anchor, verify}' evidence.json

Each entry is one day of the ping, or one week of the traceroute, from a long-running Atlas measurement, and names that measurement's id and links to it. Open it on atlas.ripe.net and every probe, every packet and every number here can be recomputed from Atlas's own copy; we keep only the minimum round trip per probe. reading says what the numbers mean in plain words, with the limits in the same sentence: light in fibre covers about 100 km of distance per millisecond of round trip, so a round trip bounds how far the host can be from a probe, and a fast answer from Nigerian probes is evidence the host is not in any of the foreign anchor cities. It places the host to within tens of kilometres at best, never to a building, and it is only as good as the probes' own locations: about two per cent of Atlas probes are not where their operators say, which is why there are many and why the ids are published. An environment that has not been given an Atlas key says "status": "not configured" and why, rather than leaving the field out.

Why attest a cloud environment at all. The attestation is built for whatever environment answers it. Today that is a cloud evaluation environment in Lagos, and the provider's signed identity document is a third party's word for the zone. On UUAMNI-owned hardware in a Tier III Lagos facility, the same attestation ships with the facility's attestation and an address registered in Nigeria in place of the provider document. The response says this in its own scope field.

On environments that declare nothing. A box nobody configured answers with declared_profile_present: false and nulls, and says development. That is deliberate. We would rather an unconfigured environment tell you it does not know than inherit a plausible-looking site from a sibling profile and have you quote it.

3.4.2Every workload, signed

The locality statement above signs where this environment runs. Each finished job also leaves its own signed record: your tenancy, the workload, the model version and weights digest it ran, when it started and ended, and the environment, site and country it ran in. Pull yours:

bash
curl -s "$NKW_API/api/portal/attestations?limit=10" -H "X-API-Key: $NKW_KEY" > attestations.json
jq '{total, first: .attestations[0]}' attestations.json

One row per finished job, newest first. If total is 0, no job has finished on your tenancy yet; scoring calls in section 2 are recorded in your audit trail rather than here.

Each row carries signed_form, exactly the object the signature covers, so you rebuild the hash rather than guess at field order. Check the first row's hash, then its signature, with the key you already saved from the evidence package in 3.2.1 (the same key signs both):

bash
jq -c -S '.attestations[0].signed_form' attestations.json | tr -d '\n' | shasum -a 256 | cut -c1-64
jq -r '.attestations[0].content_hash' attestations.json

jq -r '.attestations[0].content_hash' attestations.json | xxd -r -p > workload.digest
jq -r '.attestations[0].signature' attestations.json | base64 -d > workload.sig
openssl pkeyutl -verify -pubin -inkey audit-pub.pem \
  -rawin -in workload.digest -sigfile workload.sig

jq -r '.attestations[0].key_id' attestations.json
jq -r .signature.key_id manifest.json

The two hashes must match, the signature must verify, and the two key ids must be the same. Read does_not_prove in the response too: site and country are this environment's declaration, the same one 3.4.1 signs and lets you check independently, and node and rack are null until they are recorded on UUAMNI-owned hardware.

Each row also carries log: where that record sits in the public log of every signed record (section 3.4.3), with the proof that it is there. A row signed in the last hour can read "log": null with a log_reason saying the next signed head will cover it.

Every inference request, a receipt. A job or a deployment gets the record above. A single call to the inference API gets its own: SHA-256 of the exact bytes you sent and of the exact bytes you received, the model and the weights version that answered, when, and where, signed the same way. Neither text is kept, by us or in the receipt. Its id comes back on the response:

bash
jq -n --arg model "$NKW_MODEL" \
  '{model: $model, messages: [{role: "user", content: "Receipt check, synthetic."}]}' > request.json
curl -s "$NKW_API/v1/chat/completions" \
  -H "Authorization: Bearer $NKW_KEY" -H "Content-Type: application/json" \
  --data-binary @request.json -D headers.txt -o completion.json
export NKW_RECEIPT=$(grep -i '^x-nkwurite-receipt-id:' headers.txt | tr -d '\r' | cut -d' ' -f2)
curl -s "$NKW_API/api/portal/receipts?limit=20" -H "X-API-Key: $NKW_KEY" \
  | jq --arg id "$NKW_RECEIPT" '.receipts[] | select(.id == $id)' > receipt.json

shasum -a 256 request.json | cut -c1-64
jq -r '.request_sha256' receipt.json
shasum -a 256 completion.json | cut -c1-64
jq -r '.response_sha256' receipt.json

Each pair must match. --data-binary, not -d: -d strips newlines and you would be hashing bytes you did not send. Check the receipt's signature exactly as the workload record's above, with .signed_form, .content_hash and .signature; the same key signs both. GET /api/portal/receipts lists yours, newest first, and GET /api/portal/receipts/{receipt_id} returns one by the id in the header. A receipt is issued for every request a model answered, including a model's own error passed back to you and a stream that broke or that you left early (its outcome says which); a request refused before any model was asked gets none, because nothing was served.

3.4.3The public log and the external witness

A signature tells you who wrote a record and that it has not changed. It cannot tell you that we showed everyone the same record, or that we never dropped one. The environment keeps a public, append-only log of every signed record (each workload record, each receipt, each seal of the audit trail, and one locality statement a day), the structure Certificate Transparency uses (RFC 6962). Its head, how many records and the hash over all of them, is signed with the locality key at least once a day. Anyone can ask for the proof that a record is in it, and the proof that a later log contains an earlier one unchanged. No key is needed.

From the receipt you saved in 3.4.2, to its proof, to the signed head:

bash
export CONTENT_HASH=$(jq -r '.content_hash' receipt.json)
bash
curl -s "$NKW_API/api/transparency/proof?leaf=$CONTENT_HASH" > proof.json

h() { xxd -r -p | shasum -a 256 | cut -c1-64; }

# The leaf hash is SHA-256 over 0x00 followed by the record's content hash.
r=$( { printf '00'; jq -r .content_hash proof.json; } | h )
[ "$r" = "$(jq -r .leaf_hash proof.json)" ] && echo "leaf hash OK"

# Walk the audit path to the root (RFC 9162, section 2.1.3.2).
fn=$(jq .leaf_index proof.json)
sn=$(( $(jq .tree_size proof.json) - 1 ))
for p in $(jq -r '.audit_path[]' proof.json); do
  if [ $((fn & 1)) -eq 1 ] || [ "$fn" -eq "$sn" ]; then
    r=$(printf '01%s%s' "$p" "$r" | h)
    while [ $((fn & 1)) -eq 0 ] && [ "$fn" -ne 0 ]; do fn=$((fn >> 1)); sn=$((sn >> 1)); done
  else
    r=$(printf '01%s%s' "$r" "$p" | h)
  fi
  fn=$((fn >> 1)); sn=$((sn >> 1))
done
[ "$sn" -eq 0 ] && [ "$r" = "$(jq -r .tree_head.signed.root_hash proof.json)" ] && echo "inclusion OK"

# The head's signature, over the canonical bytes of what it signs.
printf %s "$(jq -c -S .tree_head.signed proof.json)" > head.bytes
jq -r .tree_head.signature.value_base64 proof.json | base64 -d > head.sig
openssl pkeyutl -verify -pubin -inkey locality-pub.pem -rawin -in head.bytes -sigfile head.sig

Three lines must print: leaf hash OK, inclusion OK, and Signature Verified Successfully, the last against the same locality key you checked against the published fingerprint in 3.4.1. A receipt from the last hour may answer "included": false with a reason: the next head covers it. This needs an OpenSSL 3 build (-rawin); macOS ships LibreSSL as /usr/bin/openssl, so use the Homebrew one there.

That the log only grows. Keep a head today, ask again next week, and ask for the proof that the newer log contains the older one:

bash
curl -s "$NKW_API/api/transparency/tree-heads" > heads.json
jq '.tree_heads | map(.signed | {tree_size, timestamp})' heads.json

GET /api/transparency/consistency?from=OLD&to=NEW returns that proof, with OLD and NEW two tree sizes from that list. Checking it by hand is the RFC 9162 section 2.1.4.2 algorithm; we publish a 200-line standalone checker that does it, and the inclusion check above, with nothing of ours imported. Ask for it if you want it rather than writing your own.

The external witness. A log we run is still a log we run. Where the environment has it switched on, each head's SHA-256, and nothing else, is also recorded in the public Sigstore Rekor log, which the Sigstore project runs, outside UUAMNI. The head's witness says so and links the entry:

bash
curl -s "$NKW_API/api/transparency/tree-head" > head.json
jq '.tree_head.witness' head.json
printf %s "$(jq -c -S .tree_head.signed head.json)" | shasum -a 256 | cut -c1-64

That hash is what Rekor holds. What Rekor gives you is its own timestamp for the head and a public record of it we cannot quietly change: if we ever showed two readers two different heads for the same size, both would be in Rekor. What it does not give you: Rekor does not know what any record says, and vouches for none of our declarations. An environment without the witness says "no external witness configured" rather than leaving the field out. The Rekor entry is signed by a witness key derived from the locality key, because Rekor's hashed entries do not accept the locality key's signature scheme; GET /api/transparency/witness-key returns the locality key's signed statement naming it.

3.5Questions we expect, with short answers

QuestionAnswer
Key rotationSupported and audited; rotation is recorded in your trail
Key in transportTLS 1.2 floor, 1.0 and 1.1 removed, HSTS on
Credentials at restAPI keys stored hashed, never in plaintext
BackupsDaily dumps with checksum manifests, weekly and monthly retention tiers, offsite push configurable
RecoveryA written recovery procedure per loss scenario, from a restore to a full rebuild. Rehearsal position in 3.6
Sub-processorsNamed on request; the current list depends on where your environment runs
Incident responseA written procedure per failure class, linked from each alert rule. The classes are listed in 3.6
Right to auditContractual, and the trail in 3.2 is the standing evidence

3.6Incident response, in more than one line

Every alert our monitoring raises resolves to a written procedure: what is broken for whom, how to triage it, how to recover, and when to escalate. The procedures themselves are internal, because they carry host names, contact numbers and the order in which we take our own systems apart. What they cover is not, and it is the part you are entitled to assess:

Failure classWhat the procedures cover
API availabilityThe API is down, returning errors, slow at the 95th percentile, or shedding load at the rate limiter.
GPU healthA GPU is over temperature, the fleet is saturated, a card is in maintenance, or the telemetry exporter that watches them has stopped.
Compute node healthA compute node is unreachable, has stopped reporting, or is running out of disk before it takes work with it.
SchedulingJobs are queuing without starting, or failing at a rate that is not the tenant's fault.
Metering and billingUsage collection has stalled, a worker loop has stopped, webhook deliveries are backing up, or billed-but-unpaid balances have grown past their threshold. Metering failures are treated as incidents because unmetered GPU time never back-fills.
Model servingThe OpenAI-compatible gateway is failing, a model deployment is stuck loading, or a model server needs bringing up or tearing down.
Power and facilityFacility power events and the fleet's power commitment.
Data protection and recoveryBackup, restore, recovery from partial or total loss, and reconciling database schema drift before it reaches data.
Audit trail integrityThe tamper-evidence chain, its retention sweep, and the export a security information and event management system consumes.
Access and credentialsCertificate renewal, secret and role rotation, operator sign-on, and the hardening checklist run before anyone outside UUAMNI is let in.
Tenant lifecycleOnboarding a tenant end to end, and moving one between tiers or hardware without losing its billing continuity.
Capacity and platform changesAdding capacity to a live cluster, standing up an environment, and upgrading the scheduler underneath running work.
Sanctions screening dataAn official sanctions list has not been refreshed, a new version failed to load, or a load needs checking against the publisher's file before anyone relies on it.

Each alert rule carries a link to its own procedure, so the person paged does not go looking. Ask and we will walk you through any class on this list, or share a redacted procedure under our mutual NDA. The disaster recovery procedure is the one most institutions ask for first.

Where rehearsal stands, plainly. The recovery procedure sets a quarterly drill cadence, alternating a database restore with a full rebuild, and the backup and restore tooling is exercised by our test suite on every change. One environment drill is on record, dated 29 September 2026: the evaluation environment's own encrypted backup was pushed to a second host in Nigeria, restored there, and compared with the source table by table. The record says what was restored, how long it took, and the seven things that did not work the first time; we send it on request. It also says what it does not show: the second host was in the same facility zone as the source, and the service was not rebuilt from the restored copy. The next drill is due by the end of December 2026. When you evaluate, ask for the current record, and ask to watch a drill: we run it with you on the evaluation environment.

Separately, and labelled as such: a tooling drill on synthetic data is on record in the same place. It dumps a synthetic database the way the nightly job does, encrypts the dump, moves only the ciphertext, checks its hash, decrypts it, restores it into a fresh database and compares every table. A negative control shows the comparison catches a one-row change. What that gives you is a second, smaller piece of evidence that the mechanism works, and a script we send on request so you can repeat it. It restores no environment's copy; the drill above does.

Where the backup goes. The nightly job refuses, on every run, to push to a target that is not declared in Nigeria, and refuses any AWS storage endpoint even when declared: AWS has no Region in Nigeria, and the Lagos Local Zone's snapshots and buckets live in Cape Town (3.4.1). The declaration is still a declaration; how to check a target's location, and the in-country options we are choosing between, are in a design note we share on request.

4 / What an evaluation does not cover

What an evaluation does not cover

Said plainly, because a vendor that lists only strengths is telling you about its sales process rather than its system.

  • Load behaviour at your peak. The sandbox is sized for evaluation. Ask for the load-test harness results, or for a sized environment, if throughput at your volumes is a gating question.
  • Your own data. Synthetic only in an evaluation. Modelling on your transaction history is the pilot, and it has its own agreement.
  • Detection quality, at all. The fraud scorer in section 2 is a hand-set additive logit run over synthetic data, not a model trained on anything. It establishes the contract: a score, exact reason codes, and a policy band you move. It does not establish a catch rate, and the rates in 2.3 are graded against a corpus we generate. Detection tuned to your book is what a pilot produces, from your confirmed cases.
  • Anything about production capacity unless the environment you are testing is production capacity. Ask.

5 / Getting help during an evaluation

Getting help during an evaluation

Note what you ran, what you expected and what happened, and send it. If something in this guide does not behave as written, the guide is wrong until proven otherwise, and we would rather fix it than explain it.

*Last updated: 2026-09-24*

Questions during an evaluation go to chuma@uuamni.com. If something here does not behave as written, the guide is wrong until proven otherwise.