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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions