okay-effects

Scala and Spark: from tangled code to one transparent, controllable system.

I have spent years in Scala and Spark codebases, and I know where they hurt. The code is hard to read and seems impossible to improve or move to new versions. The ecosystem is split between Spark, ZIO, Cats and the rest. Monitoring, control and audit are hard, and hardest for Spark jobs. This page names those problems and shows how I solve them: by making what the code does explicit, one part at a time, until systems on different platforms, and even in different languages, become one system that can be watched and controlled.

The problems everyone has with Scala and Spark today

None of these is anyone's fault. They are what years of growth do to a codebase and to an ecosystem.

Code

Tangled code nobody dares to touch

Implicits several layers deep, stacks of monad transformers, business logic mixed with I/O, and Spark jobs thousands of lines long. It works, but nobody can say what it does without running it. Moving it from Scala 2.12 to 2.13 or 3, or from Spark 3 to 4, looks like a rewrite, so it does not happen.

Ecosystem

Fragmented solutions

ZIO, cats-effect, Future, kyo and monix each bring their own effect type, their own way of handling errors and resources, and their own community. A company ends up with several of them in one system, glue code between them, and teams that cannot read each other's code.

Operations

Monitoring and control are bolted on

What a service calls, what a job reads and writes, which external systems it reaches: all of this is reconstructed from logs, metrics and listeners added afterwards. Changing a policy, such as a limit, a retry or a masking rule, means changing the code everywhere.

Spark

Spark jobs are the hardest to audit

The event log says that a job ran. It does not prove what the job produced. Tests need a Spark session, so the logic is tested late or not at all. Scala 3 types do not fit Spark's encoders, which pushes teams to stay on old versions.

Across the company

Many platforms, many languages, no common control

JVM services, Python data science, Go and Rust services, a TypeScript front end. Each has its own way of calling the world, so there is no single place to see, limit or audit what the whole system does.

What I offer

One idea runs through all of it. What code does to the world (a call, a read, a write, a payment, a model query) becomes an explicit operation, and a handler carries it out. That gives each action a place where it can be seen, tested, controlled and recorded, and the logic around it stays as it is.

1. A way out of tangled code, one step at a time

Wrap the code's dependencies as effects at its existing seams. The logic keeps its shape and becomes testable with handlers instead of mocks and containers. Then improve it piece by piece, and split it into services along the same boundaries when that pays. okay-simple is one interface for Scala 2.13 and Scala 3, from 3.3 LTS on. One application can mix code compiled by both, so an upgrade happens module by module, not in one big step.

2. One interface over ZIO, Cats, kyo, fs2 and monix

Teams keep their libraries, and their code meets in one program. Below, a value comes from a ZIO service, goes to a cats-effect service, and its answer goes back to ZIO. ZIO's tasks and Cats' IO are two effects of one row. One handler runs each IO as a ZIO task, so the whole program becomes one ZIO, run by ZIO's runtime. The same program could become an IO instead: only the handler would change.

import zio.{Runtime, Task, Unsafe, ZIO}
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import okay.effects.*
import okay.effects.std.*
import okay.effects.ziointerop.*

// ZIO's tasks and cats-effect's IO, in one row
type IOs[X] = Lift[IO, X]
type Both[X] = (Tasks + IOs)[X]

def loadUser(id: Int): Task[String] = ZIO.succeed(s"user-$id")                     // a ZIO service
def riskScore(user: String, amount: Int): IO[Int] = IO(amount / 100)               // a cats-effect service
def decide(user: String, score: Int): Task[String] =                                // a ZIO service again
  ZIO.succeed(if score > 50 then s"$user: review" else s"$user: approve")

// one program: from ZIO, into Cats, its answer back into ZIO
def check(id: Int, amount: Int): String ! Both =
  for
    user     <- fromZIOTask[String, Both](loadUser(id))
    score    <- lift[IO, Int, Both](riskScore(user, amount))
    decision <- fromZIOTask[String, Both](decide(user, score))
  yield decision

