Skip to content

Adopt capture checking: capture-aware fork/Flow signatures, tracked Ox capability #469

Description

@adamw

Motivation

In orca we split side-effect gating into capability tokens — a shared one that may legally cross fork boundaries (LLM-call gate, caps.SharedCapability) and exclusive ones that must not (workspace/git-index writers, caps.ExclusiveCapability) — and we want Scala's capture + separation checking to reject an exclusive capability captured into a fork closure at compile time.

That works today, but only through a wrapper, because of an empirically verified limitation: separation checking does not fire through combinators of a library that was not itself compiled under capture checking. Calling ox.fork or ox.flow.Flow.mapParUnordered from a CC-enabled file yields zero enforcement — the checker needs the combinator's own signature to declare what the closures may capture.

Our bridge is CheckedPar — a 1:1 delegate to Flow.mapParUnordered, compiled under captureChecking + separationChecking, whose capture-set parameter (thunks: Seq[() ->{C} T] with C^) makes the call site checkable. With it, a closure capturing an exclusive capability is a hard "Separation failure" compile error while shared capabilities pass — both directions pinned by a dotc-in-test negative-compile suite. The wrapper is explicitly a bridge designed for deletion once Ox carries the annotations natively (design record: ADR 0018 §6).

What Ox would need

  1. Capture-aware signatures on the closure-accepting concurrency APIfork/forkUser/etc., supervised/unsupervised bodies, and the Flow parallel combinators (mapPar, mapParUnordered, …), so separation checking evaluates each caller's closure captures at the call site. In our experiments, plain =>-style params on a CC-compiled combinator suffice for the hidden-set rules to engage; an explicit capture-set type parameter (as in CheckedPar) gives callers something to bind.
  2. Track the Ox capability itself — so a fork handle or Ox-scoped resource escaping its supervised scope becomes a compile error. Downstream this would also let us make "agent value outlives its owning scope" a compile error rather than a runtime latch.

Findings from our adoption that may save you time (all verified on stock Scala 3.8.4, no nightly, no flags)

  • caps.Capability is sealed; caps.SharedCapability is non-experimental (extendable with zero language imports), caps.ExclusiveCapability is @experimental (defining files need the CC import).
  • No consumer taint for definitions: a jar built from CC-importing files is consumable by plain non-CC consumers with zero flags. But a signature carrying a capture-set parameter (C^) is only callable from CC-enabled compilation units — an API-surface consequence worth designing around (it's why CheckedPar is deliberately not on orca's exported surface).
  • Enforcement locus is the caller's compilation unit: both the combinator and each calling file need the language imports for violations to be caught.
  • Tooling caveats: scalafmt (3.8.3) cannot tokenize the ^ syntax (per-file exclusion needed); tapir's derives schema derivation doesn't typecheck under CC (keep macro-deriving types out of CC-compiled files); munit's compileErrors/typeCheckErrors are blind to CC violations (CC runs post-typer) — negative tests need separate compilation.

Happy to share more details from the adoption or test fixtures if useful.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions