CruftScript boundaries and compartments

The per-element reference for CruftScript boundaries: the compartments that hold code, the named policies that govern each crossing between sound and unsound code, the four failure modes (halt, propagate-as-unknown, sanitize, and export-only skip-return), and how a boundary is attached and enforced when a file runs.

This is the per-element reference for CruftScript's boundary system: the compartments that hold code, the policies that govern crossings, the four continue-modes, and the ways a boundary is attached and enforced. For the language overview see the CruftScript reference; for the type system see type system.

A CruftScript program is sound code that must occasionally call code that is not. TypeScript checks such a call and then erases the types before the program runs, so nothing guards the crossing at run time; CruftScript keeps the boundary live. The compartment is the unit that holds the two apart: it fixes what a region of code can name, and every value that crosses out of an unsound region into a sound one passes a typed boundary.

Two layers carry that guarantee. The checker settles it before the program runs, rejecting any crossing it cannot prove. The running program installs a wrapper at each live boundary and enforces the policy as values actually cross. Every element below says where it stands on both.

Two invariants run through all of it. The capability check precedes the soundness check: an unwrapped value crossing a compartment edge raises a CruftCapabilityError before any validator runs. And the sound side is protected: it may relax what it produces, never blindly trust what unsound code returns.

The run-time layer enforces the secure, debug, and sanitize policies. The override and weaken to policy forms are checked at build time but do not yet emit a run-time wrapper, so using either at a boundary that actually runs is rejected. Those two gaps are collected under Limitations.

Program structure

The .fts file and the compartment-as-unit rule

A .fts file top level admits only declarations: a boundary default, boundary policy definitions, type aliases, and one or more compartment blocks. All executable code (functions, classes, imports) lives inside a compartment. The compartment is the unit of soundness, of capability isolation, and of execution: a file with zero compartments is rejected, and the minimal run needs exactly one. A top-level class or @ decorator is a diagnostic; any other loose top-level text is skipped.

boundary default = secure
compartment CoreApp {
  export function main(): number { return 21 + 21 }
}

The checker validates this shape and rejects a file with no compartment. Running a file evaluates a body that reduces to a static value and prints it (the program above prints 42), otherwise it calls the exported main.

Comments

The comment forms are // and /* */, the same as TypeScript, including inside a function body. A comment is pure lexical residue: it never becomes a node, carries no type, and cannot annotate a boundary.

compartment C {
  export function main(): number {
    // a note inside the body
    return 1 + 1
  }
}

One caveat worth stating: a leading # is not a comment. Inside a class it is the private-field sigil; elsewhere it is an unrecognized line.

boundary default declaration

boundary default = secure

Names the policy that the bare boundary shorthand and every unannotated crossing resolve to. With no default declared, resolving a bare boundary or default site is a build-time error (UnresolvedBoundaryPolicyRef); the boundary never fails open. At most one default per file (first wins). = secure is the fail-closed baseline; = debug flips every unannotated crossing to log-and-propagate. The default is resolved at build time and folded into each resolved policy.

Imports with boundaries

import { getUser }   from "./legacy.js" boundary(secure)
import { fetchData } from "./api.js"    boundary(debug)
import { saveUser }  from "./legacy.js" boundary        // shorthand: the default
import { processUser } from "./core.js"                 // inherits the compartment

An import tags the policy that governs every call into the imported unsound value. It takes a named policy, the shorthand, or nothing (inherits the compartment). boundary(skip return validation) on an import is a compile error: an import can never be told to trust what foreign code returns. The checker resolves the policy, enforces the import-side skip-return ban, and types the imported identifier under its boundary; the wrapper that governs a call into the import fires when the file runs.

Exports and export function

export function getUser(id: number): User { ... }
export function getRawData(id: number): unknown boundary(skip return validation) { ... }