// one handler: each IO run as a ZIO task, so the whole program is one ZIO
def ioAsZio[A](p: A ! Both): A ! Tasks =
  handleLast[IOs, Tasks, A, A](p, new Handler[IOs, Tasks, A, A]:
    def ret(a: A) = pure(a)
    def op[X](op: IOs[X], k: X => A ! Tasks) = op match
      case Lift.Of(io) => fromZIOTask[X, Tasks](ZIO.fromFuture(_ => io.unsafeToFuture())).flatMap(k))

// the result: a ZIO, run by ZIO's runtime
val zio: Task[List[String]] = toZIOTask(ioAsZio(for a <- check(7, 2500); b <- check(8, 9000) yield List(a, b)))
Unsafe.unsafe { implicit u => Runtime.default.unsafe.run(zio).getOrThrow() }
// List(user-7: approve, user-8: review)

Compiled and run against the released 0.1.0 (okay-effects-zio, with cats-effect) on Scala 3.3 and 3.9. More is available: a ZIO's environment, typed error and run as standard effects, with its layers and cancellation in both directions; ZIO's own types as effects; cats' Monad for any program; kyo, and fs2, ZIO and monix streams both ways. See interop.

3. Scala 3 and algebraic effects on Spark

Spark jobs in Scala 3, today: Scala 3 types, enums and sums included, become DataFrames, where Spark's own encoders do not compile in Scala 3 (how). Business rules written with effects run inside Spark's tasks (how). A Spark job written against Bulk runs on a cluster or in one JVM with the same answer, so its logic is tested without Spark. Scala 3 types become DataFrames through SparkSchema. Every output can come with evidence: per partition, a count and a SHA-256 digest, committed only after the sink acknowledges it. The details are below.

4. Monitoring, control and audit as part of the code

An interceptor sees each operation of an effect and can record, change or refuse it. An observer sees every operation of every effect. A sensitive effect, such as payments, cannot run outside its audit, and the compiler checks that. okay-audit checks in the bytecode, at every build, that business code reaches the outside world only through declared effects. A policy is changed in one handler, not across the codebase. See what this means for compliance.

5. One transparent system across platforms and languages

The same design exists for Java, Go, Rust, Python and TypeScript. okay-kernel is a protocol between them: a program written in one language runs under handlers written in another, in one process or over a wire. In the demo, a shop written in Go has its inventory, payments and mail answered by Scala handlers. Every operation that crosses has a name and a type, so the controls above apply to it wherever it came from. The whole company's software can be watched, limited and audited at one point.

Operations as data, through the audit, to handlers, into the trail

Scala 3 on Spark, today

Spark is published for Scala 2.13 only. A Scala 3 build can use it ("org.apache.spark" %% "spark-sql" % "4.2.0" cross CrossVersion.for3Use2_13), and its untyped API, RDDs and DataFrames, works. Its typed side does not. import spark.implicits._, toDF, Dataset[T] and as[T] derive their encoders by Scala 2 TypeTag reflection, which Scala 3 does not have. In a Scala 3 build, this line does not compile:

import spark.implicits.*
Seq(("acct-1", 100L), ("acct-2", 250L)).toDF("account", "amount")
// Scala 3:
// [error] value toDF is not a member of Seq[(String, Long)]

That is a large part of why Spark codebases stay on Scala 2.12 and 2.13. Even in Scala 2, Spark's encoders do not take enums or sealed sums, so domain types are flattened into strings and nullable columns by hand.

With okay: Scala 3 types are DataFrames

SparkSchema derives the StructType and the rows from an okay Schema[A], which a Scala 3 type gets with derives Schema. It handles the types Spark's encoders cannot:

import okay.codec.Schema
import okay.spark.SparkSchema

enum Credential derives Schema:
  case KeyHash(hash: Array[Byte])
  case ScriptHash(hash: Array[Byte])
final case class Output(id: Int, owner: Credential, lovelace: BigInt) derives Schema

