Caspian GitHub issue
- Overview
- Host Language: Why Lua
- Philosophy
- Design Principles
- Relationship to Other Systems
- Primitives
- Variables
- Exceptions and Warnings
- Structured Non-Local Control Flow
- Conditional Constructs Share One Primitive
- Block Parameter Binding
- Unified Name Resolution
- Typed Structured Events
- Object Model
- Helpers
- Classes
- Functions
- Scoping
- System Methods
- Jail
- Freezing
- Change Signals
Overview GitHub issue
vibecode
{"vibecode": { "section": "overview", "language": "Caspian", "complements": "Q0", "q0_role": "query_and_filter", "caspian_role": "computation_and_control_flow", "development_history": "~20 years conceptual" }}
Open issues (1)
Caspian is the programming language of the Puck ecoverse. It handles computation and control flow — the things Q0 deliberately does not do. Q0 is a query and filter language; Caspian is where actual programming lives.
Caspian has been in development conceptually for approximately twenty years.
Host Language: Why Lua GitHub issue
vibecode
{"vibecode": { "section": "host_language_why_lua", "choice": "lua", "reasons": ["tiny_footprint_and_wide_install_base", "more_contributor_involvement_than_we_would_get_with_c", "cross_os_reliability_already_solved_by_lua", "python_too_monolithic_for_pucks_light_footprint_goal", "c_efficient_but_too_painful_to_debug"] }}
The reference engine for Caspian is implemented in Lua 5.4. The choice is deliberate; the reasoning:
- Tiny and widely installed. Lua's footprint is small enough that asking users to install it (if they don't already have it) is a minor ask, not a barrier.
- More involvement than we'd get with C. Lua isn't a barrier for contributors who want to patch the Puck libraries. We expect more involvement from a Lua codebase than from a C codebase.
- Cross-OS portability is already solved. Lua works on essentially every operating system that runs anything. Puck inherits that portability without doing the work itself: if a system can run Lua, it can run Puck.
Why not Python GitHub issue
Python is also a solid candidate — installed everywhere, possibly the biggest software community of any language. But Python is monolithic. It's a much larger system than Lua, and pulling in its full footprint conflicts with the design goal of keeping Puck light. A small, reliable cross-OS footprint matters more than a larger ecosystem.
Why not C GitHub issue
C is the "obvious" choice in the sense that you can write very efficient systems in it. But C's syntax is complicated and the debugging cost is too high — running into low-level C bugs every time something subtle goes wrong is not a tax worth paying. Lua gets us most of the way without it.
Philosophy GitHub issue
vibecode
{"vibecode": { "section": "philosophy", "core_principle": "everything_that_can_be_written_in_caspian_should_be", "lua_role": "interpreter_loop_memory_management_external_bindings_only", "exception": "move_to_lua_if_unwieldy_or_too_slow", "kernel_required": ["interpreter_loop", "gc", "core_bwcs", "primitive_types", "puck.uno/object", "puck.uno/helper", "system_methods_%call_%chain_%bucket"], "goal": "standard_library_written_in_caspian_visible_and_inspectable", "lua_reference_deps": ["SQLite", "libmicrohttpd", "libsodium"], "size_target": "500k_for_caspian_own_code_excluding_deps", "threading": "not_supported_single_threaded_by_design" }}
Caspian Written in Caspian GitHub issue
Everything that can be written in Caspian should be written in Caspian. The Lua layer exists for things that cannot be expressed in Caspian at all — the interpreter loop, memory management, external library bindings — not as a convenience for things that are merely awkward to write in Caspian.
The exception is pragmatic: if something could be in Caspian but has become unwieldy or too slow in practice, move it to Lua. Performance and maintainability are valid reasons to drop down. But the default is always Caspian first.
A compliant Caspian engine must provide a minimal kernel:
- The interpreter loop
- Memory management and garbage collection
- Core bwcs:
if,while,and,or,not - Primitive types: string, number, boolean, null, array, hash
puck.uno/object— the root class, foundation of the object systempuck.uno/helper— the base helper class- System methods:
%call,%chain,%bucket
Everything built on top of these — puck.uno/loop, exception classes, the standard library, helper implementations — should be written in Caspian where possible.
This makes much of the engine visible and inspectable. A developer who wants to understand how a feature works can read Caspian source rather than implementation source. Bugs in the standard library are fixable without touching the host language.
Lua reference implementation GitHub issue
The Lua reference implementation provides the kernel above plus:
- Bindings to SQLite and libmicrohttpd
- Pattern matching via Lua's built-in pattern library
The Caspian parser is a hand-written recursive descent parser written in Caspian JSON directly — this is the bootstrapping constraint. Once the parser is working, future rewrites can use Caspian.
Dependencies (Lua reference implementation) GitHub issue
The Lua implementation requires Lua plus three C libraries:
- SQLite — used by the mikobase implementation; both
puck.uno/mikobase/memory(in-memory mode) andpuck.uno/mikobase/sqlite(file-backed mode) run on SQLite - libmicrohttpd — embedded HTTP server powering
puck.uno/mikobase/http; handles concurrent connections at the C level - libsodium — all cryptographic needs:
%utils.random.uuid(CSPRNG-backed UUIDs, see utils.md), Ed25519 signing for the Puck blockchain (see blockchain.md). One audited security-focused library covers both, on every platform Lua runs on
Performance concerns are addressed by optimizing the interpreter, not by abandoning the principle.
Error Messages GitHub issue
Sometimes complexity is unavoidable. When it is, the key to helping the developer is good error messages. If something goes wrong, Caspian should pinpoint the problem precisely and explain the mistake clearly. A confusing error message is a bug.
Design Principles GitHub issue
vibecode
{"vibecode": { "section": "design_principles", "principles": ["lightweight_and_embeddable", "no_threading_or_forking", "timeouts_via_%utils.timeout", "caspianj_as_runtime_format"], "embeddable_in": ["Python", "Ruby", "other_major_languages"], "sqlite_required": true, "timeout_mechanism": "debug.sethook_in_lua_fires_every_N_vm_instructions", "timeout_nested_budgeting": "min(requested, remaining_parent_budget)" }}
Lightweight and Embeddable GitHub issue
The Caspian interpreter must be embeddable in every major language (Python, Ruby, etc.) as a library dependency. It ships with mikobase support built in, which means SQLite is a required dependency. SQLite is ubiquitous — it is already present on virtually every platform — so this barely counts as an external requirement.
Caspian is not minimal in the way Lua is minimal, but it borrows Lua's discipline around size. The target for Caspian's own code is 500k, not counting external dependencies (Lua, SQLite, libmicrohttpd). If the compiled size of Caspian's own code exceeds 500k, that is a red flag worth investigating. Every engine that runs Caspian gets mikobase support, the security model, and the full object system.
This matters because Caspian will run inside engines — for dynamic firewall rules, trigger records, and other engine-level logic. Every engine needs to run it.
No Threading or Forking GitHub issue
Caspian does not support threading, forking, or concurrency primitives. It is single-threaded by design. The host language handles concurrency; Caspian runs within a single execution context.
This constraint keeps the interpreter small and predictable. Forking, fork-shared mikobases, and the multi-process coordination model are opt-in Caspian features the engine grants on request (off by default).
Timeouts GitHub issue
Caspian does not use threads, but untrusted code must not be allowed to run indefinitely. A function downloaded from a remote Puck object — %puck['https://borg.com/riker'] — might be an infinite loop or a cryptocurrency miner. The %utils.timeout method wraps a block with a hard time limit:
%utils.timeout(5) do
&riker
end
If the block does not complete within the specified number of seconds, the timeout fires a two-stage flag sequence:
- Inside the block:
puck.uno/timeout_handleis raised. This is not an exception — it does not unwind.begin/ensureblocks inside the timeout do not run, no Caspian-level cleanup is attempted inside. Thetimeout_handlebubbles straight up to the timeout block's boundary, ignoring all user-levelcatchandensurealong the way. - At the timeout block's boundary: the timeout mechanism intercepts the
timeout_handleand re-raises apuck.uno/error/timeoutin the caller's scope. This is a normal catchable error — the caller cancatch('puck.uno/error/timeout')(or any ancestor likeexceptionorerror) and handle it cleanly. If the caller doesn't catch, the timeout error propagates up the stack like any other.
catch('puck.uno/error/timeout')
%utils.timeout(5) do
&slow_thing
end
end
Whole-second granularity is used.
The reason timeout_handle doesn't unwind: a runaway block (infinite loop, cryptocurrency miner) can also write infinite-loop cleanup code. Letting ensure run inside the timeout block during a timeout would let the developer extend their budget by an arbitrary amount. The two-stage model denies that path entirely while still giving the caller a normal catchable error.
When running untrusted code, the engine is responsible for wrapping execution in %utils.timeout (or an equivalent native-level bound) from the embedding side. Caspian code inside cannot extend or escape that outer timeout.
The unwind: option GitHub issue
For cooperative code that wants a polite "time's up, clean up and exit" signal — not the security-boundary form — pass unwind: true:
%utils.timeout(3600, unwind: true) do
# run for an hour, then exit gracefully
end
With unwind: true, the two-stage mechanism collapses to one: when the deadline fires, a puck.uno/error/timeout is raised directly inside the block (no timeout_handle), unwinds the stack like any normal exception, runs begin/ensure, and propagates outward.
This is not a security boundary. Code inside the block can catch('puck.uno/error/timeout') and keep running. That's the point — cooperative code is trusted to honor the timeout. Use unwind: true when you control the code inside the block and want cleanup; use the default when you don't.
The unwind: kwarg is per-block. The stricter parent always wins at the moment its deadline fires. If an outer unwind: false deadline fires while execution is inside an inner unwind: true block, the outer's timeout_handle bubbles up uncatchably and bypasses the inner's begin/ensure. The inner's mode applies only when the inner's own deadline fires.
The ? form: %utils.timeout? GitHub issue
%utils.timeout? (with the ? suffix) is the tolerant form that returns the timeout flag as a value instead of raising it at the boundary:
$timeout = %utils.timeout?(5) do
&slow_thing
end
if $timeout
# timed out — $timeout is the flag object
else
# block completed normally
end
On normal completion, %utils.timeout? returns null. The block's own return value is not preserved through %utils.timeout? — if the caller needs it, assign it to a variable inside the block.
The ? form follows the language-wide ? suffix convention: falsey on the happy path, truthy (the flag itself) on the failure path. It's sugar for what would otherwise be catch('error/timeout') wrapping around %utils.timeout — the common "try for N seconds, move on if not" pattern, compressed into the method name.
The ? form combines with unwind: GitHub issue
unwind: controls what fires inside the block when the deadline hits; the ? form controls what happens at the boundary when a timeout flag escapes the block. They combine cleanly:
| Form | unwind: |
Behavior |
|---|---|---|
%utils.timeout(N) |
(omitted) | timeout_handle inside (no ensure); error/timeout raised in caller scope |
%utils.timeout(N, unwind: true) |
true |
error/timeout inside (catchable, ensure runs); propagates out of the block normally |
%utils.timeout?(N) |
(omitted) | timeout_handle inside (no ensure); boundary catches and returns the timeout flag |
%utils.timeout?(N, unwind: true) |
true |
error/timeout inside (catchable, ensure runs); if it escapes the block, boundary catches and returns it |
In the %utils.timeout?(N, unwind: true) case: if the block itself catches the timeout, %utils.timeout? returns null (nothing escaped). If the block doesn't catch, the ? form converts the escaping timeout to a return value.
Spec: compliant engine requirements GitHub issue
A compliant engine must:
- Abort execution of the block (default mode) or raise an unwinding
error/timeoutinside the block (unwind: truemode) if it does not complete within the specified number of seconds (whole-second granularity) - Make each default-mode
%utils.timeoutcall undefeatable by Caspian code — there must be no path from a Caspian expression to suppress atimeout_handle - Allow
unwind: truetimeouts to be caught and suppressed from Caspian code (the cooperative-timeout contract) - Provide a
%utils.timeout?form that catches the timeout flag at the%utils.timeout?boundary and returns it as the call's value instead of raising it. Returnsnullon normal completion. - Apply nested timeout budgeting:
effective_timeout = min(requested, remaining_parent_budget). An inner%utils.timeout(20)inside an outer%utils.timeout(5)is bound by the outer's remaining 5 seconds. The stricter parent's mode wins when its deadline fires. - Handle blocking system calls — any system method that can block must implement its own timeout at the native level, since the interpreter-level timeout cannot fire during a native blocking call
Lua reference implementation GitHub issue
%utils.timeout is implemented using debug.sethook. A hook is registered that fires every N VM instructions and checks os.time() against the deadline. When the deadline is exceeded, the hook raises a Lua error whose tag depends on the block's mode:
- Default mode: the error is tagged as
timeout_handle. Caspian'sbegin/ensurecompiles to apcallvariant that explicitly re-raisestimeout_handle-tagged errors instead of catching them, so the error propagates through Caspian-level frames without running anyensureblocks until it reaches the%utils.timeoutboundary. There, the timeout's wrapping code intercepts thetimeout_handleand translates it into apuck.uno/error/timeoutraised normally in the caller's scope. unwind: truemode: the error is untagged (or tagged as a normal exception). It's caught by ordinarypcall, runsensure, and is exposed to usercatchas apuck.uno/error/timeoutdirectly inside the block. No boundary translation needed.
The hook is cleared after the block completes (whether normally or via timeout). No threads are required.
Caspian code cannot defeat the default-mode timeout because debug, load, os, and the rest of Lua's standard library are not reachable from Caspian expressions, and begin/ensure re-raises timeout_handle-tagged errors. unwind: true mode is deliberately suppressible by user code — that's the cooperative-timeout contract.
CaspianJ as the Runtime Format GitHub issue
Caspian programs are written in Caspian and transpiled to CaspianJ for execution. CaspianJ is easy to store, transmit, and parse in any language — which makes Caspian programs easy to ship around, embed in records, and process programmatically.
By convention, code is shared as Caspian source. CaspianJ is the runtime artifact, not the distribution format.
Alternative syntaxes that transpile to CaspianJ are possible but not encouraged. Caspian is the language; CaspianJ is the wire format.
Relationship to Other Systems GitHub issue
vibecode
{"vibecode": { "section": "relationship_to_other_systems", "q0": "selects_and_filters_records_complementary_not_overlapping", "puck": "caspian_is_programming_language_component_of_puck_ecoverse", "caspianj": "runtime_format_caspian_compiles_to" }}
- Q0 — Caspian and Q0 are complementary. Q0 selects and filters records. Caspian computes, controls flow, and implements behavior. They are not the same language and are not meant to overlap.
- Puck — Caspian is the programming language component of the Puck ecoverse, alongside the Puck object model and
puck.uno/query. - CaspianJ — The runtime format Caspian compiles to. See caspianj.md.
Primitives GitHub issue
vibecode
{"vibecode": { "section": "primitives", "types": ["String", "Number", "Boolean", "Null", "Array", "Hash"], "strings": "utf8_immutable_encoded_at_engine_boundary", "hashes": "key_order_significant_equal_only_if_same_keys_values_and_order", "numbers": "no_int_float_distinction_single_number_type", "truthiness": "null_and_false_are_falsy_everything_else_truthy_including_0_and_empty_string", "null_true_false": "fully_instantiable_and_subclassable_classes", "null_flavors": "hl7_concept_subclass_puck.uno/null_for_domain_specific_nulls" }}
Caspian as an Extension of JSON GitHub issue
Think of Caspian as an extension of JSON. Primitives work like in JSON: strings, numbers, booleans, null, arrays, and objects. Caspian builds on these rather than introducing new ones.
Types GitHub issue
| Type | Examples |
|---|---|
| String | 'foo', "foo", :foo |
| Number | 1, 3.14, -7 |
| Boolean | true, false |
| Null | null |
| Array | [1, 2, 3] |
| Hash | {key: 'value'} |
Strings GitHub issue
All strings in Caspian are UTF-8. There is no other encoding. Strings are immutable — operations on a string return a new string; the original is never modified.
Encoding is handled at the engine boundary, not in Caspian code. A compliant Caspian engine must convert all incoming strings to UTF-8 before passing them to Caspian. The reference Lua implementation converts whatever encodings Lua natively supports. Strings arriving in unsupported encodings should raise an error at the boundary.
Hashes GitHub issue
Hash key order is significant. {foo: true, bar: true} and {bar: true, foo: true} are distinct values. The order in which keys are written is the order in which they are stored and iterated. Two hashes are equal only if they contain the same keys with the same values in the same order.
This matters for serialization, comparison, and anywhere key order carries semantic weight (e.g., field ordering in a record schema).
Numbers GitHub issue
There is no distinction between integers and floats — there is only number. JSON makes no such distinction, and neither does Caspian. The interpreter handles numeric representation internally, using integer or float arithmetic as appropriate for efficiency.
Truthiness GitHub issue
null and false are falsy. Everything else is truthy — including 0 and ''.
null, true, and false as Classes GitHub issue
In most languages, null, true, and false are global singletons whose underlying classes cannot be instantiated. In Caspian, the underlying classes are fully instantiable and subclassable.
The bwcs null, true, and false always return a standard instance of their respective classes. This behavior cannot be changed. But you can create instances directly:
$my_null = %puck['null'].new
$my_null = %puck['https://puck.uno/null'].new # same thing
Truthiness is immutable. Any instance of puck.uno/null or its subclasses is always falsey. Any instance of puck.uno/true is always truthy. Any instance of puck.uno/false is always falsey. Subclassing or adding methods cannot change this.
Null Flavors GitHub issue
The most compelling use case for subclassing puck.uno/null is null flavors — a concept from HL7, the healthcare data standard. In HL7, null values carry a reason: "unknown", "not applicable", "masked for privacy", "not asked". Plain null loses this information; null flavors preserve it.
In Caspian, you can subclass puck.uno/null to create domain-specific null types:
class
inherits 'puck.uno/null'
end
class
inherits 'puck.uno/null'
end
Instances of these classes are falsey in all conditionals, but carry type information that code can inspect when needed:
$val = %puck['https://myapp.com/null/unknown'].new
if($val)
# never entered — $val is falsey
end
The same subclassing pattern applies to puck.uno/true and puck.uno/false, though null flavors are the primary use case.
Operators GitHub issue
The following operators are methods that any class can implement:
+ - * / == != < > <= >=
1 + 2 is equivalent to 1.+(2). Classes can override these for custom types.
Boolean Operators GitHub issue
and, or, and not are core bwcs implemented in Lua. They use short-circuit evaluation and cannot be overridden. Symbol shortcuts:
| bwc | shortcut |
|---|---|
and |
&& |
or |
`\ |
not |
! |
Core bwcs GitHub issue
The following bwcs are implemented in Lua and cannot be overridden:
if elsif (alias: elseif) else while
and or not
begin ensure
See Cleanup with begin / ensure for the begin/ensure pair.
Loop-scoped section markers. The keywords before, between, after, and noloop are also reserved and engine-implemented, but they are not general bwcs — they may only appear as section markers inside loop bodies (per loops.md § Structural blocks), not as standalone statements. The lexer/parser recognizes them as keywords; the loop runner consumes them. Outside a loop body their appearance is a parse error.
self GitHub issue
self is a bwc shortcut for %self, which returns the current object instance.
Variables GitHub issue
vibecode
{"vibecode": { "section": "variables", "sigil": "$", "first_class": true, "variable_object": "$$foo returns variable object", "variable_object_value_accessor": ".value (gettable and settable)", "pass_by_reference": "supported_via_variable_object_value" }}
Variables are prefixed with $. They are first-class objects.
$$foo returns the variable object itself — distinct from $foo, which returns the value the variable holds. Variable objects can be passed around like any other object. The variable's value is read and written through .value:
$foo = 1
$$foo.value # 1
$$foo.value = 2
$foo # 2
Pass-by-reference is supported through this handle: a callee that receives $$foo can read or replace the variable's value via .value.
Exceptions and Warnings GitHub issue
vibecode
{"vibecode": { "section": "exceptions_and_warnings", "shared_shape": ["class", "id", "bucket"], "raise_methods": ["%chain.warn", "%chain.throw", "%chain.error", "%chain.exit", "%chain.abort"], "top_level_classes": ["puck.uno/warning", "puck.uno/exception"], "exception_subclasses": ["error", "error/timeout", "exit", "return", "abort", "security", "timeout_handle"], "per_class_properties": ["catcher", "unwinds"], "default_profile": "catcher=user, unwinds=yes", "overrides_required_for": ["exit", "return", "abort", "security", "timeout_handle"], "catch": "catch() matches by class AND filters by catcher=user", "heed": "heed() matches warnings (non-unwinding)", "abort_capability": "tied_to_scope_untrusted_code_cannot_abort_process" }}
The framework's flow-modifying events come in two top-level classes: warnings (observational, non-unwinding, never user-catchable via catch) and exceptions (raised to redirect flow). All flags share a single object shape (class, id, bucket) and a single set of %chain raising methods.
exception is the umbrella class for everything raised — including aborts, exits, security violations, and so on. By default an exception subclass unwinds and is catchable in user code; subclasses that need different behavior (abort, security, timeout_handle, exit, return) explicitly override either or both properties. The word "exception" is doing double duty here as the umbrella and the default-behavior class; in practice no one talks about the umbrella, so the ambiguity stays out of the way.
Shared shape GitHub issue
Every flag is a Caspian object with:
- A class — the formal type, used by
catch()andheed()to match. - An id — a string nickname for the specific case. Free-form, author-chosen (e.g.,
'connection_refused','redundant_fields'). The id is a human-readable label; matching is by class. - A bucket — a hash holding details/payload. Accessed openly via
[]:
$e = catch('foo.com/error/network')
# ... something that throws
end
if $e
$e.id # 'connection_refused' — the nickname
$e['host'] # 'db1' — direct bucket access
$e['port'] # 5432
end
This matches the universal Caspian object model (class stack + bucket). Exceptions, errors, and warnings are regular objects following that model — no special accessors.
The two top-level classes GitHub issue
| Class | Propagation |
|---|---|
| Warning | Up through the stack without unwinding; collected via heed() |
| Exception | Raised to redirect flow; each concrete subclass has its own (catcher, unwinds) profile |
Under exception, every concrete class is independently declared. The default profile is catcher=user, unwinds=yes (the familiar "throw and catch" model). Subclasses override one or both properties as needed:
error— same default profile. Semantic marker for "this is an error situation" — behaviorally identical to plainexception. See error vs exception below.error/timeout— same default profile; the user-catchable timeout raised at the boundary of a%utils.timeoutblock.exit— unwinds, but caught by the engine for orderly shutdown rather than usercatch.return— unwinds, caught at function-call boundary.abort— does not unwind; engine takes over, noensure.security— does not unwind; engine catches.timeout_handle— does not unwind; bubbles to the%utils.timeoutblock boundary, where the timeout mechanism translates it into a user-facingerror/timeout(see Timeouts).
Error vs exception GitHub issue
puck.uno/error is a semantic marker, not a behavioral distinction. Both exception and error carry stack traces, both unwind, both are user-catchable. The class names exist to convey developer intent:
exception— generic flow-control raise. Used for redirects, custom signals, control transfer, anything that's "this code path is taking a turn."error— "something went wrong." A subclass of exception; the name signals to readers that this is a failure condition, not a routine flow-control event.
catch('puck.uno/exception') catches both (error is-a exception). catch('puck.uno/error') catches only errors and their subclasses.
All exceptions carry a stack trace. Trace capture cost is small in practice and the debug value of having a trace on every exception (especially when one propagates farther than its raiser anticipated) outweighs the cost.
Minimum stack-trace shape (v1): the trace is accessed via $e.stack as an array of frame objects, root frame first, current frame last. Each frame is a hash with at minimum:
| Field | Type | Description |
|---|---|---|
class |
string (UNS) | The owning role / class of the function-object whose call frame this is. For top-level scripts, the engine fills in a synthetic UNS (e.g., puck.uno/script/<name>). |
method |
string | The method or function name (e.g., greet, init, or <top-level>). |
line |
integer | The source line number within the function (1-based). Null for engine-internal frames. |
Engines may add fields (column, file path, role at-time-of-call, etc.) as needed; consumers should treat unknown fields as additive and not reject the trace because of them. Serialization for Jasmine flattens each frame to the same hash; serialization for the wire format used by versioning follows the same shape (with field trimming as the doc describes).
Raising standard flags GitHub issue
Each of the standard flag classes has a shortcut method on %chain. All take an id and a bucket; the class is implied by the method:
%chain.warn 'redundant_fields', {fields: ['sort', 'sorts']}
# class: puck.uno/warning
# does not unwind
%chain.throw 'cache_miss', {key: 'user:42'}
# class: puck.uno/exception
# unwinds, carries stack trace
%chain.error 'connection_refused', {host: 'db1', port: 5432}
# class: puck.uno/error
# unwinds, carries stack trace
# the "error" subclass is a semantic marker — same behavior as throw,
# just a name indicating "this is an error" for readers
%chain.exit 'normal', {code: 0}
# class: puck.uno/exit
# unwinds (graceful shutdown), runs ensure and close
%chain.abort 'unrecoverable', {reason: 'bad_state'}
# class: puck.uno/abort
# does NOT unwind — engine terminates, no ensure runs
These are convenience shortcuts for the standard flag classes. They cover most real use — custom flag subclasses exist (see below) but are less common in practice than you might expect.
Why these live on %chain: raising any flag is a chain-flow event — exceptions unwind the chain, warnings travel up it, aborts terminate it. The chain owns the flow-control surface.
Raising custom flags GitHub issue
For custom flag classes (a specific exception subclass, a Sammy redirect, a custom warning, etc.), use the standard object-instantiation pattern:
$f = %['puck.uno/sammy/redirect/302'].new('temporary')
$f['whatever'] = 'dude'
$f.raise
- Construct the flag with
.new(id)— the id positional arg is the nickname. - Populate the bucket via
[]. - Raise with the universal
.raisemethod on the flag object.
.raise is the primitive that initiates propagation. The %chain shortcuts above internally do this — they construct a flag of the default class, populate it, and call .raise.
This pattern is uniform with how every other object is created in Caspian: .new, configure, use. Nothing special.
Class hierarchy GitHub issue
puck.uno/warning
puck.uno/exception
puck.uno/error
puck.uno/error/timeout
# other built-in and developer-defined errors live here
puck.uno/exit
puck.uno/return
puck.uno/abort
puck.uno/security
puck.uno/timeout_handle
| Class | Catcher | Unwinds |
|---|---|---|
warning |
user (heed) |
no |
exception |
user (catch) |
yes |
error |
user (catch) |
yes |
error/timeout |
user (catch) |
yes |
exit |
engine | yes |
return |
function boundary | yes |
abort |
engine | no |
security |
engine | no |
timeout_handle |
timeout block boundary | no |
puck.uno/warningis observational. Emitted via%chain.warn, collected viaheed(), never user-catchable throughcatch().puck.uno/exceptionis the umbrella for everything raised. By default, exceptions are user-catchable and they unwind; the subclasses listed above override one or both of those properties.erroris a subclass ofexception— same default profile, but carries a stack trace.catch('exception')catches both.timeoutandtimeout_handleare completely different classes doing different jobs.timeoutis the user-catchable error that surfaces in caller scope when a timeout block runs out.timeout_handleis the internal abort that fires inside the timeout block and bubbles to the block's boundary — see the Timeouts section for how they interact.
Custom flag classes can extend any concrete class — foo.com/error/network, foo.com/warning/validation, etc. A custom class inherits its parent's profile unless it overrides.
return, exit, abort GitHub issue
These are Caspian flow primitives:
returnraisespuck.uno/return. Function call boundaries automatically catch it and extract the return value. Early returns from nested blocks work naturally — no special non-local-return machinery needed; the interpreter uses the same unwind mechanism for all flow that unwinds.exitraisespuck.uno/exit. Graceful: unwinds the stack, runsbegin/ensureblocks, runscloseon objects (see Garbage Collection), and cleans up before the process ends. Equivalent to%chain.exitor%process.exit.abortraisespuck.uno/abort. Violent: the engine terminates the execution unit immediately. The stack is not unwound,begin/ensuredoes not run,closeis not called, GC does not run. Equivalent to%chain.abortor%process.abort.
%chain.exit and %chain.abort exist for symmetry with %chain.error, %chain.warn, and %chain.throw — all flag-raising lives on %chain. The bareword shortcuts (exit, abort) and the %process.* forms remain available for the developer who prefers them.
Unwinding rule:
| Method | Class | Unwinds? |
|---|---|---|
%chain.warn |
warning |
No — propagates up without unwinding |
%chain.throw |
exception |
Yes — runs ensure, runs close, unwinds frames |
%chain.error |
error |
Yes — runs ensure, runs close, unwinds frames |
%chain.exit |
exit |
Yes — graceful unwind, runs ensure, close, GC |
%chain.abort |
abort |
No — engine takes over, no ensure, no close, no GC |
Two methods don't unwind, for two different reasons: warnings by design (they're observations, not flow events), aborts by intent (immediate termination, no Caspian-level code is trusted to run during cleanup).
Abort is a capability granted per role. The user role typically holds it; roles the engine launches with restricted surfaces may not. puck.uno/abort propagates to role boundaries:
- At a boundary into a role with abort capability, the process terminates.
- At a boundary into a role without it, the abort is caught and converted to an error. Execution in the role on the other side continues normally.
This means code running under a role without abort capability can only abort itself, not the process that contains it.
Catching exceptions GitHub issue
catch() matches user-territory exceptions — classes whose catcher property is "user." That's puck.uno/exception, puck.uno/error, puck.uno/error/timeout, and any developer-defined class with catcher: user. Engine-territory subclasses (exit, return, abort, security, timeout_handle) are not catchable from user code; the engine catches them before user catch handlers see them, even though they are declared subclasses of exception.
$e = catch('foo.com/error/network')
# ... code that may throw a network exception ...
end
$e # null if no matching exception, otherwise the exception object
$e.id # the id nickname
$e['host'] # bucket access
Multiple classes:
$e = catch('foo.com/error/network',
'foo.com/error/timeout')
# ...
end
Catch all user-territory exceptions:
$e = catch()
# ...
end
A no-args catch() matches anything whose catcher is "user" — both the built-in exception and error classes, plus any developer-defined classes that declared themselves user-catchable. This is the idiomatic "catch whatever the user might throw" form.
Note on the hierarchy. catch() is filtered by both class match and the catcher property. So catch('puck.uno/exception') matches exception, error, and error/timeout (all catcher: user), but not exit, abort, security, return, or timeout_handle — those are declared subclasses of exception but have engine-or-other catchers, and catch never sees them. The engine catches engine-territory subclasses before user catch handlers run. Custom subclasses match their declared parent: catch('puck.uno/error') matches foo.com/error/network because the latter is declared as a subclass of error.
UNS naming is flat; inheritance is declared. The class names above (puck.uno/error, puck.uno/exit, puck.uno/abort, etc.) all live at the top of the puck.uno/ namespace — there is no puck.uno/exception/ prefix in the UNS. Their relationship to puck.uno/exception is by declared inheritance, not by UNS path. Catch behavior, subclass matching, and the abstract-vs-concrete distinction all follow declared inheritance — not UNS-prefix matching. (See ecoverse/uns.md for the general rule that UNS does not imply inheritance, with xemes as one of the few exceptions.)
If an exception doesn't match any class given to catch(), it continues unwinding up the stack until something does match (or nothing does and it propagates to the engine top).
Heeding warnings GitHub issue
heed() matches non-unwinding events — warnings:
$warnings = heed('foo.com/warning/validation')
# ... code that may emit validation warnings ...
end
$warnings # array of warning objects collected during the block
Heed all warnings:
$warnings = heed()
# ...
end
By default, heeding a warning collects it and stops its propagation. To collect but let it keep traveling up the stack:
$warnings = heed(rewarn:true)
# ...
end
A collected warning can be manually re-raised later:
$warning.warn # short form, on the warning object
# or
%chain.warn $warning # equivalent — pass the object instead of (id, bucket)
Auto-recording into Jasmine GitHub issue
Anything that propagates out of a function call — error, exception, or warning that wasn't heeded — is automatically recorded into that function's %chain.log entry before the entry is attached or flushed. Errors include their stack trace; plain exceptions don't. See jasmine/caspian.md § Automatic exception recording.
Cleanup with begin / ensure GitHub issue
The begin bwc creates a scoped variable region. Variables declared inside the begin block aren't visible outside it:
begin
$var = 'foo'
# $var is in scope here
end
# $var not available here
A begin block can have an ensure clause that runs on scope exit — normally, via a raised flag, via return, via exit, anything that unwinds the scope:
begin
$var = 'foo'
# main work
ensure
# always runs (except on abort)
# $var not available here — the begin block's scope has already exited
end
Ensure guarantees:
- Runs after the main block, regardless of how it exited (normal completion, raised error/exception,
return,exit). - Does not run on
abort— abort is violent, no unwinding, no GC, no ensure. - A flag raised inside
ensureitself propagates normally, overriding any in-flight flag that was being unwound (same as Ruby). - Scope variables from the begin block are not in scope in the ensure clause — the begin block's scope has already exited by the time
ensureruns. If the cleanup needs a resource, declare it before thebegin(or pass through some other mechanism).
Composes naturally with catch:
$e = catch('foo.com/error/network')
begin
$conn = $db.connect
$conn.query(...)
ensure
$db.release_connection
end
end
ensure handles cleanup; catch handles whether a flag was raised. Each does one thing.
Naming note: begin follows Ruby's begin/ensure/end shape for familiarity. Developers coming from Ruby (and similar shapes in Python's try/finally, Java's try/finally, etc.) will reach for this structure intuitively.
Design note (architectural objection): the begin ... ensure ... end shape introduces a multi-clause nested structure, which is unusual for Caspian. The only other multi-clause structure is if/elsif/else/end, and even that has occasionally been criticized for going against the language's "primitives are function calls" pattern. An alternative was considered — defer-style registration (à la Go/Swift/Zig), where cleanups are registered as flat statements: defer do; $conn.close; end. The defer pattern avoids the multi-clause structure and puts cleanup near the resource it cleans up, but the Ruby-flavored begin/ensure won on familiarity: it's the shape most developers coming from Ruby, Python, Java, etc. will reach for, and it reads more naturally to most readers. The architectural concern is noted; the familiarity argument carried the day.
Structured Non-Local Control Flow GitHub issue
vibecode
{"vibecode": { "section": "structured_non_local_control_flow", "principle": "every_named_non_local_exit_is_a_typed_exception_unwinding_until_a_registered_handler_matches", "unifies": ["loop_next", "loop_return", "block_return_via_as", "function_return", "raise_catch"], "runtime_artifact": "single_control_type_single_throw_single_unwinder", "user_facing_surface_unchanged": true, "new_exception_subclasses": ["loop/next", "loop/return", "block/return", "error/stale_handler"], "working_names": true, "open_questions": ["cross_role_boundary_propagation_policy"] }}
Caspian's loop controls ($loop.next, $loop.return), function returns (plain return), block returns ($if.return value on an as $if block), and the exception system (raise, catch, ensure) are one mechanism at the runtime level. Every named non-local exit is a typed value that unwinds the call stack until a registered handler matches it. The shape reuses the exception system described in Exceptions and Warnings; loop and block controls are additional subclasses of puck.uno/exception.
This is a runtime-architecture statement, not a new feature spec. The user-facing surface — next, return, raise, catch, ensure, as — stays exactly as the language docs describe it. The point here is to name the underlying mechanism so implementations don't grow N parallel control-flow machineries.
The exception family, extended GitHub issue
The exception class hierarchy already covers function returns and exits as exception subclasses (return, exit). The same model extends to loop and block controls:
| Class | Raised by | Carries |
|---|---|---|
puck.uno/error |
raise (default) |
message + bucket |
puck.uno/return |
plain return value |
the return value |
puck.uno/exit |
%chain.exit |
the exit code |
puck.uno/abort |
%chain.abort |
engine-fatal; not unwinding via user code |
puck.uno/security |
role violation | engine-tagged |
puck.uno/timeout_handle |
timeout | engine-tagged |
puck.uno/loop/next |
$loop.next |
target loop id |
puck.uno/loop/return |
$loop.return (optionally with a value) |
target loop id + optional value |
puck.uno/block/return |
$if.return value (and any as $name block) |
target block id + value |
All inherit the standard exception shape: class, id, bucket. The catcher and unwinds properties from the parent exception class apply.
For loop and block controls, both properties are settled by the mechanism above:
catcher=engine— a usercatch(...)block cannot intercept$loop.return,$loop.next,$if.return, etc. Allowing user code to swallow these would hijack the construct's exit semantics; only the construct's runner is supposed to consume them. (The nameengineis reused here from the existing convention for "user-uncatchable" exceptions. The loop runner isn't technically "the engine," but the property does what it says — usercatchdoesn't match it.)unwinds=yes— these controls unwind the stack until their handler is reached. Ordinary stack-unwinding behavior;ensureblocks run on the way through.
The class names (loop/return, block/return, etc.) are working names. They live at the top of the puck.uno/ namespace (puck.uno/loop/return, puck.uno/block/return) and are declared subclasses of puck.uno/exception — UNS placement and inheritance are independent. Names may rearrange when the spec gets a unifying pass; what matters at the architecture level is that they are declared subclasses of puck.uno/exception and participate in the same machinery.
Handler registration via as GitHub issue
as $name is the handler-registration syntax. When a construct opens a block with as, the runtime registers a handler on its internal stack tagged with the block's id. The construct's runner matches incoming controls against the classes it cares about and its own id.
Construct with as |
Handles classes | Tagged with |
|---|---|---|
while(...) as $loop |
loop/next, loop/return |
the loop's runtime id |
$bar.each($foo) as $loop |
same | same |
5.times as $loop do(...) |
same | same |
if(...) as $if |
block/return |
the if-block's runtime id |
Any block with as $name |
block/return |
the block's runtime id |
function &foo() |
return |
the function's call frame |
Plain return inside any function uses the enclosing function's handler regardless of how many blocks or loops are nested between. A bare return does not target intermediate as $if / as $loop handlers — it unwinds straight through them to the function's handler.
Target matching GitHub issue
A control value carries a target field — the id of the handler it's meant for. The unwinder walks the handler stack from innermost out:
- Each handler checks
(value.class, value.target)against its registered class set and its own id. - Match: the handler consumes the control and resumes whatever the construct's resume rule says (loop return exits the loop with the optional value; loop next jumps to the top of the next iteration; block return exits the block with the carried value; function return exits the function).
- No match: the handler passes; the control keeps unwinding.
Nested-loop semantics fall out for free:
$outer.each($a) as $loop_a
$inner.each($b) as $loop_b
if condition
$loop_a.return # targets $loop_a, not $loop_b
end
end
end
The return value carries target = $loop_a.id. The inner loop's handler doesn't match (its id is $loop_b.id), so the control re-propagates. The outer loop's handler matches; the outer loop exits.
ensure blocks GitHub issue
ensure blocks register pass-through handlers: they run on any control that crosses them, then re-propagate the original control. This is why an ensure block runs on a loop return the same way it runs on a function return, an exit, or a regular raise. One mechanism, one rule: ensure always runs, then the control continues.
The only exception is abort (engine-fatal) — its unwinds=no property bypasses ensure entirely. This is the intentional asymmetry that keeps untrusted code from using ensure to delay an engine kill.
Stale handlers GitHub issue
A loop object (or any as $name block object) captured in a closure and called after its construct has exited is stale — its handler is no longer on the runtime's stack. Calling .next/.return on a stale handler raises puck.uno/error/stale_handler instead of the control it would have raised.
$stored = null
[1, 2, 3].each($x) as $loop
$stored = $loop
end
$stored.return # raises error/stale_handler
The stale-handler raise is a normal catchable error (default catcher=user, unwinds=yes), so user code can decide what to do with it.
Cross-role boundaries — open GitHub issue
vibecode
{"vibecode": { "section": "cross_role_boundaries", "status": "open_question_to_settle_when_role_boundary_semantics_solidify", "the_question": "what_happens_when_a_loop_or_block_or_function_control_would_propagate_from_one_role_into_another", "plausible_answers": ["block_at_the_boundary_and_convert_to_a_catchable_error_in_the_outer_role", "silently_propagate_across_the_boundary_using_the_outer_roles_handler_stack", "a_third_option_that_falls_out_of_role_boundary_design_later"] }}
A control raised inside one role can in principle propagate across a role boundary (per roles.md) and look for a handler in the outer role's stack. Whether the runtime allows this — and if not, what happens at the boundary — is not yet decided.
A few plausible shapes:
- Block at the boundary; convert to a catchable error in the outer role. A loop runner in role B can't be the handler for a
$loop.returnraised by code that ran into role A; the propagation stops at the boundary, becomes a catchable error in B's caller (with the original class + target preserved in the bucket), and the caller decides. - Silently propagate. The control walks across the boundary the same way it walks across normal frames, using whatever handler stack the outer role exposes.
- Something else — falls out of the broader role-boundary design when that work happens.
Worth settling when the role-boundary mechanics in roles.md get their next pass. The implementation can keep the choice in one place (the unwinder's boundary check) once it's made.
What this buys the implementation GitHub issue
vibecode
{"vibecode": { "implementation_payoff": ["single_Control_type", "single_throw_function", "single_stack_walking_unwinder", "loop_runner_about_twenty_lua_lines", "ensure_is_one_pcall_plus_re_raise", "cross_role_boundary_check_lives_in_one_place_once_policy_settles"] }}
- One Control type, one throw function, one unwinder. Not separate machinery for next vs. return vs. catch.
- The loop runner becomes ~20 Lua lines. Register handler; run
beforeif first; run body inpcall; classify result (loop control I own → swallow & continue or exit; other control → re-raise; normal return → continue iteration); runbetweenbetween iterations; runafteronce at the end (ornoloopif zero iterations); propagate any unrecognized control out. ensureis onepcall+ re-raise in any host language.- Cross-role boundary policy (once the open question settles) lives in one place — the unwinder — not in N construct-specific paths.
- New constructs cost almost nothing. A future
matchstatement withas $matchregisters a handler forblock/return; that's the whole control-flow integration.
Implementors building Caspian on a host language should build the Control type and the unwinder first; every construct that exits or absorbs control then becomes a thin layer over the same primitive.
Conditional Constructs Share One Primitive GitHub issue
vibecode
{"vibecode": { "section": "conditional_constructs_share_one_primitive", "principle": "if_and_while_share_one_condition_body_primitive_at_runtime; they_differ_only_in_orchestration", "shared_primitive_name": "cond_run_once", "if_orchestration": "call_sequentially_through_elsif_branches_stop_on_first_match", "while_orchestration": "call_in_a_loop_until_false", "extensible_to": ["unless", "until", "do_while", "pattern_match_case_clauses"] }}
if and while are kindred constructs at the runtime level. Both take a condition expression and a body, evaluate the condition for truthiness, and decide what to do with the body. They differ only in what happens after a body runs (or doesn't):
ifevaluates its condition once. If true, body runs and the construct is done. If false, fall through to the nextelsifor theelse.whileevaluates its condition repeatedly. If true, body runs and the loop continues. If false, the construct is done.
A single shared primitive handles the inner mechanic; if and while differ only in how they orchestrate calls to it.
The shared primitive GitHub issue
function cond_run_once(cond_expr, body)
if truthy(eval(cond_expr)) then
execute_body(body)
return true -- condition matched, body ran
end
return false -- condition didn't match
end
cond_run_once is the atom: evaluate one condition, optionally run one body, report whether it matched. Both if and while are built on top.
if as one shape GitHub issue
if calls cond_run_once sequentially against each branch's condition, stopping at the first match. If no branch matches and an else body exists, it runs unconditionally.
function if_runner(branches, else_body, handler_id)
-- register block/return handler tagged with handler_id (per
-- the Structured Non-Local Control Flow section)
for branch in branches do
if cond_run_once(branch.cond, branch.body) then return end
end
if else_body then execute_body(else_body) end
end
branches is the list of if/elsif clauses; each has a cond and body.
while as another shape GitHub issue
while calls cond_run_once repeatedly until it returns false. The body lives inside cond_run_once's execute_body call, so it runs in the same handler scope as the loop itself.
function while_runner(cond, body, handler_id)
-- register loop/next + loop/return handlers tagged with handler_id
while cond_run_once(cond, body) do
-- intentionally empty; cond_run_once does the work
end
end
The empty Lua while-body is intentional: cond_run_once handles both the test and the action; the loop wrapper just calls it until it reports done.
What this buys the implementation GitHub issue
vibecode
{"vibecode": { "sharing_payoff": ["one_condition_evaluation_path", "one_body_execution_path", "constructs_differ_only_in_orchestration_a_one_liner_each", "new_conditional_shapes_layer_on_for_free"] }}
- One condition-evaluation path.
eval(cond_expr)plustruthy(value)lives in one place. Both constructs reuse it. - One body-execution path.
execute_body(body)plus the control-handler registration mechanism (per Structured Non-Local Control Flow) lives in one place. - Constructs differ only in orchestration.
ifis "call once, fall through on no match."whileis "call in a loop, stop on no match." Each is a one-liner over the shared primitive.
Combined with the control-flow section above, the distinct code for if and while is small: about a dozen lines each for the orchestration logic. Condition evaluation, body execution, control flow, handler registration, role transitions on cross-boundary calls — all shared.
Extensibility GitHub issue
The same primitive lines up for several construct shapes that may or may not land in Caspian:
unless cond ... end—cond_run_oncewith the condition negated; otherwise identical to single-branchif.until cond ... end— same aswhilewith the condition negated.do ... while cond end— callexecute_bodyfirst, thencond_run_oncein a loop. Body runs at least once.- Pattern matching (
match value ... case ... case ... end) — if Caspian ever adds pattern matching, eachcaseclause is a pattern-matching variant ofcond_run_once(match-or-not instead of truthy-or-not), orchestrated the same way asif/elsif.
The shared primitive isn't being designed to anticipate any of these; it just naturally accommodates them because the core insight — "evaluate one condition, optionally run one body, report the outcome" — is the same shape.
What this does NOT cover GitHub issue
.each and the numeric iteration helpers (.times, .upto, .downto) are not built on cond_run_once. They have no condition — the loop runs N times for N elements (or N steps in the numeric range). They're driven by whichever class implements .each, which typically calls execute_body once per element via the control-flow machinery directly.
So the shared primitive covers if / while and their condition-driven cousins; iteration over collections and numeric ranges is a different family that shares the control-flow mechanism but not the condition-evaluation mechanism.
Block Parameter Binding GitHub issue
vibecode
{"vibecode": { "section": "block_parameter_binding", "principle": "every_construct_that_takes_a_block_with_parameters_binds_names_to_values_via_one_shared_primitive", "shared_primitive": "bind_params", "unifies": ["function_definition_params", "closure_params", "do_block_params", "catch_binding", "as_binding"], "differences_between_constructs": "the_caller_supplies_a_different_arg_list; the_primitive_is_the_same" }}
Every construct that opens a block with named parameters does the same thing before running the body: pair the parameter list with the argument list and populate the new scope. The mechanic is one primitive; the constructs differ only in where their params and args come from.
The shared primitive GitHub issue
function bind_params(param_specs, arg_values, scope)
-- Walk param_specs in parallel with arg_values.
-- For each spec:
-- - if a corresponding arg exists, bind name → arg in scope
-- - else if the spec has a default, bind name → default
-- - else raise a typed exception (missing required param)
-- Handle extra args according to the param list's variadic rules.
end
bind_params is the atom: take a parameter spec list and an arg value list, populate the target scope. Default values, missing-arg errors, type checks (if any), and variadic handling all live here — in one place — instead of in every construct that takes a block.
Constructs GitHub issue
| Construct | What it binds |
|---|---|
function &foo($a, $b) |
call-site args bound to $a, $b in the function's new scope |
function($x) do (closure form) |
same — closure's args bound to its declared params |
each($item) do |
each iteration's element bound to $item |
do($index, $element) (block params on a do-block) |
block params bound from whatever the calling method passes |
catch(error) do($e) |
the caught exception bound to $e |
as $name (single-name binding) |
the construct's runtime object bound to $name |
as $name is the degenerate case: one parameter, no default, the "argument" is the construct's runtime object (loop, if-block, etc.) that the runtime creates before invoking the block.
What this buys the implementation GitHub issue
- One parameter-binding path. Default values, missing-arg errors, type messages all live in one place; updating any of them is a single-site change.
- New block-taking constructs cost almost nothing. A future
match value ... case ... do($matched) ... endjust callsbind_paramswith the matched value as the arg list. - The full parameters spec (positional, named,
*args,**opts, splat expansion, etc.) per parameters.md lives behind the same primitive — that doc describes the surface;bind_paramsis the runtime implementation.
Open GitHub issue
- Two parameter spec docs exist —
parameters.mdandparams.md. These cover overlapping ground (one focuses on metadata and lazy params; the other on call-site mechanics). Reconciling them is a separate task;bind_paramsworks against whichever shape settles. as $nameis currently described in caspian.md § TheasKeyword as its own concept. Recognizing it as a one-parambind_paramscall is a runtime-implementation observation, not a re-spec.
Unified Name Resolution GitHub issue
vibecode
{"vibecode": { "section": "unified_name_resolution", "principle": "every_named_reference_resolves_through_one_lookup_primitive_parameterized_by_namespace_chain", "shared_primitive": "lookup", "unifies": ["lexical_variables", "instance_vars", "sys_methods", "puck_uns_lookups", "chain_entries", "method_dispatch", "bucket_lookups"] }}
Every named reference in Caspian — $foo, @foo, %foo, puck.uno/foo via %puck[...], %chain.foo, $obj.foo for method dispatch, bucket.foo — is a lookup: given a name and a namespace (or a chain of namespaces tried in order), return the value or null. The sigil determines which namespace chain to consult; the lookup mechanism itself is one function.
The shared primitive GitHub issue
function lookup(name, namespace_chain)
for ns in namespace_chain
if ns has name
return ns[name]
end
end
return null
end
lookup is the atom: one name, an ordered list of namespaces, the first hit wins. Cross-namespace fallback (lexical scope walking, MRO for method dispatch) is just a longer chain.
Constructs GitHub issue
| Reference shape | Namespace chain |
|---|---|
$foo |
current lexical scope, then enclosing scope, then enclosing's enclosing, etc. up to the function's top |
@foo |
the current instance's bucket |
%foo |
the engine's sys-method table |
puck.uno/foo via %puck[...] |
the current puck's fetcher table (single namespace) |
%chain.foo |
the current frame's %chain entries |
$obj.foo (method dispatch) |
obj's class's methods, then the class hierarchy walked outward |
| bucket key access | a single bucket (no chain) |
What this buys the implementation GitHub issue
- One name-resolution path. Sigil parsing produces a namespace chain; the chain goes into
lookup; the value (or null) comes out. The same code serves every sigil. - Adding a new sigil costs almost nothing. Define what namespace chain it consults; the lookup mechanism handles the rest.
- Cross-namespace fallback is explicit. Each construct's chain is visible at the call site; there's no implicit "if not in scope, try the class" magic hidden in the interpreter.
- Method dispatch is a special case of lookup. Instead of separate "resolve method on this class" logic, method dispatch is
lookup(method_name, class_method_chain).
Open GitHub issue
- Whether
lookupis exposed to user code or kept internal. A user-facing%puck.lookup(or similar) might be useful for introspection; might be unwanted as engine internal. TBD. - Stop conditions for upward scope walks. Lexical scope walking stops at the function boundary; method dispatch stops at the root class. These are namespace-chain construction details, not lookup itself.
Typed Structured Events GitHub issue
vibecode
{"vibecode": { "section": "typed_structured_events", "principle": "exceptions_warnings_change_signals_and_log_entries_share_one_shape_and_one_emission_primitive_differing_only_in_runtime_behavior", "shared_shape": ["class", "id", "bucket"], "shared_primitive": "emit_event", "behaviors": ["unwinding", "heedable", "signal", "log"], "largest_restructuring_of_the_three": true, "status": "candidate_for_future_unifying_pass" }}
Four subsystems in Caspian emit typed structured events: exceptions, warnings, change signals, and Jasmine log entries. They already share the same outer shape — class, id, bucket — and most of them are emitted through %chain methods. They differ only in what the runtime does with each emission. A single emission primitive parameterized by a behavior could underlie all four.
This is the most invasive of the three tightening candidates: it touches the existing exception/warning spec, change signals, and Jasmine. The other two (block params, name resolution) are implementation tightenings without spec impact. This one would involve a unifying pass through several docs to align terminology and emission paths. Filed as an architecture direction rather than an immediate-action item.
The shared shape GitHub issue
Every event is {class, id, bucket} plus the same emission path through %chain (or its equivalent). Already true today for exceptions and warnings per Exceptions and Warnings; extends to change signals and log entries with no schema change.
Behaviors GitHub issue
| Behavior | What the runtime does | Used by |
|---|---|---|
| unwinding | Propagate up the stack until a registered handler matches; ensure runs on the way through; if uncaught, reaches the engine |
Exceptions (error, return, exit, loop/return, block/return, abort, security, timeout_handle) |
| heedable | Run any observers attached via heed(); no unwinding; engine continues |
Warnings |
| signal | Run any registered observers; no unwinding; observers are typically not user-facing | Change signals (mutation observation) |
| log | Write to the configured log sink (Jasmine); no observers, no unwinding | Jasmine entries |
The behavior is a property of the event's class — set at class- definition time, not at the emission site. %chain.error raises something with behavior=unwinding; %chain.warn raises something with behavior=heedable; the engine emits change signals with behavior=signal; Jasmine entries are behavior=log.
The shared primitive GitHub issue
function emit_event(event)
-- event has class, id, bucket; the class declares its behavior.
-- Dispatch on the class's behavior:
-- unwinding → throw it; the unwinder propagates per the
-- Structured Non-Local Control Flow section
-- heedable → invoke registered heeders; if none, no-op
-- signal → invoke registered observers; if none, no-op
-- log → write to the log sink
end
One emission function; the runtime path is selected from the event's class metadata, not from which %chain.X method was called.
What this buys the implementation GitHub issue
- One emission path. No separate functions for raising vs. warning vs. signaling vs. logging.
- One observer-registration mechanism.
catch,heed, change- signal observers, and Jasmine sink registration all configure the same dispatch table, just for different behaviors. - Shared shape across four subsystems. Class, id, bucket; catcher/unwinds/observer-set; all in one place.
- Adding a new event kind = adding a new behavior. No new emission machinery, no new dispatch path.
Open GitHub issue
vibecode
{"vibecode": { "events_open": [ "unifying_terminology_event_vs_signal_vs_flag", "reconciling_the_emission_apis_chain_error_chain_warn_etc_under_one_emit_function", "whether_jasmine_log_sink_registration_fits_the_observer_pattern_or_needs_its_own_path", "whether_change_signal_observers_are_the_same_objects_as_heeders_or_different"] }}
- Terminology. Current docs call the family "flags" in places, "events" and "signals" elsewhere. A unifying pass would pick one term and propagate it.
- Emission APIs.
%chain.error,%chain.warn,%chain.throw, change-signal emission,%jasmine.X— could all reduce to one%chain.emit(event)or similar, with sugar where helpful. The current per-behavior method names are user-friendly; collapsing them risks losing that clarity. Worth weighing. - Observer registration. Whether
catch,heed, change-signal observers, and Jasmine sinks are facets of one registration mechanism or four different things that happen to feed the same dispatch — TBD. - Whether to do this pass at all. The other two unifications (block params, name resolution) are pure wins; this one has real cost (the unifying-pass work). Worth doing if the runtime payoff is large; possibly defer if the existing four subsystems are already clean enough in isolation.
Object Model GitHub issue
vibecode
{"vibecode": { "section": "object_model", "canonical_spec": "see ../../ecoverse/objects/ for the full structural model; this section summarizes the parts a Caspian programmer touches", "two_fields": ["bucket", "stack"], "bucket": "shared data hash for the object", "stack": "ordered hash of platters; each platter contributes a class to the object's identity", "shadow_platter": "first platter; the name 'shadow' is required at position 1 (rule, not convention); holds singleton/instance methods; implicitly present-and-empty when not written explicitly in the stack", "single_class_default": "most objects have just the shadow platter plus their class platter; multi-platter stacks are the result of explicit .classes.add", "object_helper": "reserved_built_in_cannot_be_overridden", "explicit_dispatch": "$class.object.call_with($foo, 'method', args)" }}
Two-field objects GitHub issue
Every object has exactly two structural fields:
- bucket — a single shared hash where all the object's data lives
- stack — an ordered hash of platters; each platter contributes a class to the object's identity
Everything else — methods, fields, helpers — is behavior layered on top of these two primitives. This maps directly to how Mikobase records work and how Puck wire objects look: the same {bucket, stack} shape applies in the language, the store, and the protocol. The full structural spec — including the four recognized platter fields (class, warning, bucket, vibecode), the per-platter bucket used by mix-ins, and the position-1-is-always-shadow rule — lives at requirements/ecoverse/objects/.
The simplest object — empty bucket, no class beyond the shadow:
{
"bucket": {},
"stack": {
"shadow": {}
}
}
A typical object created from a named class has two platters: the shadow on top and the class platter beneath it.
%bucket GitHub issue
%bucket is a system method that returns the object's private data hash. All instance data lives here.
%bucket['foo'] = 'bar'
%bucket['foo'] # 'bar'
@foo is shorthand for %bucket['foo'] (when foo is a valid identifier). No prior declaration is required — @foo = 'bar' writes to the bucket whether or not the class declared field :foo. A field declaration with :get / :set flags creates getters and setters that read and write %bucket['<name>'] (see field :get / :set flags).
@foo = 'bar' # same as %bucket['foo'] = 'bar'
All platters in the stack share the same %bucket hash. Key collision between classes is the developer's responsibility — unless a class needs its own private storage, in which case it uses a per-platter bucket (%platter) instead.
Serialization is straightforward — %bucket is the object's data, so exporting it exports the object's state.
Buckets are not Puck hashes. The global Puck hash-key standard (snake_case names, JSON-shaped data) does not apply to object buckets. Buckets are internal storage with their own conventions; class designers pick whatever keys make sense for their state.
%platter GitHub issue
Mix-in classes — classes added to many different host objects as supplementary platters — can't safely store state on the shared bucket, since their key names would collide with whatever each host is already using. Each platter can carry its own private bucket for state that belongs to that platter's class, and the in-method accessor %platter reads and writes it:
%platter['parent'] = $other_node
%platter['children'] = $children_array
%platter['id'] = 'food'
Trivet (a tree-node mix-in) is the canonical example. Inside Trivet's methods, %platter resolves to Trivet's own platter bucket regardless of what host class the object started as. %bucket continues to be the shared bucket; @foo remains shorthand for %bucket['foo']. There is no @-style shorthand for the platter bucket — %platter[...] is always explicit.
Most platters don't have a per-platter bucket; the field is just absent. The mechanism is there when the class needs it. See requirements/ecoverse/objects/structure.md § bucket for the structural spec.
The stack GitHub issue
Method calls are resolved by walking the stack top to bottom. The first platter whose class defines the method wins. The shadow platter — required to be named "shadow" at position 1, implicitly present-and-empty when the explicit entry is absent — is at the top and dispatch starts there. There is no special "shadow always first" rule baked into dispatch; the shadow is simply the first platter in the stack.
For most objects the stack is short: a shadow on top with class: {} (default, empty), and the object's own class platter below it. A bare color instance:
{
"bucket": {"r": 255, "g": 0, "b": 0},
"stack": {
"shadow": {},
"2": {"class": "puck.uno/color"}
}
}
Adding more platters via .classes.add extends the stack downward; later additions sit lower in the stack and are reached after earlier ones during dispatch. The shadow stays on top because the rule says it does — see ecoverse/objects/structure.md § Stack for the position-1-is-always-shadow rule and the implicit-empty-shadow convention.
Marker classes (like puck.uno/class/redact) sit somewhere in the stack but have no methods to find — dispatch walks past them to whatever class defines the called method. Behavior-adding classes (a foo.uno/upper that overrides to_string) intercept dispatch because their methods are encountered first.
Null and false instances GitHub issue
When the engine creates a null or false value, it puts a platter directly under the shadow carrying the class puck.uno/null or puck.uno/false, then freezes the whole instance. Because the instance is frozen, the truthiness platter can't be added, removed, replaced, or moved — no mutation on the instance is permitted at all. The value can't be talked out of being null or false later.
{
"bucket": {},
"stack": {
"2": {"class": "puck.uno/null"}
}
}
A truthy value has no such platter under shadow. Truthiness is determined by asking, at the bottom of the dispatch walk, whether the object's stack carries a puck.uno/null or puck.uno/false platter — there is no separate truthiness-marker class. The full mechanism is in caspian/built-in-classes/object.md § Mechanism and the design discussion is in caspian/truthy.md.
Defining methods on the shadow GitHub issue
$foo.object.method(name) do(params) … end defines a method on this one object — it lives on the shadow, so other instances are unaffected. See object.md § method for the catalog entry.
null, true, and false are fully locked — their stacks, shadows, and buckets are all sealed. The reason is load-bearing: if any program could mutate true or null, every truthiness check everywhere becomes unreliable.
The object helper GitHub issue
Every object has a reserved helper called object that cannot be overridden. It's the home for engine-controlled introspection and lifecycle methods — truthiness predicates, stack inspection, identity equality, freezing, jail creation, borrow, explicit dispatch. The full catalog lives in built-in-classes/object.md. Highlights touching the stack:
.object.classes— array of the classes in the stack;.addpushes a new class..object.stack— the full stack hash with per-platter metadata (per-platterbucket,warning, etc.)..object.isa?(uns)— class-membership predicate; walks the stack including each platter's inheritance chain..object.method(name) do(params) … end— define a method on this one object (lives on the shadow)..object.call_with(receiver, method, args…)— explicit dispatch to a specific class's implementation, bypassing the normal walk..object.borrow(uns) do … end— push a transient platter for the duration of the block, then pop it. Useful for pluggable interpretations, visitor patterns, and adapter dispatch.
Helpers GitHub issue
vibecode
{"vibecode": { "section": "helpers", "base_class": "puck.uno/helper", "purpose": "namespace_methods_without_polluting_main_object_namespace", "access": "self.reference points back to parent object", "initialization": "lazy_not_created_until_first_accessed", "reserved_helper": "object built_in_present_on_every_object_cannot_be_overridden", "pattern_in_other_languages": "ActiveRecord_associations_jQuery_data_python_descriptors_ORM_query_proxies" }}
Why helpers exist GitHub issue
Object helpers are a common pattern across many languages: an object owns a separate sub-object that knows about its owner and provides a focused, related set of operations. Examples elsewhere:
- ActiveRecord's
user.posts— a collection proxy that knows it belongs to that user and exposes association-specific methods (.create,.where, etc.). - jQuery's
$elem.data()— a per-element data namespace accessed through a helper. - Python descriptors /
@propertynamespaces — sub-objects bound to an instance that expose related getters and setters. - Fluent-builder patterns where chained methods live on a separate builder instance bound to the parent.
The common shape across all of these:
- The helper is a distinct object with its own identity, not the owner in disguise.
- It knows its owner through a backreference, so its methods can reach back into the parent's state.
- Access is namespaced via dot-chaining:
$owner.helper.method, never$owner.methoddirectly. - It hosts a coherent cluster of related tasks — validation, serialization, association management, audit logging, etc. — that conceptually belong to the owner but would crowd the owner's main namespace if put there.
Caspian formalizes this pattern. A helper is an instance of puck.uno/helper (or a subclass) held by its owner, with @reference pointing back so its methods can call into the owner. The helper BWC inside a class body provides the syntactic sugar for declaring one.
This is structurally different from the platter model: platters add methods to the same object's identity (one identity, many class contributions); helpers introduce a sibling object with its own identity (one identity, one helper-owner relationship, separate dot-namespace access). Use a platter when extending what the object is; use a helper when adding a sub-area of related operations the object has access to.
Why .object is a helper: namespace hygiene by default GitHub issue
The original motivation for the helper mechanism is a specific problem: how to keep user classes from inheriting a noisy cloud of base-class methods.
Every Caspian object ultimately derives from puck.uno/object, which provides a substantial set of universal operations: tri-value truthiness (bool, null?, defined?), identity equality, role introspection (role, chain access), classes-stack inspection, the close-handler mechanism, and more. If those methods lived directly on every object, then a user defining a trivial class with one method:
class
method &send($msg)
...
end
end
…would produce instances exposing fifteen-plus methods, only one of which the user actually wrote. Auto-completion lists would be cluttered. The namespace of "what this thing does" would be drowned in inherited infrastructure.
puck.uno/object solves this by declaring exactly one helper, called object, and putting every universal operation on that helper instead of on the object directly:
$foo.object # the universal helper for $foo
$foo.object.role # current role of $foo's frame
$foo.object.bool # tri-value truthiness
$foo.object.classes # array of classes in the stack
$foo.object.on_close ... # close-handler introspection
So $conn from the example above exposes only two methods at its top level: .send (what the user wrote) plus .object (the universal helper). The universal cloud is reachable but namespaced away.
That makes helpers infrastructural, not just a convenience pattern. They're how Caspian keeps every user class from inheriting noise from the base. The mechanism exists to solve a specific design problem; the helper BWC is the syntactic sugar that lets user classes apply the same hygiene to their own concerns.
User code uses the same pattern. A myapp.com/connection class might declare:
class
method &send($msg) ... end
helper transport
function &configure(...) ... end
function &reconnect() ... end
function &teardown() ... end
end
helper metrics
function &bytes_sent() ... end
function &uptime() ... end
function &reset() ... end
end
end
Now $conn.send is the hot-path method developers reach for; .transport.<x> and .metrics.<x> cluster related operations under their own namespaces; the top-level method list of $conn stays short.
The self-recursive case: $foo.object is itself an object, so it has its own .object helper. That bottoms out cleanly — the engine recognizes the recursion and returns a degenerate helper for the helper-of-a-helper case rather than infinitely nesting. (Users rarely need this; it's mentioned for completeness because the rule "every object has .object" is universal.)
Mechanism GitHub issue
A helper is an instance of puck.uno/helper. It provides a way to namespace methods without polluting the main method namespace of an object.
The base helper class defines a single field, @reference, which points back to the parent object, and exposes it as self.reference from inside helper methods (declared as field :reference, :get in the base class — the :get flag generates the reader). So @reference is the bucket field; self.reference is the method-style reader of that field. Two surfaces, one piece of state.
Defining a helper GitHub issue
The helper bwc inside a class definition creates a lazily initialized helper:
$myclass = class
helper foo
method &bar()
return self.reference.gup
end
end
end
$myclass.foo.bar
This is shorthand for creating a helper class and a lazy getter that instantiates it with self as the reference:
$myclass = class
method &foo()
return @foo ||= $helper_class.new(self)
end
end
The helper is not created until first accessed.
object as a Reserved Helper GitHub issue
object is a built-in helper present on every object. It cannot be overridden. It is the home for primitive introspection operations that are rarely needed day-to-day, keeping them out of the main method namespace.
Classes GitHub issue
vibecode
{"vibecode": { "section": "classes", "identity": "from_reference_held_not_declared_name", "no_global_registry": true, "puck_namespace": "%puck[UNS] to access registered objects", "definition": "$myclass = class...end or %puck['https://puck.uno/object'].subclass do...end", "subclassing": "$new_class = $my_class.subclass do...end", "field_get_set_flags": "declared with field :foo, :get, :set, default: ...", "abstract": "abstract true prevents direct instantiation", "initializer": "init method", "methods": "method &name() inside class block" }}
Classes are objects. Like functions, they live wherever they are stored. There is no global class registry and no namespacing system like Foo::Bar. A class's identity comes from the reference held to it, not from a declared name. To use a class, you need a reference to it.
%puck GitHub issue
%puck is a system method that provides access to the global Puck object namespace. %puck[UNS] returns the object registered at that UNS address — which may be a class, but is not limited to classes.
%puck['https://puck.uno/mikobase/memory'].new
%puck['https://puck.uno/mikobase/http'].new(mikobase: $mikobase, socket: '/var/run/myhive.sock', auth: :peer)
In the first version, %puck only resolves a predefined set of built-in objects. When and how remote objects can be retrieved via %puck is a Puck design question for later.
Class definition syntax is a DSL — it uses the same dispatcher/bwc mechanism as any other DSL. There are no special parser rules for class definitions.
Instantiation GitHub issue
$my_class.new(...)
Defining a class GitHub issue
The standard form:
$myclass = class
end
For developers who need explicit access to the root class:
$myclass = %puck['https://puck.uno/object'].subclass do
end
Subclassing GitHub issue
$new_class = $my_class.subclass do
end
Subclassing is always a method call on the parent class object.
Field :get and :set flags GitHub issue
A field declared with no :get / :set flags is private to the class's own methods — accessible via @foo inside method bodies, not externally callable. The :get and :set flags auto-generate reader and writer methods that read from and write to %bucket['<name>']:
field :foo # private, no external access
field :bar, :get # creates a getter: bar()
field :gup, :set # creates a setter: gup=()
field :baz, :get, :set # creates both
These flags compose with any other field options (class:, required:, default:, etc.). They're just typed sugar over the bucket-access the developer could write by hand.
:get and 'get' are equivalent — :foo is shorthand for 'foo' throughout Caspian.
(Caspian previously had a separate accessor keyword for this. It has been removed and its functionality folded into field.)
Abstract Classes GitHub issue
A class declared abstract true cannot be directly instantiated. It must be subclassed. Attempting to call .new on an abstract class raises an exception.
$myclass = class
abstract true
end
Initializer GitHub issue
init is the method called when a new instance is created. It is defined using method &init(...) inside the class block:
method &init($name, $birthdate)
@name = $name
@birthdate = $birthdate
end
Methods GitHub issue
Methods are defined inside the class block using method &name(...):
method &greet()
return "Hello, I am " + @name
end
Full Example GitHub issue
$person = class
field :name, :get
field :birthdate, :get
field :email, :get, :set
field :active, default: false
method &init($name, $birthdate)
@name = $name
@birthdate = $birthdate
@email = null
end
method &greet()
return "Hello, I am " + @name
end
end
$p = $person.new(name: 'Jean-Luc', birthdate: '2305-07-13')
$p.greet # "Hello, I am Jean-Luc"
Subclass Example GitHub issue
$officer = $person.subclass do
field :rank, :get
method &init($name, $birthdate, $rank)
@rank = $rank
end
method &greet()
return @rank + " " + @name
end
end
Functions GitHub issue
vibecode
{"vibecode": { "section": "functions", "first_class": true, "no_lambda_syntax": "all_functions_already_objects", "blocks": "do...end closure multiple_blocks_chained", "yielding": "$dispatcher.yield or yield shortcut", "bwc_resolution_order": ["reserved_bwcs", "dsl_entries", "scope_variables"], "dsl_receivers": "dispatcher.dsl maps bare words to objects in yielded block", "caller_objects": "$foo.caller reusable configurable pending call", "amp_method": "any_class_can_define_& to_make_instances_invokable" }}
Functions are first-class objects. They can be assigned to variables, passed as arguments, and assigned to class methods. There is no concept of a named function — a function is just an object stored somewhere. They live where they're stored.
There is no lambda syntax. In other languages, lambdas exist to create passable function objects. In Caspian, all functions are already objects.
See caspian.md for function definition and call syntax.
Blocks and Yielding GitHub issue
A do...end block passed to a function call is a closure. Multiple blocks can be chained. Inside the function, %call.blocks is an array of the passed blocks in order.
do...end is only intended for a short array of blocks. If you want to get fancy with named blocks then pass them as named params.
The official way to call a block uses a dispatcher object:
$myfunc = function()
$dispatcher = %call.dispatcher # defaults to block at index 0
$dispatcher = %call.dispatcher(1) # explicit block index
$dispatcher.yield 'gup', 'bear'
end
&myfunc do($a, $b)
$a # 'gup'
$b # 'bear'
end
&dispatcher is shorthand for $dispatcher.yield:
&dispatcher 'gup', 'bear'
yield is a top-level shortcut for %call.dispatcher.yield:
$myfunc = function()
yield 'gup', 'bear'
end
Multiple blocks are chained with additional do...end after each end:
&myfunc do($a)
end do($b)
end
To yield to a specific block, use an explicit dispatcher index. yield always hits block 0.
If you want named blocks, pass functions as named parameters instead.
Bare Word Commands GitHub issue
A bare word command (bwc) is an unqualified word used as a method call. When the interpreter encounters a bwc, it looks in the current lexical scope to find the correct association.
This section documents the underlying dispatcher mechanism. For Caspian's language-wide commitment to using DSLs (the four-tier model, function-property convention, loop and class DSLs, the cheat clause), see dsl.md.
Resolution order:
- Reserved bwcs — built-in keywords such as
ifandwhile. These cannot be overwritten under any circumstances. - DSL entries — set via
$dispatcher.dsl. Evaluated before the incoming scope, so DSL mappings can override scope variables. - Scope variables — the normal lexical scope.
DSL Receivers GitHub issue
The dispatcher object has a dsl hash. Entries map bare words inside the yielded block to objects — a bwc resolves to a method call on the mapped object.
$myfunc = function()
$dispatcher = %call.dispatcher
$dispatcher.dsl['foo'] = $bar
$dispatcher.yield
end
&myfunc do
foo # calls $bar.foo
gup # calls $bar.gup
end
DSL entries take priority over scope variables, making them suitable for overriding default behavior inside a closure. Receivers are scoped to the closure call and do not leak into the surrounding scope.
DSL settings do not propagate down the call stack. They apply only to the directly yielded block — any nested function calls or blocks inside that block run without them.
Scoping GitHub issue
vibecode
{"vibecode": { "section": "scoping", "scope_per_block": true, "applies_to": ["if", "else", "loop_bodies", "bare_blocks"], "first_class_scopes": true, "closure_mechanism": "pass_scope_object_explicitly_to_function" }}
Every block creates a new scope that inherits from its parent. This applies to all blocks without exception — if, else, loop bodies, and bare blocks all create new scopes.
Scopes are first-class objects. This is the mechanism by which closures are implemented: a scope object can be passed explicitly to a function, making it act as a closure. There is no special closure type — any function becomes a closure when passed a scope.
System Methods GitHub issue
vibecode
{"vibecode": { "section": "system_methods", "prefix": "%", "not": "global_variables", "behavior": "scope_aware_may_return_different_objects_in_different_scopes", "defined_by": "engine_at_boot_time_only", "key_methods": { "%chain": "ambient_context_carries_request_scoped_values", "%engine": "gateway_to_host_resources_user_role_only_deliberate_special_case", "%call": "current_call_object_function_or_closure" } }}
Caspian has a small number of system methods, prefixed with %. These are not global variables — they are methods that return a scope-aware object. The same method call in different scopes may return different objects.
System methods are defined only by the engine at boot time. User code cannot create new %-prefixed methods.
%chain GitHub issue
Use
%chainsparingly. It is ambient state that is invisible in function signatures and can carry security-sensitive information. Prefer explicit arguments when possible. The security implications of%chainare discussed separately.
%chain is the chain context object. It carries information down the call stack — current user, request ID, locale, transaction context, security settings — without requiring it to be threaded through every function signature.
%chain has two main components: misc for arbitrary values, and stack for the call stack.
Block vs function isolation GitHub issue
%chain does not isolate at block boundaries. A write to %chain['foo'] inside an if, loop, or bare block persists after the block ends — chain flows freely through blocks within the same function. This is unlike the lexical scope of $foo variables, where every block creates a new inherited scope.
Isolation happens at function call boundaries. When a function is called, it gets its own chain that inherits from the caller's, but writes inside the callee do not propagate back up. The caller's chain is unchanged after the call returns.
If you want block-level isolation, use %chain.scope do...end to create an explicit boundary inside the same function.
%chain is cleared at role boundaries and inside %chain.clear blocks.
%stdout and %stderr do live in %chain. The system methods %stdout / %stderr read from chain; the engine places them at bootstrap, always as a real handle — either pointing at a real destination (terminal, capture buffer, etc.) or as a dev/null handle that discards writes (with a nanny warning, silenceable via no_writers_ok). They are never null, so writes can be sprinkled in without guards. This is also why %stdout.capture do ... end works without special primitives — it creates an inherited chain scope where %stdout is the capture buffer, runs the block, and the parent's %stdout is restored on exit. Same mechanism as function-call chain isolation, applied to a single ambient handle.
STDIN is different: it's not in %chain. The engine hands the script a STDIN object at bootstrap, and functions that need it must receive it as an explicit parameter. The asymmetry: incoming data needs role labeling (so the object travels explicitly with its role), while outgoing handles can be ambient (writes don't carry a role label, so the chain-replacement pattern works cleanly).
Misc values GitHub issue
%chain.misc['foo'] = 'bar'
%chain.misc['foo'] # 'bar'
%chain['foo'] is shorthand for %chain.misc['foo'].
Sandboxing GitHub issue
Each component can be cleared within a block, hiding it from code running inside:
# hide the call stack from untrusted code
%chain.stack.clear() do
# %call does not have the full stack here
end
# hide misc values from untrusted code
%chain.misc.clear() do
%chain['foo'] # null
end
# clear everything
%chain.clear() do
# no stack, no misc values
end
After each block, the original values are restored. %chain.clear() will clear all components, including any added in future.
Explicit scope block GitHub issue
%chain.scope do...end creates an explicit scope boundary:
%chain['foo'] = 'bar'
%chain.scope do
%chain['foo'] # 'bar'
%chain['foo'] = 'gup'
%chain['foo'] # 'gup'
end
%chain['foo'] # 'bar'
Clean scope GitHub issue
%chain.scope(inherit:false) starts with an empty chain, inheriting nothing:
%chain['foo'] = 'bar'
%chain.scope(inherit:false) do
%chain['foo'] # null
end
%chain['foo'] # 'bar'
%engine GitHub issue
%engine returns the engine object — the gateway through which the running script reaches host-provided resources (capabilities, configuration, injected objects). Only the user role can call %engine at all — any other role invoking %engine raises immediately, by a dedicated check in the engine object itself. The bootstrap pattern is: user code pulls resources from %engine and passes them down to libraries explicitly as parameters.
Full spec: engine/.
Function and closure opacity GitHub issue
Once a function or closure is defined, it exposes nothing about its internals to its caller. This is a general default that applies to every callable in the language. A caller can call a function or closure and receive its return value. That's all.
Specifically, none of the following is accessible to a caller:
- The function's or closure's body code.
- A closure's captured scope (variables, references, anything pulled in by the lexical capture).
- A closure's captured
%enginereference, if any. - Internal stack frames during or after the call.
- Parameter defaults set at definition time.
- Equality or identity comparisons that would reveal contents (beyond "is this the same callable object?").
There is no opt-out at definition time, and no API a caller can use to peer inside. Opacity is enforced uniformly — by language rule, not by per-callable configuration.
Testing requirement. This opacity guarantee is load-bearing for the security model (especially for closures capturing %engine, but more broadly for any function or closure carrying captured resources). It needs an extensive test suite verifying it holds against every introspection surface:
- Direct access (
$fn.scope,$fn.body,$fn.captures, etc.) — must not exist or refuse. - Indirect access via exception traces, stack inspection, or call metadata — must not leak captured values.
- Finished closures (after they've returned) — must not be inspectable for their captured state.
- Closure / function equality — must not reveal contents.
If any introspection path leaks, the language has a security regression that needs to be fixed before the leak is exploited.
Possible future feature: an opt-in inspection capability granted at definition time for debugging or REPL use cases — function .introspectable &foo() ... end or similar. Not in v1; flagged if a real debugging story needs it.
%call GitHub issue
%call returns the call object for the current function or closure. Inside a closure, %call refers to the closure call. Inside a function, it refers to the function call.
function &foo()
%call # the call to foo
&bar() do
%call # the call to the closure
%call.return # return from the closure
return # return from foo
end
%call.return # return from foo
return # also return from foo
end
%call.return exits the current call (function or closure) and returns a value from it. return always exits the calling function, propagating through closure boundaries.
%call.blocks is an array of do...end blocks passed to the current function, in order.
Caller Objects GitHub issue
$foo.caller returns a caller object — a reusable, configurable pending call to $foo. Parameters are set as properties, blocks are attached with do, and the call is executed with .call.
$foo = function(gup)
end
$caller = $foo.caller
$caller.gup = 'bear'
$caller.call
Caller objects are first-class — they can be passed around, further configured, and executed by whoever holds them. Setting a param before passing restricts what the receiver needs to supply.
Setting params GitHub issue
$caller.gup = 'bear'
Attaching blocks GitHub issue
Official form:
$caller.foo = do
end
Shorthand (what The People will want):
$caller.foo do
end
Multiple anonymous blocks are available via $caller.blocks, which is an array. For anything beyond simple cases, use named params instead.
Executing GitHub issue
$caller.call
Locking and freezing a caller before passing it around is deferred for later design.
The & Method GitHub issue
Any class can define a & method to make its instances invokable with the & sigil. & means "do the main thing on this object."
- Functions define
&to call themselves - Callers define
&to execute their call - Any other class can define
&for its own main action
&my_function # calls my_function.call
&my_caller # calls my_caller.call
How to define a & method in a class definition is deferred until method definition syntax is designed.
Jail GitHub issue
A jail is a capability-restricting proxy that wraps an object and exposes only a specified list of allowed methods. Pass a jail instead of the full object when the recipient only needs a subset of its capabilities — the object-capability pattern. Created via $foo.object.jail(:method1, :method2); see object.md for the full catalog entry.
Freezing GitHub issue
Freezing locks an object against modification, split into two independent axes — the class stack and the bucket — rather than conflating them into a single freeze. Three operations live on .object: freeze (both axes), classes.freeze (stack only), and bucket.freeze (bucket only). Without a block the freeze is permanent (there is no unfreeze); with a block it holds for the block's duration and releases on exit.
The bucket helper returns a jail wrapping %bucket with only :freeze permitted, so external code can freeze the bucket without seeing its contents.
Change Signals GitHub issue
vibecode
{"vibecode": { "section": "change_signals", "trigger": "hash_key_assigned_new_object", "listener_api": "$foo.object.listen field: 'bar', :on_change do($change) end", "change_object_fields": ["field", "old_value", "new_value"], "propagation": "auto_up_through_every_object_holding_changed_object", "signal_stack": "central_one_at_a_time_in_single_threaded_model", "deduplication": "object_can_appear_on_stack_at_most_once", "lazy_check": "skip_signal_if_nothing_listens", "hot_records": "mechanism_behind_automatic_mikobase_saves_on_write" }}
What a change is GitHub issue
A change is a hash key being assigned a new object:
$foo['bar'] = $something # change event fires on $foo
Scalars do not change. Assigning 2 where 1 was does not change the number 1 — it replaces a reference. Only hashes produce change events, because only hashes hold references that can be reassigned.
Listening to field changes GitHub issue
$foo.object.listen field: 'bar', :on_change do($change)
# fires when $foo['bar'] is assigned a new object
end
The block receives a change object:
$change.field # the key that changed ('bar')
$change.old_value # the previous object
$change.new_value # the newly assigned object
Signal propagation GitHub issue
When a hash key is reassigned, the signal propagates automatically up through every object that holds the changed object. Given:
$foo['bar']['gup']['baz']['bear'] = 1
bazchanges → signalsgup(which holdsbaz)gupchanges → signalsbar(which holdsgup)barchanges → signalsfoo(which holdsbar)foo's listener fires
Each object in the chain automatically re-signals its own listeners when it hears a signal from an object it holds.
The signal stack GitHub issue
Signals are processed through a central stack, one at a time, in order. In Caspian's single-threaded execution model, there is always either zero or one signal being processed. This eliminates concurrent signal storms — a new signal is never processed until the current one completes.
Three rules keep propagation tractable:
Lazy check. Before pushing a signal onto the stack, the system checks whether anything listens to the changed object. If nothing is registered, the signal is skipped entirely. Most objects have no listeners, so this makes the common case free.
Deduplication. An object can appear on the stack at most once. If a signal for an object that is already on the stack is generated, it is dropped. This handles any remaining cycle risk — a listener that reassigns a hash key can only trigger one additional signal per object.
Terminal listeners. Most listeners do work and stop — they call $foo.save, write to a log, update a counter. They do not reassign hash keys, so they generate no further signals. Cycles only arise when a listener reassigns a hash key, which is the unusual case.
Underlying hot records GitHub issue
The change signal system is the mechanism that makes hot mikobase records work. When a hot connection returns a record, the runtime automatically registers listeners up the reference chain. A write anywhere in the chain triggers a save back to the mikobase without the developer doing anything explicitly.
See mikobase.md for the hot record design.