okay-effects

Why everyone needs algebraic effects: audit by construction.

Every operation of a serious system has to be observable and provable: who did what, with what result, what failed, and whether it can be replayed. Often this is the law, for example DORA, SOX or MiFID II. With algebraic effects it is almost trivial. Every operation is typed data, and it reaches the world only through a handler. The audit is one more handler around it, and the business code does not change.

What the law asks of software

None of these laws mentions effects. Each one asks for something that, in software, comes down to knowing and controlling what the code does. The right column says what that is in code terms.

LawProvisionWhat it asksIn code terms
DORA
Regulation (EU) 2022/2554
Art. 8Identify, classify and document ICT-supported business functions, information and ICT assets, and the links and dependencies between them.Which code reaches which systems, written down and kept current.
Art. 9(4)Limit access to information and ICT assets to what legitimate, approved functions require.A function touches only what it is allowed to touch.
Art. 28(3)Keep a register of all contractual arrangements for ICT services from third-party providers.Which modules depend on which external providers.
PSD2
Directive (EU) 2015/2366
Art. 95A framework of risk-mitigation measures and control mechanisms for the operational and security risks of payment services, with an annual assessment of how well they work.Controls on every payment operation, and evidence that they ran.
MiFID II
Directive 2014/65/EU
Art. 16(6), 16(7)Keep records of all services, activities and transactions, sufficient for the supervisor to do its job; record the communications that lead to transactions, and keep them five years, up to seven if asked.Every operation recorded with its result, complete enough to reconstruct what happened.
MiFIR
Regulation (EU) 600/2014
Art. 25Keep the relevant data on all orders and transactions at the supervisor's disposal for five years.A trail of orders and transactions, produced by the code that executes them.
GDPR
Regulation (EU) 2016/679
Art. 5(2), 25, 30, 32Accountability: be able to demonstrate compliance. Data protection by design and by default. Records of processing activities. Security of processing.Personal data reached only through declared operations, masked where it leaves, every processing recorded.
AML Directive
Directive (EU) 2015/849
Art. 40Keep customer due-diligence data and transaction records for five years. Its successor, the AML Regulation (EU) 2024/1624, keeps the duty.A complete, durable trail of transactions, produced by the code that makes them.
AI Act
Regulation (EU) 2024/1689
Art. 12, 14; Annex III 5(b)For high-risk AI systems, including creditworthiness assessment: automatic logging of events over the system's life, and effective human oversight, up to stopping the system.Every action of an AI system or agent logged, checkable, and refusable.
NIS2
Directive (EU) 2022/2555
Art. 21Cybersecurity risk-management measures, supply-chain security among them.Known, bounded dependencies, and controlled access to the outside world.
Sarbanes–Oxley
US, Public Law 107–204
§ 404Management assesses, and auditors attest to, internal control over financial reporting.Controls on the operations that produce financial records, provable to an auditor.
PCI DSS
industry standard
Req. 7, 10Restrict access to cardholder data by business need to know. Log and monitor all access to system components and cardholder data.Card data reached only through declared operations, each access logged.

The provisions are summarised here. The linked official texts are what applies, and this page is not legal advice. Some obligations depend on the kind of firm and on dates of application.

Audit by construction: a bank

Imagine a bank whose operations are modelled like blockchain UTXO. Money is a set of coins, and each coin is spent once, atomically, so when two payments race for the same coins only one wins. The ledger is an effect: two operations, as data. The bank is a handler that carries them out against a table.

case class Coin(id: String, owner: String, value: Int)

enum Ledger[+A]:
  case Unspent(owner: String) extends Ledger[List[Coin]]
  case Spend(in: List[Coin], out: List[Coin]) extends Ledger[Boolean]
import Ledger.*

trait Table: // DynamoDB, say
  def unspent(owner: String): List[Coin]
  def spend(in: List[Coin], out: List[Coin]): Boolean // atomic, iff no input is spent

def bank[G[_], A](db: Table) = Handler[Ledger, A, A, G](Return(_),
  [X] => (op, k) => op match
    case Unspent(o) => k(db.unspent(o))
    case Spend(in, out) => k(db.spend(in, out)))

A payment knows only its effect. Nothing in it says where the coins live, and nothing in it says it is audited:

def pay(from: String, to: String, sum: Int): Eff[Ledger, Boolean] = for
  in <- perform(Unspent(from))
  total = in.map(_.value).sum
  tx = in.map(_.id).mkString("+")
  ok <- if total < sum then Return(false)
        else perform(Spend(in, List(Coin(tx + ">0", to, sum), Coin(tx + ">1", from, total - sum))))
yield ok

