On 4 December 2020 the Belgian Official Gazette published a list of 1,663 companies struck off the Crossroads Bank for Enterprises (KBO) on 24 November 2020 for not filing annual accounts for three years in a row. On 25 September 2026 we looked up 100 of them, spaced evenly through the list.
78 were still struck off. 30 of those 78 still showed Status "Active" on their KBO page, and 12 of the 30 also showed Legal situation "Normal situation".
Ask an agent "is this Belgian company active?" and hand it that page, and it answers yes. The answer is correct, since the register says Active. It is also the wrong thing to act on if you are about to extend credit to a company that has filed no balance sheet since 2020.
This post collects five results like that. All five come from registers we read every day for KYB, credit and insolvency checks, and several of them came out of our own tools before we fixed them. The runs succeeded, the rows parsed, and the result was not empty. The trouble is in what the data means, and a model has no way to know that from the JSON alone.
Other posts in this organization have covered empty-versus-failed results and timeouts in agent tool calls. This one is about results that arrive complete and still mislead.
Where the check belongs
Each trap below gets a check, and each check is a few lines of deterministic code that runs between the tool and the model. That placement matters. You can write "remember that Belgian companies can be struck off while Active" into a system prompt, but the agent has to recall it at the right moment, on the right field, for the right country. A function that runs on every record doesn't have to remember anything.
So the pattern is: fetch, check, then give the model a decision and the reasons for it instead of the raw status. The fetching part is ordinary Apify client code:
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
def fetch(actor_id, run_input):
run = client.actor(actor_id).call(run_input=run_input)
items = client.dataset(run["defaultDatasetId"]).list_items().items
return run, items
The checks don't care which tool produced the rows. Field names below are the ones our actors emit. If you read these registers some other way, map them to your own schema; the logic carries over.
1. "Active" is one field, and a strike-off lives in another
The Belgian Code of Economic Law (art. III.42 § 1, 4°) lets the KBO strike a company off ex officio when National Bank data show it has not filed annual accounts for at least three consecutive financial years. A strike-off is not a dissolution: dissolving a company for missing accounts takes a court (art. 2:74 of the Companies and Associations Code). So the Status field doesn't change. The strike-off sits on its own line on the company page, "Ex officio striking off", with a since-date, and it is lifted once the missing accounts are filed.
Our own Belgian actor didn't read that line until build 1.0.7 in September 2026. Before then it returned exactly what an onboarding check wants to see, a valid enterprise number with status Active, for companies that had been struck off for years. It now returns struckOff and a strikeOffs list with the reason, a type and the since-date. A company can carry two strike-offs at once, one for accounts and one for beneficial-ownership filings.
def check_kbo(rec):
reasons = [
f"struck off ex officio since {s.get('sinceDate')}: {s.get('reason')}"
for s in rec.get("strikeOffs") or []
]
if reasons:
return "hold", reasons
if rec.get("status") != "Active":
return "hold", [f"status is {rec.get('status')}"]
return "pass", []
The rule behind it applies well beyond Belgium: a status field summarises one dimension of a company's legal life, and the register may record other dimensions somewhere else. Before you trust a status field, find out what it does not cover.
2. One row is a record, not a company
Poland's insolvency and enforcement register (KRZ) lists proceedings, and a company can be party to many of them. Until build 1.1.19 our KRZ actor's company search returned one row per participant record, with the case fields empty. For Getin Noble Bank that meant one row, an enforcement case that had ended. The register holds six proceedings for it, one of them an ongoing bankruptcy.
An agent reading "1 result, ended" concludes the company once had a minor enforcement matter that is now closed. That is the opposite of the truth, and nothing in the row looks broken.
The actor now emits one row per proceeding, puts open proceedings first, and marks proceedingsComplete: false on any company whose full list it could not retrieve. Your check should still aggregate before deciding, because an agent that reads only the first row, or a no-code integration that does, makes the same mistake one level up.
def check_krz(rows):
if any(r.get("proceedingsComplete") is False for r in rows):
return "unknown", ["proceeding list incomplete for at least one record"]
open_cases = [
r for r in rows
if (r.get("proceedingStatus") == "w toku") # "in progress"
or (r.get("proceedingStatus") is None and not r.get("proceedingEndDate"))
]
if open_cases:
kinds = sorted({r.get("proceedingKind") or "unknown" for r in open_cases})
return "hold", [f"{len(open_cases)} open proceeding(s): {', '.join(kinds)}"]
return "pass", [f"{len(rows)} proceeding(s), all closed"]
When you build a register check, write down the grain of a row (a filing, a participant, a proceeding, an announcement) before you write any logic that counts rows or reads the first one.
3. The first 100 rows are a sample, and a biased one
Spain's insolvency register (Registro Público Concursal) records three roles per person or company: debtor, disqualified, and insolvency administrator. For a director check, "disqualified" is the role that matters.
On 31 August 2026 we searched it for two common surnames. Martínez matched 9,587 entries and Fernández 8,889. In both cases the first 100 rows were all debtors, with zero disqualified people and zero administrators. In rows 101 to 250 the other roles turned up: 6 disqualified and 27 administrators for Martínez, 7 and 29 for Fernández. The first non-debtor row came at position 118 and 158.
The register lists debtors first. Our actor's default maxResults is 100. Put those together and a director check on a common name returns 100 rows, every one of them isDisqualified: false. That looks like a thorough negative, and it is really a sample of the one role that happens to come first.
We made the same mistake ourselves before we caught it. We first concluded that the disqualified flag was never populated, because 294 of 294 stored rows had it false. Every one of those rows came from a run capped at or near 100. What we had measured was the cap.
MAX = 100
def search_es(name, max_results=MAX):
return fetch("regdata/spain-concursal-scraper",
{"searchMode": "byName", "name": name, "maxResults": max_results})
def check_es_roles(rows, max_results=MAX):
if max_results and len(rows) >= max_results:
return "unknown", [f"result hit the cap of {max_results}; this is the head of a sorted list"]
hits = [r for r in rows if r.get("isDisqualified")]
return ("hold", [f"{len(hits)} disqualified match(es)"]) if hits else ("pass", [])
If the check returns unknown, re-run with maxResults: 0 (no cap), or narrow the query with an identity number. Any capped list behaves like this. Unless you know the sort order, the head of the list tells you nothing about the rest of it.
4. A zero is a claim, and it needs evidence of the right size
The same Spanish register once gave our actor a false all-clear on every large name search. For the most common surnames it took about ten seconds to answer, longer than the actor waited. The actor read the empty results table as "0 matches" and stamped the result "verified against a control query".
The control query was real. It searched for a known insolvent company, and it passed every time. But that company has about a hundred entries, and the register answered for it in under three seconds. A query of that size only shows that small queries come back. It said nothing about a query with ten thousand matches.
Since build 1.0.14 the actor bases the verdict on the register's answer arriving, not on time elapsed. Any search the register did not answer is written to an UNVERIFIED record in the run's key-value store, and the status message says outright that it is not a clean result. Your side of the check is short:
def check_zero(run, rows):
if run["status"] != "SUCCEEDED":
return "unknown", [f"run {run['status']}"]
unverified = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("UNVERIFIED")
if unverified:
return "unknown", ["register did not answer some searches; do not read as clean"]
if not rows:
return "pass", ["register answered: no entries"]
return None, [] # rows present: hand over to the role check above
The general rule is about your own monitoring as much as anyone's tool. A canary or control query vouches only for queries of its own size and shape. If your pipeline's real queries are larger, slower or rarer than the canary, they need a positive control of their own, meaning a query you know should return a lot of results, run on the same schedule.
5. A clean number can come from the wrong row
Polish companies file financial statements with the court register, and one of the formats they come in is a rendered document, not structured data. In July 2026 our KRS financials actor read CD Projekt's FY2025 balance sheet and returned short-term liabilities of 279,811,608,000 and equity of 1,010, flagged success: true. Both values were well-formed numbers, and nothing about their format was wrong. Two adjacent table cells had been merged into one number, and the reporting period's date range had been read as a figure.
Format checks can't catch this, but the balance sheet itself can. Assets have to equal equity plus liabilities, and the asset subtotals have to add up to total assets. After the fix, CD Projekt's figures reconcile exactly, using values taken from two different tables: fixed assets 2,174,743 plus current assets 1,332,673 equals total 3,507,416, and equity 3,294,816 plus long-term 29,408 plus short-term 183,192 equals the same 3,507,416.
def check_balance(f, tol=1):
need = ["totalAssets", "fixedAssets", "currentAssets",
"equity", "longTermLiabilities", "shortTermLiabilities"]
missing = [k for k in need if f.get(k) is None]
if missing:
return "unknown", [f"cannot verify, missing {missing}"]
errs = []
if abs(f["fixedAssets"] + f["currentAssets"] - f["totalAssets"]) > tol:
errs.append("fixed + current != total assets")
if abs(f["equity"] + f["longTermLiabilities"] + f["shortTermLiabilities"] - f["totalAssets"]) > tol:
errs.append("equity + liabilities != total assets")
return ("reject", errs) if errs else ("pass", [])
These two identities fit the summary balance sheet this filing format carries. If your schema splits out provisions or accruals, add them to the right-hand side. Keep the tolerance small, at rounding level.
The harder part is the income statement, which has no internal identity to test against. A wrong revenue figure passes every format check and nothing flags it. So after the balance-sheet fix our actor kept returning revenue, gross profit and net profit for that filing as null, on purpose, for two months. They came back in September only after we found a second table in the same filing that carries the same figures. A profit-and-loss figure is now published only when both tables agree. If a figure has no arithmetic cross-check, use a second source or return null. An agent will use whatever number it receives.
A smaller one: two kinds of null
One more, briefly, because it changes how you triage. Germany's Handelsregister output carries a legal-form code and a legal-form label. Until build 1.1.10 our actor had labels for only two of the codes, so SE, KG, UG and every other form came back with legalFormCode set and legalForm: null. A code with no label means a lookup gap in the tool. When both fields are null, the source document was not retrieved at all, and the run says so with a partial status. These two nulls call for different responses (report a bug, or re-run), and an agent told to treat null as "unknown" will handle both the same way. Check the sibling fields before you decide what a null means.
Wiring it into an agent
Put the checks together and the agent no longer receives a register row. It receives something like this:
def screen_be_company(enterprise_number):
run, rows = fetch("regdata/belgium-kbo-company-scraper",
{"searchMode": "byNumber", "enterpriseNumbers": [enterprise_number]})
if run["status"] != "SUCCEEDED" or not rows:
return {"decision": "unknown", "reasons": ["no verified record"], "source": "KBO"}
decision, reasons = check_kbo(rows[0])
return {"decision": decision, "reasons": reasons, "source": "KBO", "runId": run["id"]}
Three design choices are deliberate here:
-
unknownis a separate decision. It is not a softpass. A capped list, an unanswered search and an incomplete proceeding list all land there, and your workflow decides whatunknownmeans, which is usually re-run or send to a human. - The reasons are plain sentences. The model can quote "struck off ex officio since 2020-11-24 for non-filing of annual accounts" to a user. It can't do much with
struckOff: true. - The
runIdstays with the decision. When someone asks six months later why a supplier was put on hold, you can show the run that answered.
The model still does what it is good at. It explains the decision, drafts the email asking the supplier for its filed accounts, and works out what to do next. It just doesn't get to decide what "Active" means.
If you want to try the first check, the Belgian actor is on Apify. The checks themselves are plain Python, and they work just as well on data you collect yourself.
Which register field has misled you? Put it in the comments. We would like to add to this list, especially for registers we don't read.
Top comments (0)