export function is the sole way a compartment publishes a callable outward, and the sound side's outbound boundary surface. It is the only site where the sound side may opt out of validating what it returns (via skip return validation, below); arguments and the capability check still apply.

When JavaScript imports a compartment export and calls it, each argument is validated at the crossing, and a non-conforming argument throws before the body runs. Calling needNum("not-a-number") on an export declared needNum(a: number) raises a TypeError naming the export and the parameter it failed.

Compartments and inheritance

compartment LegacyCode boundary(secure) {
  import { getUser } from "./legacy.js"
  compartment LegacyAPI boundary(weaken to debug) {
    import { fetchLegacyData } from "./legacy-api.js"
  }
}
compartment CoreApp {           // inherits the default
  import { processUser } from "./core.js"
}

A compartment is at once a soundness domain and a capability domain. Its policy flows down to every nested compartment and import unless a child declares its own (effective policy = explicit, else inherited). Parsing, endowments, inheritance, and the resolution chain are settled at build time, and the resolved policy governs the compartment when the file runs.

Capability endowment

compartment Net (fetch: FetchFn) { ... }

A compartment's accessible bare-identifier globals are exactly the fixed intrinsic-name allowlist plus the identifiers it explicitly endows; nothing else is nameable. This is the capability half of the compartment's dual invariant, and the value checked first in the two-layer cross-boundary gate. Endowment types are validated, and any is rejected. The whole declaration surface is checked at build time: an endowed bare identifier resolves to a compartment-tagged reference, and an un-endowed non-intrinsic name is not nameable.

Cross-compartment calls

A cross-compartment call has no special syntax: it is an ordinary call whose callee resolves to another compartment's export. The receiving compartment's policy applies automatically, and two gates fire in order: the capability check (CruftCapabilityError if the crossing value is a raw callable rather than a wrapped one), then the soundness check (the callee-contextualized validator). Resolution, arity, policy precedence, and the unknown result are settled at build time, and the call then runs: a CoreApp.main that calls Lib.twice(10) across the compartment edge returns 20.

Boundary policies

Boundary policy declaration

boundary secure = {
  at process { install: wrapper, mode: strict }
  at call { on violation(expected: unknown, received: unknown) { throw new CruftTypeError() } }
}

A named policy fixes an install mode (strict/debug/sanitize), an on-violation continue-mode, and, for sanitize, a table of typed defaults. Named policies are what compartment, import, and function annotations refer to. Only secure and debug are built in; any other name must be declared. A policy with no recognizable mode: is unresolvable, a build-time error where it is referenced. The checker parses the policy, resolves its mode, type-checks the sanitizer defaults, and resolves references at build time; the wrapper it describes is installed when the file runs.

The at process clause

at process { install: wrapper, mode: strict }   // strict | debug | sanitize

Declares that a wrapper is installed at module-link time and selects the continue-mode via the mode keyword. The mode is the sole determinant of the policy that drives run-time dispatch (debug, strict, sanitize). A sanitize mode requires an at sanitize table; declaring a table under any other mode is an error. The mode keyword drives dispatch at both build and run time. The install: wrapper token is decorative in this generation; only the clause's presence and its mode are read.

The at call clause and on violation

at call { on violation(expected: unknown, received: unknown) { ... } }

The call-time clause. Its handler parameters are conventionally expected and received, both typed unknown. The handler body does not run as user code today. The runtime performs the fixed built-in behavior for the selected continue-mode: HALT throws, and the others increment a violation counter and record a diagnostic. Read on violation as declaring which continue-mode applies, not as a callback that runs; its statements are captured as text, not executed.

The at sanitize table

at sanitize {
  default string        = ""
  default number        = 0
  default Array<string> = []
  default User          = { name: "", role: "" }
}

