Caspian GitHub issue

vibecode
{"vibecode": {
    "doc": "caspian",
    "language": "Caspian",
    "runtime_format": "CaspianJ",
    "influences": ["Ruby", "Perl"],
    "conventions": ["share_as_caspian_not_caspj", "formatter_enforces_style"],
    "example_universe": "Shakespeare"
}}

Overview GitHub issue

Caspian is the programming language of the Puck ecoverse. Caspian is designed from the ground up to handle running untrusted code.

Caspian's style is influenced by Ruby with some Perl and Bash mixed in.

A few examples of Caspian:

caspian
# Variables and string interpolation.
$name = 'Hamlet'
puts "Hello, #{$name}!"
caspian
# A function. Definitions use & on the name; calls pass keyword args.
function &greet($name)
    'Hello, ' + $name + '!'
end

puts &greet(name: 'Ophelia')
caspian
# Iterating an array with a do block.
$plays = ['Hamlet', 'Macbeth', 'King Lear']
$plays.each do($play)
    puts $play
end

Formatting standards, or lack thereof GitHub issue

vibecode
{"vibecode": {
    "doc": "caspian",
    "section": "community_formatting_standards",
    "role": "establishes the community norm for Caspian code formatting — write however you want, reformat code you receive; sidesteps tabs-vs-spaces debates",
    "key_concepts": ["vs_code_formatter_extension", "format_however_you_want",
        "reformat_on_receive", "no_house_style"]
}}

To avoid debates about tabs-vs-spaces and other bickering, a Visual Studio Code extension for Caspian is available (link here when it actually exists). That extension allows you to format Caspian to your own preference. The rule is simple:

That's it. Tabs-vs-spaces is a setting that each developer owns.


Security model GitHub issue

vibecode
{"vibecode": {
    "section": "security_model",
    "role": "high-level overview of Caspian's role-based security model; defers full semantics to roles.md and the bindings/filesystem/system-methods docs",
    "key_concepts": ["role_per_executing_function", "objects_owned_by_role",
        "boundary_is_cross_role_call", "host_provided_jails_and_bindings",
        "safe_defaults_no_nanny"]
}}

Caspian's security model is role-based, not trust-based. Every object — functions, data values, classes, instances — is owned by a role. The code currently executing belongs to a function-object, and that function-object's owning role is the current role. Trust is a directed relationship between roles, not a global property of code or data.

A cross-role call is the security-relevant event: when code in one role calls into a function owned by another role, the boundary triggers the role machinery (chain wiping, faucet narrowing, etc.). Code that stays inside a single role runs as if no security model were present at all.

The host process is what sets up roles. It assigns roles to loaded code, configures cross-role permissions, and injects scoped resources that Caspian can reach for host-controlled capabilities — typically:

Default posture is safe by default, with explicit overrides. A script that doesn't ask for filesystem access doesn't get any; a script that doesn't import a binding doesn't get the underlying capability. Where a restriction would block a legitimate use case, the system gives the developer a way through — never a flat refusal. See no nanny code for the principle.

For the full role spec — initial roles, transitions, faucets, isolation, cross-role exception handling, and what is and isn't checked at a boundary — see roles.md.


Syntax GitHub issue

vibecode
{"vibecode": {
    "section": "syntax",
    "role": "umbrella for all of Caspian's source syntax: strings, variables, blocks, control flow, functions, classes, loops, and related conventions",
    "key_concepts": ["surface_syntax", "ruby_perl_bash_influences",
        "end_terminated_blocks", "sigils_dollar_at_percent_amp",
        "dsl_first_design_with_parser_shortcuts_only_when_necessary"]
}}

Most of what looks like a keyword in Caspian source is actually a bare-word command (bwc) resolved through a DSL, not a parser keyword. See dsl.md for the language-wide DSL commitment, the four-tier token model (parser-built / reserved invariants / DSL-overridable / pure DSL), and the cheat clause for cases where parser shortcuts are warranted.


Strings GitHub issue

