Automating compliance with SOX, COSO and SCF
Compliance automation with SOX, COSO and SCF
Content
In the previous post, Towards Repeatable Compliance, we explored the basics of a generic framework to define compliance requirements, extract controls and execute them as part of a deterministic verification set.
That is useful on its own, but we can do better by aligning each of the components involved with industry standards, gaining better integration and reusing existing patterns.
This post starts from SOX regulation, and introduces a well-known governance framework (COSO), a set of controls (SCF) and a standard format for expressing findings (NIST’s OSCAL).

The concepts
COSO is the framework the SOX assertions are made against. Published by the Committee of Sponsoring Organizations of the Treadway Commission, it organizes internal control into five components — control environment, risk assessment, control activities, information and communication, and monitoring — expanded into 17 principles. It is a rubric, not a catalog: it says how to judge whether a set of controls is effective, but not which controls to implement.
The Secure Controls Framework is a free control catalog maintained by the SCF Council: 34 domains, more than 1,500 controls, each one written once and cross-referenced to the equivalent requirement in over 200 laws, regulations and frameworks. The crosswalks are the point. Instead of maintaining one control set for SOX, another for ISO 27001 and a third for SOC 2, SCF controls implementation allows reusability across compliance frameworks.
Finally, control results can be expressed in a standard machine-readable form. NIST’s Open Security Controls Assessment Language (OSCAL) defines a set of layered models: a catalog holds the controls, a profile selects and tailors the subset that applies to us, a component definition and a system security plan describe how they are implemented, and assessment results carry the findings. OSCAL gives the result verdicts a schema that any tool can read without knowing anything about our database.
The Role of AI
Use AI to convert SOX obligations into requirements, requirements into controls.
And nothing else.
The controls should be deterministic.
As first step, leverage AI to extract requirements from SOX, align your requirements to COSO, or define new requirements.
Then, use AI to create controls based on your data data catalogs that can be stored, audited and executed at any time with deterministic results.
Refer to Figure 1. in Towards Repeatable Compliance: an SME should be part of the loop as well.
The example: Intercompany Reconciliation
Intercompany reconciliation is one of the highest-risk areas in corporate accounting, and a standing focus of SOX 404 audits.
A parent company with subsidiaries — say a US parent, a manufacturing arm in Ireland and a distributor in Germany — has those entities constantly selling, lending and transferring among themselves.
A group cannot book profit on selling to itself, so intercompany transactions must be eliminated on consolidation.
The elimination only works if both sides recorded the same thing.
If the Irish entity books a $10M sale to the German entity and the German entity never books the purchase, the consolidated balance sheet is materially misstated: revenue never earned outside the group, and an intercompany receivable with no payable against it.
In practice three things cause the break, and none of them is fraud:
- timing — goods shipped in December, received in January
- currency — each side translating at its own rate
- mismatched accounts — the same transaction booked to different GL accounts
What starts as a reconciliation difference ends as a manual year-end adjustment, and manual year-end adjustments are exactly where real misstatement hides.
From the regulation angle, it becomes an obligation. Quoting SOX §404(a):
From SOX to COSO
COSO does not say “reconcile your intercompany balances”.
Instead, it determines which kind of control has to exist, and under which component a failure would be reported:
| COSO component | Principle | What it demands here |
|---|---|---|
| Information & Communication | 13 | Relevant, quality information to support internal control — both entities producing matched, comparable records |
| Control Activities | 10 | Control activities that mitigate risk to acceptable levels — including segregation of duties across entities |
| Control Activities | 11 | General control activities over technology — the ERP configuration that enforces the above |
From COSO to SCF requirements
This is where the policy becomes more tangible, closer to data validation domain.
| Requirement | SCF control |
|---|---|
REQ-301 — both legs recorded and reconciled | ORG-DCH-01 Intercompany Matching & Validation |
REQ-302 — one identity cannot post both legs | HRS-11 Separation of Duties |
REQ-303 — every translation is reproducible | MON-03.2 Audit Trails |
Notice that ORG-DCH-01 carries an ORG- prefix because intercompany matching is a financial reporting control and the SCF catalog does not contain a specific one.
We can express the requirements in EARS format:
REQ-301 IF the selling entity has not recorded a leg, OR the buying entity has not
recorded a leg, OR either amount is absent in the reporting currency, OR the
two amounts differ by more than 1 USD, THEN the Consolidation Service SHALL
reject the intercompany transaction.
REQ-302 IF the identity that posted an opposing leg is not recorded, OR it is the same
identity that posted the first leg, THEN the Consolidation Service SHALL refuse
the posting.
REQ-303 IF a posted leg does not record the rate, OR does not record the rate source,
OR does not record the timestamp the rate was taken at, THEN the Consolidation
Service SHALL reject the leg.And the same three requirements expressed in Gherkin, which provides clear evidence in case of mismatch:
Scenario: An unmatched intercompany leg blocks consolidation
Given an intercompany reference recorded by the selling entity
When the buying entity has not recorded the opposing leg
Then the reference is reported as unmatched and consolidation is halted
Scenario: One identity cannot post both sides
Given an intercompany reference with a leg in each entity
When the two legs name the same posting identity
Then the reference is reported as a segregation-of-duties breach
Scenario: Every translated amount can be recomputed
Given a posted intercompany leg
When its translation record is read
Then it carries a rate, a source and the timestamp the rate was taken at
The experiment
We run an experiment involving 5 million trades, with a certain percentage of reconciliation breaks. Each company keeps its own ledger, in its own functional currency, and sees only its own rows.
The generator spreads trades across the 2026 financial year and splits the 5% across the four ways an intercompany trade goes wrong:
| Cause | Share | Trades | Caught by |
|---|---|---|---|
| Only the seller records a leg | 2.0% | 100,001 | REQ-301 |
| The buyer records a smaller amount — partial receipt, cut-off | 1.8% | 90,001 | REQ-301 |
| One identity posts both legs | 0.7% | 35,000 | REQ-302 |
| The leg arrives without rate, source or timestamp | 0.5% | 25,000 | REQ-303 |
In the following example we’ll focus on some concrete issues but you can expect the same behaviour would apply in all the reconciliation errors.
The three ledgers
The following data snippets render an idea of the data schema. Notice that each entity keeps its own ledge.
| Entity | Name | Country | Currency | Ledger table |
|---|---|---|---|---|
US-PARENT | Northwind Holdings Inc. | US | USD | LedgerUsParent |
IE-MFG | Northwind Manufacturing Ltd. | IE | EUR | LedgerIeMfg |
DE-DIST | Northwind Distribution GmbH | DE | EUR | LedgerDeDist |
LedgerUsParent — a sale to Ireland, a purchase from Germany booked after the period closed:
| Entry | Reference | Counterparty | Leg | Posted | Amount (local) | USD | Posted by |
|---|---|---|---|---|---|---|---|
E-0001 | IC-1001 | IE-MFG | AR | 2026-11-30 | 5M USD | 5M | U-ALEX |
E-0005 | IC-1003 | DE-DIST | AP | 2027-01-04 | 1.44M USD | 1.44M | U-ALEX |
EG-100002A | IC-1100002 | IE-MFG | AR | 2026-10-11 | 22K USD | 22K | U-US-06 |
E-0006 | IC-1004 | DE-DIST | AR | 2026-12-10 | 2M USD | 2M | U-ALEX |
EG-100052B | IC-1100052 | IE-MFG | AP | 2026-11-30 | 90K USD | 90K | U-US-15 |
LedgerIeMfg — the purchase from the parent, and a 10M USD sale to Germany. The EUR rows translate at 1.25, so the local and USD columns differ:
| Entry | Reference | Counterparty | Leg | Posted | Amount (local) | USD | Posted by |
|---|---|---|---|---|---|---|---|
E-0002 | IC-1001 | US-PARENT | AP | 2026-11-30 | 4M EUR | 5M | U-SIOBHAN |
E-0003 | IC-1002 | DE-DIST | AR | 2026-12-18 | 8M EUR | 10M | U-SIOBHAN |
EG-100004A | IC-1100004 | US-PARENT | AR | 2026-10-13 | 24K EUR | 30K | U-IE-06 |
EG-100052A | IC-1100052 | US-PARENT | AR | 2026-11-30 | 72K EUR | 90K | U-IE-13 |
LedgerDeDist — a sale to the parent, a purchase whose translation record lost its timestamp, and a matching entry for every trade except IC-1002:
| Entry | Reference | Counterparty | Leg | Posted | Amount (local) | USD | Posted by |
|---|---|---|---|---|---|---|---|
E-0004 | IC-1003 | US-PARENT | AR | 2026-12-29 | 1.2M EUR | 1.5M | U-KLAUS |
E-0007 | IC-1004 | US-PARENT | AP | 2026-12-10 | 1.6M EUR | 2M | U-KLAUS |
EG-100000A | IC-1100000 | US-PARENT | AR | 2026-10-09 | 20K EUR | 25K | U-DE-05 |
EG-100021B | IC-1100021 | US-PARENT | AP | 2026-10-30 | 31K EUR | 39K | U-DE-12 |
Let’s focus on four specific references. The first one is valid, the other three contain issues:
IC-1001reconciles. Both ledgers posted on the same day, both translate to 5M USD, and two different identities did the posting.IC-1002raises a flag. Ireland booked the sale; Germany’s ledger holds a matching entry for every other trade and none for this one. ReadE-0003on its own terms and it is a correct posting — the break lives in the gap between two tables, so finding it takes a query that spans both.IC-1003has both legs and still breaks. Germany posted 1.2M EUR on 29 December at 1.25; the parent posted on 4 January, in the next period, at 1.20. Each entry is correct in its own book, and the two sides land 60K USD apart — which is how timing and currency differences reach a consolidation.IC-1004lacks the timestamp.
The procedures
The EARS sentences compile to invariants — exception reports, in control language. Every row returned is a transaction that consolidation would have rejected, so a healthy run returns an empty set.
The Gherkin scenarios compile to assertions that carry their own expected answer, so each reports a verdict per named case rather than a list to interpret.
EARS tests the data (ledger), Gherkin tests the validation process (checker).
Let’s go back to the reconciliation breaks:
| Reference | Seller | Buyer | Outcome |
|---|---|---|---|
IC-1001 | US parent | Irish manufacturer | Both legs, amounts agree, two identities — clean |
IC-1002 | Irish manufacturer | German distributor | Seller books 10M USD; the buyer books nothing |
IC-1003 | German distributor | US parent | Both legs, but posted a period apart at different rates — 60K apart |
IC-1004 | US parent | German distributor | Both legs agree at 2M; the buyer’s leg carries no rate timestamp |
When we run the controls, REQ-301-C1 returns two exceptions, and the assessment records them:
IC-1002: no buyer leg recorded (variance 10M USD)
IC-1003: legs differ beyond tolerance (variance 60K USD)
REQ-303-C1 returns the validation error:
IC-1004: no rate timestamp recorded
| Entry | Reference | Amount (local) | Rate | Source | Taken at | USD |
|---|---|---|---|---|---|---|
E-0004 | IC-1003 | 1.2M EUR | 1.25 | ECB | 2026-12-29 16:00 | 1.5M |
E-0007 | IC-1004 | 1.6M EUR | 1.25 | ECB | — | 2M |
An auditor asks two separate questions of any control:
- whether it was designed to catch what it claims, and
- whether it operated over the period.
REQ-301-C1 answers the second — the control ran, and it found breaks. REQ-301-C2 answers the first, and that answer is what gives an empty exception report its meaning: a check returning no rows reads the same as a check that never ran, until a scenario demonstrates it fires on the cases it should.
| reqId | COSO | scfId | controlId | methodology | control | requirement |
|---|---|---|---|---|---|---|
| REQ-301 | 13 | ORG-DCH-01 | REQ-301-C1 | EARS | FAIL | FAIL |
| REQ-301 | 13 | ORG-DCH-01 | REQ-301-C2 | Gherkin | PASS | FAIL |
| REQ-302 | 10 | HRS-11 | REQ-302-C1 | EARS | PASS | PASS |
| REQ-302 | 10 | HRS-11 | REQ-302-C2 | Gherkin | PASS | PASS |
| REQ-303 | 11 | MON-03.2 | REQ-303-C1 | EARS | FAIL | FAIL |
| REQ-303 | 11 | MON-03.2 | REQ-303-C2 | Gherkin | PASS | FAIL |
And the same run, rolled up to one row per requirement:
| reqId | cosoComponent | COSO | controls | satisfied | failed | status |
|---|---|---|---|---|---|---|
| REQ-301 | Information & Communication | 13 | 2 | 1 | 1 | FAIL |
| REQ-302 | Control Activities | 10 | 2 | 2 | 0 | PASS |
| REQ-303 | Control Activities | 11 | 2 | 1 | 1 | FAIL |
Results as OSCAL assessments
Let’s express the results following OSCAL’s assessment-results document.
An observation is what the procedure saw, a finding is what it means against the control objective. One exception, one observation. REQ-301-C1 failed on two references, so it produces two items.
Notice that all items are linked back to controls, requirement and SOX mandate.
{
"uuid": "1dd0aa3c-…",
"methods": ["TEST"],
"types": ["control-objective"],
"description": "IC-1002: no buyer leg recorded (variance 10M USD)",
"props": [
{ "name": "control-id", "value": "REQ-301-C1" },
{ "name": "requirement-id", "value": "REQ-301" },
{ "name": "mandate-id", "value": "SOX-404a" }
],
"links": [
{ "rel": "requirement", "href": "#b40221a3-…", "text": "REQ-301" },
{ "rel": "mandate", "href": "#6a8f88c8-…", "text": "SOX 404(a)" }
],
"collected": "2026-12-31T02:00:00Z",
"relevant-evidence": [{ "href": "sql://procedure/check_req_301#IC-1002" }]
},
{
"uuid": "8fe15dfb-…",
"methods": ["TEST"],
"types": ["control-objective"],
"description": "IC-1003: legs differ beyond tolerance (variance 60K USD)",
"props": [
{ "name": "control-id", "value": "REQ-301-C1" },
{ "name": "requirement-id", "value": "REQ-301" },
{ "name": "mandate-id", "value": "SOX-404a" }
],
"links": [
{ "rel": "requirement", "href": "#b40221a3-…", "text": "REQ-301" },
{ "rel": "mandate", "href": "#6a8f88c8-…", "text": "SOX 404(a)" }
],
"collected": "2026-12-31T02:00:00Z",
"relevant-evidence": [{ "href": "sql://procedure/check_req_301#IC-1003" }]
}
A finding stays single per control — it either holds or it does not — and names the observations it rests on.
In our example, REQ-301-C1 has two findings supporting the result:
{
"uuid": "fe4a4553-909e-46e0-8161-75098c6f07e5",
"title": "ORG-DCH-01 - Intercompany Matching & Validation (REQ-301-C1)",
"props": [
{ "name": "requirement-id", "value": "REQ-301" },
{ "name": "methodology", "value": "EARS" },
{ "name": "coso-component", "value": "Information & Communication" },
{ "name": "coso-principle", "value": "13" },
{ "name": "mandate-id", "value": "SOX-404a" }
],
"links": [
{ "rel": "requirement", "href": "#b40221a3-…", "text": "REQ-301" },
{ "rel": "mandate", "href": "#6a8f88c8-…", "text": "SOX 404(a)" }
],
"target": {
"type": "objective-id",
"target-id": "org-dch-01_obj",
"status": { "state": "not-satisfied", "reason": "fail" }
},
"related-observations": [
{ "observation-uuid": "1dd0aa3c-2507-4e30-ac41-0dbb94d167bf" },
{ "observation-uuid": "8fe15dfb-3b84-4272-acf7-4d62ba6582b3" }
]
}
Traceability works both ways:
a reader who starts from the IC-1002 observation navigates to REQ-301, then COSO Principle 13, then SOX 404(a). A reader who starts from SOX 404(a) obligation finds every requirement that has been defined to validate it, including controls and results.
Conclusions
Speak standards language. SOX states the obligation, COSO supplies the rubric that assertion is measured against, so a failure gets reported under a named principle instead of as a loose finding. SCF supplies the control content and the crosswalks that come with it. OSCAL turns the result into a document other systems can read.
EARS and Gherkin notation complements each other. EARS gives completeness: it compiles to an invariant that examines every row in the population and returns the ones that would have been rejected. Gherkin gives validation: named cases carrying the verdict each one is specified to produce, which proves the check fires on the breaks it claims to catch and stays quiet on a clean pair.
Compliance on correctly structured data is fast. Once a requirement is written precisely enough to compile and the data it governs is structured, the control is an ordinary query.
Unfortunately, not all data is structured. Very often, reconciliation deals with unstructured data sources as well, which is not covered in this post.
Coding Resources
As usual, sample code is available on Github.