The data half of SANITIZE: each default T = expr binds one expected type to one inert default the checker proves has type T. Legal only under mode: sanitize; a sanitize policy with no table, an unknown or callable target, or a default that fails its type is a build-time error. The checker enforces the full discipline at build time, and the table runs: an export under a sanitize policy substitutes the declared default for an undefined argument slot (calling it with a missing value returns the default-filled result) and HALTs on a present-but-invalid value.

Process modes: strict / debug / sanitize

The three mode: keywords select the continue-mode: strict → HALT, debug → PROPAGATE-AS-UNKNOWN, sanitize → SANITIZE. strict and debug carry no table; sanitize requires one. The keyword and the mode-vs-table consistency rules are enforced at build time, and each mode's continue-mode runs.

Continue-modes

When validation fails at a boundary, the policy's continue-mode decides what happens.

HALT (the secure default)

HALT is the continue-mode a secure or strict policy selects, not a keyword you write. On a failed validation the wrapper throws and the stack unwinds; nothing ill-typed reaches sound code. It is also the catch-all fallback for any unresolved policy, so an unresolved policy still fails closed.

compartment LegacyCode boundary(secure) { import { getUser } from "./legacy.js" }
getUser("not-a-number")   => throws, stack unwinds

This runs: a non-conforming value crossing a secure export throws before the body executes.

PROPAGATE-AS-UNKNOWN (debug)

The value crosses typed as unknown wrapped in an envelope, a violation counter increments, and downstream sound code must narrow it before use: an un-narrowed use of unknown is a checker error. It returns rather than throws, so the stack does not unwind.

import { fetchData } from "./api.js" boundary(debug)
fetchData(badArg)   => an unknown-typed envelope; must be narrowed downstream

The narrow-before-use obligation is enforced at build time; the envelope-and-count behavior runs at the boundary.

SANITIZE (tolerant)

The wrapper substitutes a declared per-type default from the at sanitize table for the failed value and never lets the received unsound value through. It fills only undefined argument slots; a present-but-invalid value, or a missing default, HALTs. The re-validated return is handled the same way. This runs along the path a .fts executes: an undefined argument slot is filled from the table, and a present-but-invalid argument HALTs. SANITIZE is not yet a free-standing boundary(tolerant) at an ordinary function boundary, and the ORM row boundary is where it ships in full.

skip-return-validation (trust with the opt-out recorded)

The export-only trust dual: an exported function opts out of validating its own return, the value passes back untouched, and the opt-out is recorded. The price is that the export's static return type collapses to unknown, so every caller must narrow. Argument validation and the capability check still apply. Forbidden on imports and on function-type boundaries.

export function getRawData(): unknown boundary(skip return validation) { ... }

The checker enforces the whole obligation at build time (the return-yields-unknown rule, the import-side and function-type rejections); the trust-with-opt-out behavior runs at the boundary.

Attaching a boundary

The boundary clause and shorthand

boundary(secure)         // named policy
boundary                 // shorthand: the default policy (currently secure)
boundary(override: debug)
boundary(weaken to debug)
boundary(skip return validation)               // export-only
boundary(weaken to debug, skip return validation)

boundary(...) attaches a policy to an import, an exported function, a function type, or a compartment; bare boundary expands to the default. A boundary(...) clause that names nothing recognizable is a hard syntax error, never silently dropped. Every clause form is checked at build time: parsing, resolution, the resolution-chain report, and the export-only skip-return rule.

override

compartment RiskyThirdParty boundary(override: debug) { ... }

Replaces the inherited policy with a named one, in either direction (stronger or weaker), unlike weaken. It is parsed, type-checked, and its resolved chain is reported at build time. It does not yet emit a run-time wrapper; a boundary declared override that actually runs is rejected (does not support override policy yet). See Limitations.

weaken to

compartment Experimental boundary(weaken to debug) { ... }

The named escape hatch: re-resolves the policy downward (canonically to debug), from fail-closed to fail-soft. By construction it can only lower the effective level, never raise it, and it is visible syntax. Parsing, downward propagation to nested imports, and the override+weaken composition rejection are settled at build time. Like override, it does not yet emit a run-time wrapper; a weakened boundary that actually runs is rejected (does not support weaken-to policy yet). See Limitations.