vibecode
{"vibecode": {
    "section": "strings",
    "quote_styles": ["single_not_interpolated", "double_interpolated", "heredoc"],
    "colon_shorthand": ":foo == 'foo'",
    "bare_word_keys": "equivalent_to_string_keys_in_hashes_and_kwargs",
    "interpolation_forms": ["$variable", "#{}"],
    "heredoc_strips_leading_whitespace": true
}}

Default to single quotes GitHub issue

Use single quotes unless interpolation is needed. All documentation examples follow this convention.

Single-quoted strings GitHub issue

Not interpolated. What you write is what you get:

'hello world'

Colon shorthand GitHub issue

:foo is shorthand for 'foo'. Caspian does not have a symbol type, but sometimes it's handy to have something look symboly.

:foo     # same as 'foo'
:get     # same as 'get'

Bare word keys GitHub issue

Bare words used as hash keys or keyword argument names are also strings. All three forms are identical:

{foo: 'bar'}
{'foo': 'bar'}
{:foo => 'bar'}

The same applies to keyword arguments in function calls:

&greet(name: 'Hamlet')      # name: is 'name'

Hash keys must be strings. Numbers, booleans, objects, and all other types are invalid as hash keys — using one is an error.

Double-quoted strings GitHub issue

Interpolated. Variables and expressions can be embedded:

"my name is $foo"
"my name is #{@name}"
"my name is #{&something}"

$variable interpolates directly. #{} interpolates any expression.

Heredocs GitHub issue

$string = <<'EOF'
    indented content
EOF

$string = <<EOF
    same thing, no interpolation
EOF

$string = <<"EOF"
    interpolated: $foo
EOF

Leading whitespace is stripped to the least-indented line. If a developer mixes tabs and spaces then that's sloppy and I won't take the blame for it. Ugly results will be their own fault and they deserve it.

Type hints GitHub issue

Any heredoc opener may carry an optional type hint in parentheses immediately after the delimiter:

$json_blob = <<EOF('json')
    {"foo": {}}
EOF

$markdown = <<"DOC"('markdown')
    # Heading
    interpolated: $author
DOC

$raw = <<'YML'('yaml')
    key: value
YML

The type hint is a tooling-only metadata marker. The Caspian engine silently ignores it — the string at runtime is exactly the heredoc body, with no attached type. Tools that read source code (the VS Code extension's syntax highlighter, the future LSP, doc generators) use the hint to drive behaviors like embedded syntax highlighting and per-type validation.

Recognized type tags follow common short names: json, yaml, markdown, html, css, xml, sql, regex, caspian. Tools may extend this set; unknown tags fall through to plain-text treatment.

One implicit special case: %vibecode always carries JSON content by convention, so tools should treat %vibecode <<EOF (no type tag) as if it were <<EOF('json'). The tag isn't required because the surface guarantees the type.


Booleans and null GitHub issue

vibecode
{"vibecode": {
    "section": "booleans_and_null",
    "role": "introduces Caspian's three primitive literals — true, false, null — and notes that null carries optional flavor metadata; defers detailed semantics to nulls.md",
    "key_concepts": ["true_false_literals", "null_is_a_value_not_absence",
        "null_flavors_metadata"]
}}

Caspian's three primitive literals:

caspian
$active   = true
$retired  = false
$nickname = null

Caspian implements null flavors. See nulls.md for the full spec and nulls.md § Null Flavors for the flavor model.


Variables GitHub issue

vibecode
{"vibecode": {
    "section": "variables",
    "sigil": "$",
    "variable_object": "$$foo returns variable object",
    "variable_object_value_accessor": ".value (gettable and settable)"
}}

Variables are prefixed with $, Perl-style:

caspian
$role     = 'Prince'
$name     = 'Hamlet'
$greeting = $role + ' ' + $name
puts $greeting               # Prince Hamlet

Once a variable is set, the $ form refers to the value:

caspian
$prince  = 'Hamlet'
$soliloquy = 'To be or not to be'
puts $prince + ': ' + $soliloquy

$$foo returns the variable object itself — a first-class handle to the variable slot, distinct from $foo (the value it holds). Variable objects can be passed around like any other object. The variable's value is read and written through .value:

caspian
$foo = 1
$$foo.value           # 1
$$foo.value = 2
$foo                  # 2

This makes pass-by-reference a supported pattern: hand $$foo to a callee, and the callee can read or replace the variable's value through that handle.


Blocks GitHub issue

vibecode
{"vibecode": {
    "section": "blocks",
    "closed_with": "end",
    "scope": "every_block_creates_new_inherited_scope",
    "applies_to": ["if", "else", "loop_bodies", "bare_blocks"]
}}

Blocks are closed with end, Ruby-style. Every block creates a new inherited scope. This applies to all blocks without exception — if, else, loop bodies, and bare blocks.

caspian
# `if` block
if $role == 'King'
    puts 'My liege.'
end

# `while` block
while $count > 0
    $count = $count - 1
end

# Bare block — just `do ... end` standing on its own
do
    $sealed_letter = 'For Polonius, in confidence'
    puts $sealed_letter
end

Inherited scope. Variables defined in the outer scope are visible inside the block:

caspian
$courtier = 'Polonius'

if $at_court
    puts 'At court: ' + $courtier   # $courtier inherited from the outer scope
end

New scope. Variables defined inside a block stay inside — they are not visible after the end:

caspian
$prince = 'Hamlet'

if $prince == 'Hamlet'
    $play = 'Hamlet'
end

puts $play     # error — $play was scoped to the `if` block

To make a variable survive the block, declare it outside first:

caspian
$play = null

if $prince == 'Hamlet'
    $play = 'Hamlet'
end

puts $play     # 'Hamlet'

Multi-section blocks GitHub issue

vibecode
{"vibecode": {
    "section": "multi_section_blocks",
    "principle": "multi_boundary_blocks_are_acceptable_when_each_boundary_marks_a_distinct_structural_phase_of_the_constructs_run; not_when_boundaries_mark_conditional_alternatives",
    "kind_1_phase_markers_acceptable": ["loop_before_between_after_noloop",
        "begin_ensure_end_or_equivalent"],
    "kind_2_conditional_alternatives_avoid": ["if_elsif_else"],
    "rationale": "phase_markers_have_no_clean_replacement_outside_the_construct; conditional_alternatives_have_clean_replacements_via_sequential_ifs_or_lookup_tables_or_polymorphism"
}}

Caspian has constructs whose syntax breaks into multiple labeled sub-sections inside a single endif / elsif / else, loops with before / between / after / noloop, begin / ensure / end, and so on. (elsif and elseif are accepted as synonyms; they transpile to the same CaspianJ.) Whether such a shape is desirable depends on what kind of boundary each label represents:

Examples:

Construct Boundary kind Status
Loops with before / between / after / noloop Phase markers Keep
begin / ensure / end (or whatever try/finally settles as) Phase markers Keep
if / elsif / else Branch boundaries Avoid

Why phase markers are fine. The loop's before block runs once before the body, between runs between iterations, after runs after the last iteration. Each is tied to a structural event the loop already tracks internally. Pulling these out of the loop and replacing them with hand-rolled state — a flag for "first iteration," a counter to know when to print "between," etc. — is uglier than keeping them inside the construct. The phase markers are sugar over state the loop already tracks. There's no clean alternative.

Why branch boundaries are worth avoiding. if / elsif / else is sugar over else if, which is sugar over nesting. An if-elsif chain can always be flattened into something that doesn't need multiple boundaries inside one end:

The branch-boundary shape isn't wrong; the alternatives are generally clearer.

Applying the rule to future constructs. When a new construct introduces labeled sub-sections — a match statement with case clauses, a state-machine block with named states, an HTTP handler with before / process / after phases, anything else — the question to ask is: is each sub-section a phase the construct itself runs through, or is it an alternative path the developer chose? If the former, multi-boundary syntax is fine. If the latter, prefer a form where each path stands on its own.

When do is Required GitHub issue

vibecode
{"vibecode": {
    "section": "do_keyword",
    "required_for": "block_passed_as_argument_to_function_call",
    "not_used_for": ["control_structures", "definitions"]
}}

The do keyword marks a block being passed as an argument to a function call. That is its only role.

No do for control structures. Their body follows the head directly:

if $foo == 'bar'
    # body of the if
end

while $foo
    # body of the while
end

begin
    # body of the begin
ensure
    # cleanup
end

No do for definitions. Same reasoning — the body is part of the definition:

function &foo(x)
    # body
end

class
    # body
end

do required for blocks passed to function calls. Without do, the parser has no way to know there's a block argument coming:

$server.get('/path') do($request)
    # block argument to .get()
end

catch('foo.com/error/network') do
    # block argument to catch()
end

%utils.tempdir do($jail)
    # block argument to %utils.tempdir
end

The distinction:

Pick one form and stick with it. Do not write while $foo do ... end or if $foo do ... end. Ruby allows it; we don't. Keeps the rule clean: do means block-as-argument, nowhere else.


Statement Termination GitHub issue

vibecode
{"vibecode": {
    "section": "statement_termination",
    "implicit_terminator": "newline",
    "explicit_terminator": "semicolon",
    "continuation_signals": ["trailing_comma", "trailing_binary_operator",
        "leading_dot", "leading_binary_operator"]
}}

A statement is terminated by a newline. To put multiple statements on one line, separate them with semicolons:

$foo = 1; $bar = 2; $foo + $bar

A statement can also span multiple lines via continuation. Lines continue when:

%chain.error 'connection_refused', {
    host: 'db1',
    port: 5432,
}
$obj.foo
    .bar
    .baz

Outside these continuation signals, a newline ends the statement. The parser doesn't need cleverness beyond these rules — they're the same rules Ruby and similar languages use.


The __END__ Marker GitHub issue

vibecode
{"vibecode": {
    "section": "end_marker",
    "role": "everything after a bare __END__ line is ignored by the parser",
    "key_concepts": ["must_be_on_own_line",
        "must_not_be_inside_string_or_heredoc",
        "borrowed_from_perl_and_ruby"]
}}

A line containing only __END__ terminates the script. Everything after it is ignored by the parser.

$foo = 'hello'
puts $foo
__END__
this and everything else in the file is ignored

Two rules:

Borrowed from Perl and Ruby.


Functions GitHub issue

vibecode
{"vibecode": {
    "section": "functions",
    "callables": ["function", "closure"],
    "function_captures_scope": false,
    "closure_captures_scope": true,
    "sugar": "function &foo() == $foo = function()",
    "call_sigil": "&",
    "inline_do_blocks": "behave_like_closures",
    "remote_function": "delegates_to_%puck.call"
}}

Definition GitHub issue

There are two kinds of stored callable: functions and closures. They differ in whether they capture the lexical scope at the point of creation.

Function — does not capture outer scope:

function &foo($a, $b)
    # body
end

This is the everyday form. It works both at top level and inside class blocks for method definitions. Mechanically, it's a shortcut for the assignment form:

$foo = function($a, $b)
    # body
end

— the two are equivalent. After either form, $foo refers to the function object and &foo calls it.

Closure — captures the lexical scope where it is defined:

$bar = closure($a, $b)
    # body
end

No do between the parameter list and the body — definitions own their body directly (per When do is Required). The parameters are the function's parameters; outside variables are invisible to a function; a closure sees everything in scope at the point it was created.

Inline do blocks GitHub issue

Inline do blocks passed to method calls (.each, route handlers, etc.) behave like closures — they capture the outer lexical scope:

$x = 'hello'

$items.each($item) do
    puts $x    # $x is visible
end

Scope summary GitHub issue

Form Captures outer scope?
$f = function(...) ... end No
$f = closure(...) ... end Yes
function &f(...) ... end No (sugar for function)
inline do ... end block Yes

Calling GitHub issue

The & sigil calls a function. All of the following are equivalent call forms:

&foo(1, 2)    # with parens
&foo 1, 2     # without parens
&foo()        # explicit empty call
&foo          # bare call, no arguments

$foo refers to the function object. &foo runs it. This distinction is intentional — it makes passing functions as objects unambiguous.

Remote methods GitHub issue

remote method declares a method that delegates to %puck.call. It is shorthand for an explicit remote dispatch — the two forms are equivalent:

# shorthand
remote method &save(name:)
end

# equivalent explicit form
method &save(name:)
    %puck.call(self, :save, name: name)
end

%chain is forwarded automatically in both forms. See puck.md for the full %puck.call design.


Classes GitHub issue

vibecode
{"vibecode": {
    "section": "classes",
    "definition_keyword": "class",
    "class_name_format": "UNS",
    "schema_declarations": ["inherits", "abstract", "field", "join"],
    "field_types": ["built_in_string_names", "UNS_addresses"],
    "field_get_set_flags": "auto_generated_getter_setter_methods",
    "helper": "lazily_initialized_namespaced_sub_object"
}}

Definition GitHub issue

A class is defined with the class keyword and a UNS name. The block contains schema declarations and method definitions:

class
    inherits 'foo.com/person'

    field :name, class: :string, required: true, collapse: true
    field :age,  class: :number, min: 0, integer_only: true

    method &greet(name:)
        'Hello, ' + name
    end
end

Anonymous (bare) class GitHub issue

The UNS name may be omitted. A bare class ... end block produces an anonymous class — a class with no UNS identity of its own. The class is just a value the surrounding code captures (typically by assignment, return, or the host that loaded the file):

class
    inherits 'puck.uno/robinson/page'

    method &process($request)
        response.html(200, '<h1>Hello</h1>')
    end
end

Anonymous classes are used where a class's identity comes from its location rather than its UNS — e.g., Robinson page files (identified by their path in the directory tree) and Robinson per-directory handlers. inherits and the other schema/method declarations work the same way as in named classes.

If an anonymous class needs to refer to itself, capture it in a variable:

$page_class = class
    inherits 'puck.uno/robinson/page'
    ...
end

Schema declarations GitHub issue

Schema declarations define the class's structure. They map directly to the JSON class definition stored in the mikobase.

Declaration Description
inherits 'UNS' Inherit from a parent class
abstract true Prevent direct instantiation
field :name, ... Declare a field
join :a, :b Required, unique-in-combination, immutable fields

field GitHub issue

field declares a field with a name and keyword options. The options map directly to the JSON field definition:

field :name,      class: :string, required: true, collapse: true
field :age,       class: :number, min: 0, integer_only: true
field :homeworld, class: 'puck.uno/reference', allowed_class: 'foo.com/planet'

Built-in type names are strings — :string and 'string' are identical. UNS names use the quoted form by convention since they contain dots and slashes.

Field declarations are optional. Under the simplified @ sigil rule (@foo is plain sugar for %bucket['foo'], no declaration required), a method body can read or write any bucket key via @foo whether or not the field was declared. field declarations are useful when the developer wants schema metadata (persistence, type constraints, defaults, validation) or auto-generated getter/setter methods — see below — but they are not required to enable @foo access.

Auto-generated getters and setters. field accepts :get and :set flags that produce reader and writer methods automatically:

field :nickname                            # private — no auto methods
field :nickname, :get                      # adds a getter: $obj.nickname
field :nickname, :set                      # adds a setter: $obj.nickname=()
field :nickname, :get, :set                # adds both
field :nickname, class: :string, :get      # combined with other options

The generated methods read from and write to %bucket['nickname'] — equivalent to what the developer would write by hand. Without the flags, the field is private to the class's own methods (which can still access it via @nickname).

(This is the role that was previously filled by a separate accessor keyword. accessor is gone; everything it did is now on field.)

Abstract classes GitHub issue

abstract true prevents direct instantiation. Subclasses may still be instantiated:

class
    abstract true
end

Join classes GitHub issue

join marks the listed fields as required, unique in combination, and immutable after write:

class
    field :person,  class: 'puck.uno/reference', allowed_class: 'foo.com/person'
    field :episode, class: 'puck.uno/reference', allowed_class: 'foo.com/episode'

    join :person, :episode
end

Helpers GitHub issue

helper creates a lazily initialized helper object namespaced off the parent:

class
    helper :stats
        method &average()
        end
    end
end

$character.stats.average

Loops GitHub issue

All loop forms — while, .each, and the numeric iteration helpers (.times, .upto, .downto) — and everything about them (loop object via as, control methods, structural before / between / after / noloop blocks) live in loops.md.


The as Keyword GitHub issue

vibecode
{"vibecode": {
    "section": "as_keyword",
    "purpose": "bind_a_handle_to_the_block_invocation; api_surface_depends_on_caller",
    "syntax": "block_declaration as $name",
    "placement_rule": "as_immediately_follows_the_block_declaration_not_the_receiver",
    "handle_richness": {
        "plain_method_call": "thin_closure_or_call_like_handle",
        "loop": "rich_loop_object_with_count_active_return_break_next",
        "if_elsif_else": "branch_handle_with_explicit_return"
    },
    "unifying_principle": "same_syntax_caller_decides_api_surface"
}}

as $name binds a handle to the block's invocation. The handle's API surface depends on who is calling the block — same syntax, same meaning ("give me a handle on this block"), different richness based on context.

Placement GitHub issue

as $name always immediately follows the block declaration — never the receiver. Examples:

if (foo) as $if              # if/elsif/else block
while ($foo) as $loop        # while body (opens implicitly)
$bar.each($foo) as $loop     # block opened by .each
5.times do($i) as $loop      # explicit do(...) block
$foo.action do($i) as $call  # plain method call with do(...)

In the last two, as follows do(...) — not 5.times or $foo.action. The block is what as names.

Handle API by caller GitHub issue

The handle's methods depend on who's invoking the block. Three common shapes:

Caller Handle is Why
if / elsif / else branch handle with .return The block runs once; explicit return-value control is the useful affordance.
Loops (while, .each, .times, etc.) loop object with .count, .active, .index, .next, .return, .break The block runs repeatedly; iteration state and per-iteration control are the useful affordances.
Plain method call ($foo.action do(...) as $X) thin closure/call-like handle (basically %call) The block runs once at the method's discretion; only the basics are exposed.

Same as $name form in all three cases. The difference in API is genuine information about what kind of context the block is running in — not a contradiction.

if / elsif / else GitHub issue

$gup =
    if (foo) as $if
        $if.return 'foo'
    elsif (bar)
        $if.return 'bar'
    else
        $if.return null
    end

as is declared on the opening if. The named handle is accessible across all branches (elsif, else). If $if.return is not called, the block returns the value of its last statement.

Loops GitHub issue

while (&foo) as $loop
    $loop.count
end

A .each over a collection, using $loop.count for 1-based numbering:

caspian
$plays = ['Hamlet', 'Othello', 'Macbeth', 'King Lear']

$plays.each($play) as $loop
    puts $loop.count + '. ' + $play
end
# 1. Hamlet
# 2. Othello
# 3. Macbeth
# 4. King Lear

A .times loop that exits early with a value via $loop.break. The named loop expression evaluates to whatever .break was called with:

caspian
$found = 5.times do($i) as $loop
    if $plays[$i].is_tragedy?
        $loop.break $plays[$i]    # exit loop; expression is $plays[$i]
    end
end
# $found is the first tragedy found, or null if .break was never reached

A .next to skip an iteration without exiting the loop:

caspian
$plays.each($play) as $loop
    if $play.is_comedy?
        $loop.next                # skip comedies; continue with the next $play
    end
    stage($play)
end

The loop object exposes .count, .active, .index, .next, .return, and .break — see loops.md § Loop object methods. $loop.return and $loop.break are aliases.

For the prefix-free break / break N bwc form (no $loop reference needed; supports multi-level exit), see loops.md § break.

Plain method call GitHub issue

$foo.action do($i) as $call
    $call.return $i + 1   # exit the block early with a value
end

For a one-shot block passed to an ordinary method, the handle is just what the method needs to invoke and (optionally) accept a return value. It's the same shape as the engine's %call reference.


Return and Emit GitHub issue

vibecode
{"vibecode": {
    "section": "return_and_emit",
    "return": "exits_current_function_propagates_through_closures",
    "call_return": "%call.return exits current call only closure or function",
    "distinction": "return=exits_calling_function, %call.return=exits_current_call"
}}

return GitHub issue

return exits the current function, raising puck.uno/return. Inside a closure, return propagates through the closure boundary and exits the calling function.

$my_closure = closure() do
    return 'something'   # exits the calling function, not the closure
end

%call.return GitHub issue

%call.return exits the current call — function or closure — and returns a value from it. Inside a closure, it exits the closure without affecting the calling function.

function &foo()
    &bar() do
        %call.return 'gup'   # exits the closure
    end

    return 'bear'            # exits foo
end

The distinction: - return — exits the calling function (propagates through closures) - %call.return — exits the current call only (closure or function)

The %call object itself is owned by the caller's role, not by the role that owns the function being called. That matches its semantic role: %call represents the call as the caller initiated it; %call.return hands a value back to the caller and exits the callee. From inside the function, reading %call is a cross-role access into the caller's role — what the function sees is the caller's view of the call, not the function-owner's.


Safe Navigation GitHub issue

vibecode
{"vibecode": {
    "section": "safe_navigation",
    "operator": "&.",
    "behavior": "short_circuits_to_null_if_receiver_is_null",
    "example": "$foo&.bar.gup"
}}

&. is the safe navigation operator. If the receiver is null, the entire chain short-circuits to null rather than raising an error:

$foo&.bar.gup        # null if $foo is null
$foo.bar&.gup.bear   # null if $foo.bar is null

Pipe Operator GitHub issue

vibecode
{"vibecode": {
    "section": "pipe_operator",
    "operator": "|",
    "null_safe_variant": "|&",
    "behavior": "passes_result_as_first_positional_arg_to_next_stage",
    "implementation": "syntactic_sugar_desugared_by_transpiler",
    "null_safe_note": "once_used_all_subsequent_stages_short_circuit_on_null"
}}

The | operator chains operations left-to-right. Each stage passes its result as the first positional argument to the next. Pipes are syntactic sugar — the transpiler desugars them into ordinary nested calls in CaspianJ.

Basic pipe GitHub issue

&baz |
&bear |
$bar.gup

Desugars to:

$bar.gup(&bear(&baz))

Pipes can appear on the same line or split across lines:

&baz | &bear | $bar.gup

Both forms are identical. The multi-line form is preferred for long chains.

Null-safe pipe (|&) GitHub issue

|& activates null propagation for the remainder of the chain. Once used, every subsequent stage short-circuits to null if its input is null:

&foo |&
&bar |
&gup

If &foo returns null, the chain stops and returns null. If &bar returns null, same. The |& switch applies to all remaining stages — you do not need to repeat it.

Operator Meaning
`\ `
`\ &`

Unicode Method Names GitHub issue

vibecode
{"vibecode": {
    "section": "unicode_method_names",
    "feature": "any_valid_unicode_identifier_allowed_as_method_name",
    "example": "$foo.√ is alias for square_root",
    "requirement": "compliant_engine_must_accept_unicode_method_names"
}}

Caspian identifiers, including method names, may contain Unicode characters. This allows methods to be named with mathematical or symbolic notation where it improves readability.

The canonical example is the square root operator on Number:

$foo = 16
$foo.√     -> 4

is a valid method name and an alias for square_root. A compliant engine must accept any valid Unicode identifier as a method name.


Method Naming Conventions GitHub issue

vibecode
{"vibecode": {
    "section": "method_naming_conventions",
    "question_mark_suffix": "method returns truthy_or_falsey; truthy form is whatever's most useful",
    "examples": ["isa?", "null?", "defined?", "parse?", "timeout?"]
}}

The ? suffix GitHub issue

The ? suffix is a Puck convention, not a language-enforced contract — the Caspian parser doesn't treat names ending in ? specially. It's a hint to readers about how a method behaves, not a hook with semantics baked in. The convention is still settling through use; the patterns below describe how it's currently used, not a rule about what it must always mean.

Current usage clusters around methods that return a truthy-or-falsey result, where the truthy form is often the object itself rather than a bare true. Callers can typically treat the call as a predicate (if x.foo?) regardless of what the truthy form actually is.

Examples spanning the spectrum:

A common pattern in current code: method? doesn't throw on the failure path it's named for. Reach for the ? form when you expect failure sometimes and want to handle it as a value; use the plain method when failure indicates a bug and should propagate. This is a guideline drawn from how the convention gets used today, not a contract — the convention will evolve as more methods get written.

A method may have both forms (parse strict + parse? tolerant). When both exist, they typically produce the same successful result; only their failure behavior differs.

vibecode
{"vibecode": {
    "section": "what_is_not_yet_designed",
    "status": "partial_spec",
    "notes": ["document_captures_decisions_made_so_far"]
}}

Most of Caspian is not yet fully specified. The above captures decisions made so far. Further design will be added as Caspian develops.


Garbage Collection GitHub issue

Caspian uses deterministic garbage collection — objects die the moment they become unreachable, and a strict on_close hook runs cleanup at scope exit. Full spec lives in its own doc: garbage-collection.md.


Transpilation GitHub issue

vibecode
{"vibecode": {
    "section": "transpilation",
    "target": "CaspianJ",
    "notes": ["see_caspianj_md_for_format",
        "surface_syntax_separated_from_runtime_form_to_keep_alternate_syntaxes_architecturally_open"]
}}

Caspian compiles to CaspianJ. See caspianj.md for the CaspianJ format.

Caspian is the canonical surface syntax — the one users see and write today. The language architecture deliberately separates the surface syntax from the canonical runtime form (CaspianJ), preserving the possibility of alternate grammars that also transpile to CaspianJ. To, for example, if you want a grammar that looks like Python, you could design it:

text
if $foo
    &do_something

Then just write some code that transpilers your grammar to CaspianJ.

I don't have any plans to develop alternate grammars, but the options is there if the community wants to develop it.


Cache: conflicts and gaps GitHub issue

vibecode
{"vibecode": {
    "section": "cache_conflicts_and_gaps",
    "role": "report on cache-related conflicts and gaps; trimmed 2026-06-02 after the official cache spec landed at downloads/caching/index.md; resolved items struck for traceability, still-open items live below",
    "current_spec": "caspian/downloads/caching/index.md"
}}

The official cache spec landed at caspian/downloads/caching/ on 2026-06-02 (relocated into downloads/ since caching is one way of downloading a library). Many items in earlier drafts of this report are now resolved; they're listed struck for traceability. Items still open are listed live below.

Resolved by downloads/caching/index.md GitHub issue

Still open GitHub issue

"Engine library cache policy" is referenced but never defined GitHub issue

engine/require.md says cache priming via %engine.require "is governed by the engine's library cache policy" and notes "some libraries opt out via their own metadata." The new downloads/caching/index.md doesn't define this policy either — what knobs it exposes, where it's configured, what the per-library opt-out metadata looks like.

Gabbo development slice premise mismatched with the new cache spec GitHub issue

gabbo.md and decisions.md are built around the superseded caspj-cache design — user-source files cached at caspj/ next-to-source, fallback to ~/.cache/caspian/<hash>.caspj, validity = engineversion + transpilerversion + source mtime + source SHA-256. The new caching spec covers library cache only — no user-source caching, no next-to-source caspj/ directory, no mtime/SHA-256 validity stamping. Gabbo's premise needs either re-grounding against a separate user-source caching spec (which doesn't exist) or rescoping to align with library caching.

decisions.md records superseded cache design as settled GitHub issue

Specifically, the two rows in decisions.md at lines 78–79 still describe the next-to-source caspj/ scheme and the engineversion + transpilerversion + source mtime + source SHA-256 validity stamps as settled decisions. After the consolidation, those decisions are no longer reflected in any current spec. They need to be either rewritten against the new spec or recorded as superseded.

Open items the new spec itself flags GitHub issue

The official spec carries its own list of in-scope open items at downloads/caching/index.md § Open items: engine-version invalidation for locally generated source.caspj, concurrent write semantics, eviction policy, multi-file libraries. Those are tracked there; not duplicated here.

© 2026 Puck.uno