val df = SparkSchema.dataFrame(spark, Seq(
  Output(1, Credential.KeyHash(Array[Byte](1)), BigInt(2_000_000)),
  Output(2, Credential.ScriptHash(Array[Byte](2)), BigInt(5_000_000))))
// owner: struct<kind: string, KeyHash: struct<hash: binary>, ScriptHash: struct<hash: binary>>

df.createOrReplaceTempView("outputs")
spark.sql("SELECT id, lovelace FROM outputs WHERE owner.kind = 'ScriptHash'").collect()
// Array([2,5000000])

This is the example from okay-spark's documentation, and okay's test TestDocExamplesSparkSchema runs it. With it, SparkFrames.load[A](df) and frame[A](table) move typed tables in and out of a program, and a Bulk program runs on a cluster or in one JVM.

Move to Scala 3 now

The jobs move to Scala 3 module by module. Spark stays as it is, with no wait for a Scala 3 build of Spark.

Your domain types, as they are

Enums, sealed sums and the rest of Scala 3 can be used in jobs. The schema follows the type, and it evolves safely when a case is added.

SQL still works

The result is an ordinary DataFrame, so spark.sql, Parquet, Delta and every Spark tool see normal columns and structs.

Algebraic effects on Spark

Effects work on Spark at two levels.

Around the job, on the driver

The job's reads and writes are operations of an effect, its I/O is one handler and its audit is another, as shown in adding audit to an existing job. That is where datasets are recorded, policies refuse what a job may not do, and tests replace the lake.

Inside the tasks, on the executors

Business rules can be written with effects too, and run per record inside Spark's tasks. A rule that may refuse a record (Error) and add a note (Writer) is a small program. Its handlers turn each record's outcome into a value:

final case class Trade(account: String, amount: Long)

// a business rule, written once with effects: a raise refuses, a tell notes
type Check[X] = (Error % String + Writer % String)[X]

object rules extends std.On[Check]:
  def check(t: Trade): Trade ! Check =
    for
      _ <- if t.amount <= 0 then raise(s"${t.account}: amount ${t.amount} refused") else pure(())
      _ <- if t.amount > 10000 then tell(s"${t.account}: large amount ${t.amount}") else pure(())
    yield t

/** the rule handled: each record's outcome a value — refused, or passed with its notes */
def outcome(t: Trade): Either[String, (List[String], Trade)] = run(attempt[String](writer[String](rules.check(t))))
// the same rule in a plain unit test, no Spark
outcome(Trade("acct-1", -5))       // Left(acct-1: amount -5 refused)
outcome(Trade("acct-2", 20000))    // Right((List(acct-2: large amount 20000),Trade(acct-2,20000)))

// on Spark: outcome is shipped to the executors; each task builds, handles and runs one program per record,
// so the RDD holds plain values, Either[String, (List[String], Trade)], and Spark sees an ordinary function
val checked = trades.rdd.map(r => Trade(r.getString(0), r.getLong(1))).map(outcome)

// on the driver, in this demo: the outcomes collected and printed
val (refused, passed) = checked.collect().partition(_.isLeft)
println("refused: " + refused.map(_.left.toOption.get).mkString("; "))   // refused: acct-3: amount -7 refused
println("notes:   " + passed.flatMap(_.toOption.get._1).mkString("; "))   // notes:   acct-2: large amount 25000
println("passed:  " + passed.map(_.toOption.get._2.account).mkString(", ")) // passed:  acct-1, acct-2, acct-4

Where the effects are handled: in outcome, inside each task. rules.check(t) only describes a program. writer collects its notes into a list, attempt turns a refusal into a Left, and run runs what is left, which has no effects. So each record's result is a plain value by the time it leaves the task. In a real job the three outcomes go to their own tables straight from the RDD, with no collect: shown below.

The rule is one piece of code. It is unit-tested on its own, it runs on the executors unchanged, and it can be audited or have its policy changed by another handler, without touching the job. This was run on Spark 4.2.0 (local[2]) with okay-effects 0.1.0. The program is in examples/spark-audit (Rules.scala).