The audit wraps any handler. It records the intent first, then the result or the failure. This is write-ahead: with no record there is no operation.

enum Event:
  case Begin(op: Any)
  case End(op: Any, result: Any)
  case Fail(op: Any, error: Throwable)
import Event.*

trait AuditLog: // Kafka, say
  def append(e: Event): Unit
  def read: Iterator[Event]

def audited[F[_], A, B, G[_]](h: Handler[F, A, B, G], log: AuditLog) =
  Handler[F, A, B, G](h.ret, [X] => (op, k) =>
    log.append(Begin(op)) // write-ahead: no record, no operation
    var done = false
    try h.eff(op, x => { done = true; log.append(End(op, x)); k(x) })
    catch case e: Throwable => { if !done then log.append(Fail(op, e)); throw e })

val system = [A] => (p: Eff[Ledger, A]) => run(handle(audited(bank(db), log))(p))

system(pay("alice", "bob", 30))  // true

Replay needs no database. A handler answers each operation from the log, so a program run again gets the same answers. A program that does something different is caught at the first operation that differs:

def replay[G[_], A](log: AuditLog) =
  val ends = log.read.collect { case e: End => e }
  Handler[Ledger, A, A, G](Return(_), [X] => (op, k) => (op, ends.next()) match
    case (Unspent(o), End(Unspent(p), r: List[Coin] @unchecked)) if o == p => k(r)
    case (Spend(i, u), End(Spend(j, v), r: Boolean)) if i == j && u == v => k(r)
    case (op, e) => throw IllegalStateException(s"diverged: $op"))

val replayed = [A] => (p: Eff[Ledger, A]) => run(handle(replay(log))(p))

replayed(pay("alice", "bob", 30))  // true, from the log
replayed(pay("alice", "bob", 50))  // diverged: Spend(...)

Nobody else changes

Neither the payment nor the bank knows about the audit. It is added, or swapped for another, where the system is put together.

No record, no operation

The intent is written before the operation runs, and the result or failure after it. The trail is complete because there is no other way to the world.

Replay and divergence

The log is enough to rerun a program without its database, and to find where a new version behaves differently from the old.

The example runs on the Eff monad from the author's earlier post, and it is the whole mechanism. okay-effects provides it as a library, with more guarantees on top, described below.

In okay: the same idea, with guarantees

In okay, audit is part of the kernel and the types enforce it. A sensitive effect cannot run without its audit, an observer sees every effect, and the build checks the boundary in the bytecode.

RequirementMechanism in okayServes
Know what each part of the system may doThe type lists every effect a program may perform, for example Receipt ! Db + Payments. The compiler holds the program to that list.DORA 8, 9(4); GDPR 25
Sensitive actions only under controlAn effect marked Sensitive, such as payments, cannot be performed or handled outside its audit. A program that tries does not compile.PSD2 95; SOX 404; PCI DSS 7
A policy at the point of actionAn interceptor sees each operation, and can pass it on, change it (cap an amount, normalise an account, mask data) or refuse it.DORA 9(4); PSD2 95; AI Act 14
A trail made by the action itselfThe trail is an effect too. Each operation and its answer is recorded where it happens, and its handler sends the record wherever records are kept.AI Act 12; AML 40; GDPR 30; PCI DSS 10
Oversight of an AI agentAn observer sees every operation of every effect on its way out of a program, an agent's tool calls included. It can record them, wait for a person, or refuse.AI Act 12, 14
The dependency inventoryokay-audit checks the compiled bytecode at every build. Business code must reach no network, files, SQL, clock or threads on its own. It lists, by provider, the modules that do.DORA 8, 28(3); NIS2 21
Test, replay, investigateThe same program runs under test handlers, recording handlers and replaying ones. An incident is replayed from its trail, operation by operation.DORA's testing and incident duties; GDPR 32

The details are in okay-core's docs/intercept.md, and the same guarantee exists in Rust (okay-rust) and TypeScript (okay-ts). What okay gives is evidence and mechanism for your own compliance work. It is not a certification.

Getting there from an existing system

You do not rewrite the system. You start where the risk is.

Step 1

One sensitive effect

Take payments, customer data or model calls, and make that one an effect with a handler over the code that exists today. Then add its audit and its trail.

Step 2

The boundary in CI

Run okay-audit at every build. The report shows which modules reach the outside and how, so the dependency inventory comes from the build, not from a spreadsheet.

Step 3

Widen as you go

Move more of the code onto effects module by module. The same boundaries are where a monolith splits into services. It interoperates with cats, ZIO, fs2 and kyo on Scala, and the design exists for Java, Go, Rust, Python and TypeScript.

Help with this is available, as a team member or as a contract consultant: the author.