Function-level skip return validation

Documented as a continue-mode above; as an attachment it is the one function-level directive permitted only on exports inside a sound compartment. On an import or a function type it is a compile error. It is checked at build time end to end, and the trust behavior runs at the boundary.

Boundary-qualified function types

type GetUser = (id: number) => User boundary(secure)

Any function type can carry a trailing boundary(...) that folds the policy into the type itself; it survives aliases, becomes part of an imported identifier's type, and propagates through call chains so intermediate sound functions need no annotation. skip return validation on a function type is rejected. The syntax and checker behavior are settled at build time (lowering to a boundary-carrying function type, policy resolution, the function-type skip-return rejection). A function type's boundary is a type-level and tooling construct, not an independently installed run-time wrapper.

Callback authority (same-turn)

A callback crossing a boundary may be invoked only immediately, in the same host turn, each argument re-validated as a crossing. It may not be stored, returned as data, handed onward to imported JavaScript, registered as a listener, used as a sanitizer default, or invoked from a later turn. Those forms are outside the language, not latent capabilities. The checker admits the arrow and direct-call forms and refuses the retained, async, and cross-turn forms at build time. Direct same-turn callbacks run: a typed arrow passed to map, filter, or reduce inside a compartment executes and returns its result.

Visible-when-deviating

A boundary a function merely inherits stays invisible in its signature (the clean common case); a function-level deviation (declaring, override, weaken to, or skip return validation on its own boundary) is visible at the signature, the exact point where unsound behavior enters. Tooling hover and error messages always show the fully resolved policy plus its inheritance chain, so brevity never costs observability. The visible-versus-inherited distinction, the obligations, and the resolution-chain strings are settled at build time.

Errors

The system names three surfaces: CruftBoundaryError (a soundness or validation failure), CruftCapabilityError (an unwrapped value crossing a compartment edge), and CruftTypeError (the name a policy may write in its on violation reaction). The specified HALT message carries the resolution chain:

CruftBoundaryError: Soundness violation at LegacyCode.secure
  Boundary:   LegacyCode.secure
  Expected:   string
  Received:   number
  Call Site:  CoreApp.processUser:12
  Chain:      secure ← compartment LegacyCode ← function getUser

A boundary that rejects a crossing throws when the file runs, with capability-before-soundness ordering. A non-conforming argument at a compartment export throws a TypeError that names the export and the parameter it failed. The fully chain-formatted message above, and distinct JavaScript-observable error classes for each surface, are the specified target that the thrown TypeError stands in for.

Limitations

  • override has no run-time wrapper yet. The checker resolves an override policy and reports its chain, but a boundary declared override that actually runs is rejected at wrapper emission (does not support override policy yet).
  • weaken to has no run-time wrapper yet. Downward resolution and propagation are settled at build time, but a weakened boundary that runs is rejected the same way (does not support weaken-to policy yet). Use a named debug policy where a weakened boundary must run today.
  • on violation handler bodies do not run. The declared continue-mode is what fires; the handler statements are captured as text. A custom reaction written in the handler has no run-time effect.
  • SANITIZE is not yet a free-standing boundary. It substitutes defaults under a declared sanitize policy, but boundary(tolerant) at an ordinary function boundary does not resolve. The full tolerant surface ships at the ORM row boundary.
  • The chain-formatted error is not the shape thrown. Boundary rejections surface as a TypeError with a message that names the failed export and parameter, not yet as the chain-formatted CruftBoundaryError / CruftCapabilityError classes above.
  • A function body is a narrow surface. The soundness rules refuse forms a general JavaScript body allows: loose == and != (sound typing requires ===), for and while loops, try/catch, and self- or mutual recursion are rejected, each with a stable diagnostic.