The outcomes handled in the job: split, decided on, written

The same rule, now in a job that handles its outcomes itself, with no collect. Each record's Either is routed into a dataset: refusals, notes and passed trades. The job then decides at its own level: with too many refusals it stops with raise, and the passed trades are not published. Effects work at both levels at once. The rule runs on the executors, per record. The job's reads, writes and refusal are effects on the driver, under the same audit handler, so every dataset written is in the trail with its row count and digest.

object daily:
  // the job's own effects: the lake, and a refusal of the whole run
  type Job[X] = (Lake + Error % String)[X]

  def read(path: String): DataFrame ! Job = perform[Lake](Lake.Read(path))
  def write(df: DataFrame, path: String): Unit ! Job = perform[Lake](Lake.Write(df, path))
  def stop(why: String): Unit ! Job = raise[String, Job, Unit](why)

  private val reasons = StructType(Seq(StructField("reason", StringType)))
  private val tradesSchema = StructType(Seq(StructField("account", StringType), StructField("amount", LongType)))

  /** the rule per record on the executors; its outcomes split, decided on and written, all in the job */
  def checkTrades(lake: String, day: String, maxRefused: Long): Unit ! Job =
    for
      trades  <- read(s"$lake/trades/day=$day")
      spark    = trades.sparkSession
      checked  = trades.rdd.map(r => Trade(r.getString(0), r.getLong(1))).map(outcome).cache()
      refused  = spark.createDataFrame(checked.flatMap(_.left.toOption).map(Row(_)), reasons)
      notes    = spark.createDataFrame(checked.flatMap(_.toOption.toList.flatMap(_._1)).map(Row(_)), reasons)
      passed   = spark.createDataFrame(checked.flatMap(_.toOption.map(_._2)).map(t => Row(t.account, t.amount)), tradesSchema)
      _       <- write(refused, s"$lake/refused/day=$day")
      _       <- write(notes, s"$lake/notes/day=$day")
      n        = refused.count()
      _       <- if n > maxRefused then stop(s"$n refused, more than $maxRefused: passed trades not published")
                 else write(passed, s"$lake/passed/day=$day")
    yield ()
// the handlers: the audit, Spark's I/O, and the run's refusal as an Either
run(attempt[String](onSpark(spark)(audited(log, s"check-$day")(daily.checkTrades(lake, day, maxRefused = 5)))))
// Right(())
//   read  …/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint>
//   write …/refused/day=2026-10-18: 1 rows, digest 889b1b6062f0c029…
//   write …/notes/day=2026-10-18: 1 rows, digest 1481fde4ce6ce393…
//   write …/passed/day=2026-10-18: 3 rows, digest e98689ed05d1bba8…

run(attempt[String](onSpark(spark)(audited(log, s"check-$day")(daily.checkTrades(lake, day, maxRefused = 0)))))
// Left(1 refused, more than 0: passed trades not published)
//   read  …/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint>
//   write …/refused/day=2026-10-18: 1 rows, digest 889b1b6062f0c029…
//   write …/notes/day=2026-10-18: 1 rows, digest 1481fde4ce6ce393…

Run on Spark 4.2.0 with okay-effects 0.1.0: examples/spark-audit (RulesJob.scala). With maxRefused = 5 the passed table holds [acct-1,100] [acct-2,25000] [acct-4,40]. With maxRefused = 0 it is not written, and the trail shows exactly what was.

Spark in detail

One aggregation, local or distributed

An okay Aggregator (init, add, merge, present) maps onto Spark's zero, seqOp and combOp. The same value runs over a local collection, over an RDD, per key, or as a Dataset column. It is written and tested once.

import okay.spark.SparkInterop.*

val stats = Aggregator.mean[Double].zip(Aggregator.variance[Double])

val (m1, v1) = stats.run(data)                              // locally
val (m2, v2) = aggregate(sc.parallelize(data, 8))(stats)    // the SAME value, distributed
val byKey = aggregateByKey(pairs)(Aggregator.variance[Double])
ds.select(toSpark(stats).toColumn)                          // as a typed Dataset column

