Skip to content

Capabilities and Destructuring

Accepted

Complete.

Catalyst does not use a separate needs / use dependency system in V1. Capabilities are explicit operational authority values passed through ordinary parameters. To keep this ergonomic and honest, Catalyst relies on general struct destructuring for capability records.

Capabilities include allocation, diagnostics, IO, filesystem access, logging, clocks, randomness, interning, cancellation, host access, and similar handles. "Capability" is a documentation and linting convention, not a language-level parameter category.

Problem

Large systems often thread the same capabilities through many functions:

fn parse(alloc: *impl Allocator, diag: *Diagnostics, interner: Interner, source: Source) Ast!ParseError

Plain parameters are honest, but they become noisy and order-sensitive. Different libraries can disagree about whether allocator, diagnostics, IO, or interner parameters come first, and the signature starts to obscure the domain inputs.

Broad context objects solve the call-site noise but can hide capability use:

fn parse(ctx: *CompilerContext, source: Source) Ast!ParseError

If CompilerContext contains allocation, diagnostics, IO, logging, and filesystem access, a function that only needs diagnostics appears to receive everything.

Parameter Order

Capabilities conventionally occupy the first non-comptime, non-receiver parameter slots:

fn parse({ alloc, diag }: *const CompilerContext, source: Source) Ast!ParseError {
}

For methods and contract operations, the receiver stays first:

fn write(self: *Self, io: *Io, bytes: []const u8) usize!WriteError {
}

Convention only

This is a style convention only. A lint can flag likely violations, but it must be heuristic because capabilities are ordinary parameters. Active design-doc examples should follow this order unless they intentionally demonstrate legacy, invalid, or deferred forms.

Context Records Are Convention

The V1 direction is to use ordinary structs as context records by convention and destructure only the fields a function needs:

const CompilerContext = struct {
  alloc: *dyn Allocator
  diag: *Diagnostics
  interner: Interner
}

fn parse({ alloc, diag }: *const CompilerContext, source: Source) Ast!ParseError {
}

The context record is not a distinct type category and has no special compiler behavior. It is just an ordinary struct parameter. The destructuring pattern states which fields the function uses and binds them as locals. Call sites pass one ordinary value:

var ast = try parse(&ctx, source)

This keeps capabilities visible in the function signature while avoiding long, order-sensitive parameter lists at call sites.

The alloc: *dyn Allocator field above is an explicit erased allocator boundary because struct fields require concrete runtime types in V1. APIs that take allocator authority directly should prefer static dispatch with alloc: *impl Allocator.

Context records that do not need allocator erasure should use concrete allocator field types or a generic context type.

As a convention, context records are for bundling multiple related capabilities. If a function needs only one capability, pass that capability directly instead of creating a one-field context wrapper:

fn allocate_node(alloc: *impl Allocator, value: Node) *Node!AllocError {
}

fn report(diag: *Diagnostics, error: ParseError) void {
}

Context-style structs should store borrowed handles such as pointers when the callee should not take ownership of a capability. Destructuring follows ordinary field-access rules and does not secretly borrow non-copy fields.

Capability records are passed by pointer by convention. Use *const Context when the function only reads field slots, and *Context only when it mutates those slots. A *const Context may still expose mutable capability handles, such as a field whose type is *Diagnostics.

Structural Capability Constraints

Domain code should prefer concrete context records. Generic or reusable library code may use a structural capability constraint when it only needs an open set of capability fields:

const ParseCapabilities = satisfies(.{
  alloc: *dyn Allocator,
  diag: *Diagnostics,
})

fn parse_any({ alloc, diag }: *const impl ParseCapabilities, source: Source) Ast!ParseError {
}

This accepts any concrete context type with visible assignable alloc and diag fields. Extra fields are allowed:

const AppContext = struct {
  alloc: *dyn Allocator
  diag: *Diagnostics
  fs: *FileSystem
  clock: *Clock
}

var ast = try parse_any(&app_ctx, source)

The anonymous *const impl ParseCapabilities form is function declaration parameter shorthand for an implicit concrete context type satisfying the structural constraint. It has no runtime vtable or erased structural representation.

Use read-only satisfies(...) field requirements for normal capability records. Use mutable_fields(...) only for APIs that assign to the context record's field slots.

Required capability fields should be required fields. Optional fields are only for behavior that is genuinely optional. Structural capability constraints use source-visible field requirements; they do not project capabilities through methods:

const NeedsDiag = satisfies(.{
  diag: *Diagnostics,
})

Do not model V1 capability passing as a universal std.Context or broad optional bag. A function should ask for the concrete or structural capabilities it requires.

Field names are part of a structural capability constraint's public shape. Renaming diag to diagnostics is a breaking API change for callers using that constraint.

Destructuring

Capability records use ordinary struct destructuring. The canonical destructuring rules live in Destructuring, and parameter-specific rules live in Destructuring Parameters.

For context records, struct destructuring is preferred because it is name-based. That avoids allocator/IO/diagnostics order drift across APIs:

fn parse({ alloc, diag }: *const CompilerContext, source: Source) Ast!ParseError {
}

Use field renaming when adapting legacy or domain-specific records:

fn parse({ allocator = alloc, diagnostics = diag }: *const LegacyContext, source: Source) Ast!ParseError {
}

Partial struct destructuring is allowed by the general destructuring rules. A capability-using function should bind only the fields it needs; unmentioned fields are ignored.

Capability Semantics

Capabilities use ordinary function and destructuring semantics:

  • a destructured capability parameter is still an ordinary parameter
  • the caller passes an ordinary value
  • no capability is inferred from the function body
  • no ambient scope provides hidden capabilities
  • no separate function effect channel is introduced
  • a "context record" is not a language-level category

If a function needs allocation, diagnostics, IO, logging, or other caller-supplied capabilities, those capabilities should be reachable through its ordinary parameters. Capabilities may also be owned by a receiver, stored in fields, exposed through globals, or created locally, but project profiles may lint globals or other hidden capability sources.

Capability conventions do not affect function type identity. Parameter types, return type, error type, and calling convention define the function type; names, destructuring patterns, and capability-ness are metadata or convention. See Function Types and Pointers and Function Reflection.

Prefer conventional capability field names in public examples and structural constraints. This is an open convention, not a closed vocabulary:

alloc
diag
io
fs
log
clock
random
interner
cancel
host

Realtime Suitability

V1 realtime convention

There is no special no-dependency marker and no dedicated allocation/realtime restriction metadata in V1. A function with no context parameter is just an ordinary function with no such parameter.

Realtime suitability is assessed by inspecting ordinary calls, parameters, explicit allocator/API use, and the SIR/IR structure already needed by accepted V1 semantics. A realtime-critical function should not receive or call through allocation, IO, blocking, locking, or hidden-dispatch APIs unless the enclosing policy allows it.

Not V1 semantics

Dedicated realtime attributes such as @realtime, @noalloc, or @kernel, checked realtime regions, allocation-effect metadata, and a separate effect system are not part of the V1 semantic model.

Future enforcement and restriction metadata are tracked by CEP-0020: Realtime Enforcement Regions.