One program, a cluster or one JVM

A job written against Bulk[D] does not name its platform. On SparkBulk it is a cluster job. On localBulk it runs in one JVM and gives the same answer, which is how it is tested. okay's own tests check the two against each other.

def revenue[D[_]](using B: Bulk[D]) =
  B.csv("sales.csv").map(r => r("shop") -> r("amount").toLong)
    .join(B.csv("shops.csv").map(r => r("id") -> r("city")))
    .aggregate(Aggregator.groupBy((kv: (String, (Long, String))) => kv._2._2)(
      Aggregator.sum[Long].contramap(_._2._1)))

revenue(using SparkBulk(spark))   // a cluster
revenue(using okay.localBulk)     // one JVM, the same answer

Scala 3 types as DataFrames

SparkSchema turns an okay Schema[A] into a StructType and rows. That covers Scala 3 enums, which Spark's ExpressionEncoder does not see:

val df = SparkSchema.dataFrame(spark, Seq(
  Output(1, Credential.KeyHash(Array[Byte](1)), BigInt(2_000_000)),
  Output(2, Credential.ScriptHash(Array[Byte](2)), BigInt(5_000_000))))
// owner: struct<kind: string, KeyHash: struct<hash: binary>, ScriptHash: struct<hash: binary>>
df.createOrReplaceTempView("outputs")
spark.sql("SELECT id, lovelace FROM outputs WHERE owner.kind = 'ScriptHash'").collect()
// Array([2,5000000])

Catalyst where it counts

With SparkFrames, a DataFrame-born table stays in Catalyst while it meets only structural operators: a predicate becomes a Column (a pushed filter on Parquet), and a join becomes a DataFrame join.

Sketches as aggregators

HyperLogLog, count-min and quantiles (okay-data) are aggregators, so approximate distinct counts and percentiles run distributed with no extra code.

Delta Lake without Spark

okay-delta creates tables and appends to them through delta-kernel. Its loadId uses Delta's transaction identifier, so a load lands once even when it is retried. A small producer needs no cluster.

Audit in Spark: evidence of what was written

Spark's event log and listeners record what ran: jobs, stages, task times. They do not prove what came out. SparkEvidence does. For each partition it records the partition's id, its successful task attempt, its row count and a length-framed SHA-256 digest of its records. It folds these into a run digest and hands the manifest to your sink. The result is Committed only after the sink has durably committed and acknowledged.

object SparkEvidence:
  trait Canonical[-A] extends Serializable:
    def bytes(value: A): Array[Byte]          // a record's deterministic bytes: yours

  final case class Partition(id: Int, attempt: Long, count: Long, digest: String)
  final case class Manifest(runId: String, partitions: Vector[Partition], digest: String)
  final case class Receipt(output: String, acknowledgement: String)
  final case class Committed(manifest: Manifest, receipt: Receipt)

  def run[A](rdd: RDD[A], runId: String)(canonical: Canonical[A])(sink: Manifest => Receipt): Committed

How to add it to a job, with what it prints: step by step, below.

Reproducible

The same input, partition layout and record order give the same digest, so rerunning the job proves that the output is the same. Attempt ids are kept for diagnosis and never choose a winner.

No acknowledgement, no evidence

A failed sink produces no Committed value, so evidence never claims an output that was not stored. Empty partitions are counted too.

No data in the logs

Only counts and digests leave the tasks. Payloads and credentials do not enter task logs.

Around the job: its reads and writes as effects

With SparkFrames, loading, reading and handing back a DataFrame are effects of the program. The audit-by-construction pattern then applies to a Spark job as it does to a bank. A handler around them records each dataset read and written, intent first and result after, and the job's code does not change. Step by step, below. Together with the evidence manifest this answers the questions an auditor asks: what went in, what came out, and whether it can be shown to be the same again.

Asks forWhereWhat answers it
Accurate, complete risk data, reconcilable to its sourcesBCBS 239, principles 3 and 4Per-partition counts and digests, and a run digest to reconcile against
Records of transactions sufficient for the supervisorMiFID II Art. 16(6); MiFIR Art. 25Evidence of each end-of-day output, and the job's reads and writes recorded as effects
Known data flows and ICT dependenciesDORA Art. 8Each dataset read and written, as a declared operation

Durable storage, identity and access, signatures and regulatory policy stay with the application. The adapter commits summaries and does not claim atomicity with a separate Spark write. An application that needs both provides a sink that commits output and manifest in one transaction.

Adding audit to an existing Spark job

Take a job as it is today: read trades, total them per account, write the totals. Audit can be added in two steps, and the job's logic stays as it is in both. The code below was compiled and run on Spark 4.2.0 (local[2], JDK 25) and Scala 3.3, with okay-effects 0.1.0 from its release and SparkEvidence from okay-spark. The outputs shown are what it printed.

The job today

def dailyTotalsToday(spark: SparkSession, lake: String, day: String): Unit =
  val trades = spark.read.parquet(s"$lake/trades/day=$day")
  val totals = trades.groupBy("account").agg(sum("amount").as("total"))
  totals.write.mode("overwrite").parquet(s"$lake/totals/day=$day")

Step 1. Evidence of the output, with no other change

Give the output a deterministic layout: hash-partitioned by its key and sorted within each partition. The same data then always gives the same digest. Write it as before, and hand it to SparkEvidence, which records each partition's count and SHA-256 digest and a digest for the whole run. The manifest goes to your sink: here a file beside the data, in production a ledger, a bucket with object lock, or a signed record. Later, anyone can read the stored output back, recompute the digest and compare.

val rowBytes: Canonical[Row] = row => row.mkString("\u001f").getBytes(UTF_8)
def stable(df: DataFrame): DataFrame = df.repartition(8, col("account")).sortWithinPartitions("account")

def dailyTotalsWithEvidence(spark: SparkSession, lake: String, day: String): Committed =
  val trades = spark.read.parquet(s"$lake/trades/day=$day")
  val totals = stable(trades.groupBy("account").agg(sum("amount").as("total")))
  val out = s"$lake/totals/day=$day"
  totals.write.mode("overwrite").parquet(out)
  SparkEvidence.run(totals.rdd, runId = s"totals-$day")(rowBytes) { m =>
    // the manifest beside the data; in production a ledger, a bucket with object lock, a signed record
    java.nio.file.Files.writeString(java.nio.file.Path.of(s"$lake/totals-$day.evidence"), s"${m.digest} ${m.partitions.map(_.count).sum}")
    Receipt(output = out, acknowledgement = m.digest)
  }

/** later, by anyone: the stored output read back, its digest recomputed and compared */
def verify(spark: SparkSession, out: String, runId: String, expected: String): Boolean =
  SparkEvidence.run(stable(spark.read.parquet(out)).rdd, runId)(rowBytes)(m => Receipt(out, m.digest)).manifest.digest == expected

Run as it ran:

dailyTotalsWithEvidence(spark, lake, day)
// evidence: 3 rows, 8 partitions, digest 6616d58871415ec1…
verify(spark, out, s"totals-$day", digest)            // true
// a row appended to the stored output by hand, then:
verify(spark, out, s"totals-$day", digest)            // false

Step 2. The job's reads and writes as effects, and the audit a handler

Name what the job does to the outside world: it reads a dataset and writes one. These become two operations of an effect, Lake. The logic is the same lines, with read and write in place of Spark's calls:

enum Lake[X]:
  case Read(path: String) extends Lake[DataFrame]
  case Write(df: DataFrame, path: String) extends Lake[Unit]

def read(path: String): DataFrame ! Lake = perform[Lake](Lake.Read(path))
def write(df: DataFrame, path: String): Unit ! Lake = perform[Lake](Lake.Write(df, path))

/** the same job: its logic as it was, its reads and writes now operations */
def dailyTotals(lake: String, day: String): Unit ! Lake =
  for
    trades <- read(s"$lake/trades/day=$day")
    totals = trades.groupBy("account").agg(sum("amount").as("total"))
    _ <- write(stable(totals), s"$lake/totals/day=$day")
  yield ()

One handler does the I/O, exactly as the job did before:

/** the handler doing the I/O: Spark, as before */
def onSpark[R[_], Rest[_], A](spark: SparkSession)(p: A ! R)(using Split[Lake, R, Rest]): A ! Rest =
  handle(p, new TailHandler[Lake, Rest, A, A]:
    def ret(a: A) = pure(a)
    def answer[X](op: Lake[X]): X = op match
      case Lake.Read(path) => spark.read.parquet(path)
      case Lake.Write(df, path) => df.write.mode("overwrite").parquet(path))

The audit is another handler, put around the job. It records each read and write before it happens, then its result: row count, schema, and for each output its evidence digest. The job does not know it is audited, and Spark's handler does not know either.

trait AuditLog:
  def append(line: String): Unit

/** the audit: every read and write recorded, intent first; each output with its evidence */
def audited[R[_], A](log: AuditLog, runId: String)(p: A ! R)(using In[Lake, R]): A ! R =
  intercept(p, new Handler[Lake, R, A, A]:
    def ret(a: A) = pure(a)
    def op[X](op: Lake[X], k: X => A ! R) = op match
      case Lake.Read(path) =>
        log.append(s"read  $path")
        perform[Lake].apply[R, X](op).flatMap { df =>
          log.append(s"read  $path: ${df.count()} rows, ${df.schema.simpleString}"); k(df) }
      case Lake.Write(df, path) =>
        log.append(s"write $path")
        perform[Lake].apply[R, X](op).flatMap { done =>
          val e = SparkEvidence.run(df.rdd, runId)(rowBytes)(m => Receipt(path, m.digest)).manifest
          log.append(s"write $path: ${e.partitions.map(_.count).sum} rows, digest ${e.digest.take(16)}…"); k(done) })
val log: AuditLog = line => auditTopic.send(line)     // Kafka, a table, a ledger: the handler's choice
run(onSpark(spark)(audited(log, s"totals-$day")(dailyTotals(lake, day))))

The audit trail of that run. Its digest is step 1's: the same data, proven the same.

read  /tmp/spark-audit/lake/trades/day=2026-10-18
read  /tmp/spark-audit/lake/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint>
write /tmp/spark-audit/lake/totals/day=2026-10-18
write /tmp/spark-audit/lake/totals/day=2026-10-18: 3 rows, digest 6616d58871415ec1…

What the auditor gets

Every dataset read and written, with row counts and schemas, in the order they happened, and the output's digest. The stored output can be checked against the digest at any time later.

Policies at the same point

The audit handler can also refuse an operation, for example a write outside the paths a job is allowed, or a read of a table a job may not see, before Spark runs it.

Tests without a cluster

With its reads and writes as effects, the job's logic runs under a test handler that serves small DataFrames and records the writes, with no data lake behind it.

The full program, data set-up included, is about a hundred lines: examples/spark-audit (sbt run). Two things are left to the application: the canonical bytes of its records (here the row's fields joined) and where the manifest and the trail are kept.

Status

Ready, with tests

  • Aggregator on RDDs, per key and as Dataset columns
  • SparkBulk: a Bulk program on a cluster, checked against one JVM
  • SparkFrames and SparkSchema
  • SparkEvidence
  • okay-delta

Spark 4.2 on JDK 17, 21 and 25.

Done per project

  • The effects of your jobs' reads and writes, their audit handler (as shown above) and where its trail goes
  • The canonical encoding of your records and the evidence sink: storage, signatures, retention
  • Bringing an existing Spark codebase onto this, one job at a time

okay-spark is part of okay. It builds from source today; its release is planned with okay 0.2.0.

I do this work as a team member or as a contract consultant on Scala and JVM projects: contact.