Roles GitHub issue

vibecode
{"vibecode": {
    "section": "overview",
    "role": "the official security model for Caspian: every object owned by a role, code runs as the owning role of the function-object executing it, security boundaries are cross-role calls, faucets are the only way to pull objects in, jails are the explicit narrowing mechanism",
    "key_concepts": ["one_role_per_executing_function", "objects_owned_by_role", "boundary_is_cross_role_call",
        "chain_wiped_at_boundaries", "alarms_fatal_no_unwinding", "faucets_only_inbound_path",
        "jails_explicit_narrowing", "no_method_level_gating"],
    "supersedes": ["binary_trust_model", "%chain.trust", "%chain.allow_abort_escalation",
        "%chain.allow_catch_security_exceptions"]
}}
Open issues (1)

File: documentation/requirements/caspian/roles.md — clarify fetcher-role vs faucet-role ownership

[roles.md](https://github.com/mikosullivan/puck/blob/main/documentation/requirements/caspian/roles.md) currently says external objects pulled through faucets are "owned by the **faucet's** role." The fetcher concept (the layer that *contains* faucets) isn't mentioned as a role-ow…

The role model is Caspian's security model. Every object is owned by a role: functions, data values, classes, instances, everything. Code itself isn't owned; 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.


Motivation GitHub issue

This role design supersedes an earlier binary trust/untrust model. That model was found to be insufficient fore security purposes. The previous model classified every value and every running function as either trusted or untrusted, based on its source. The model worked but was too coarse:

The role model replaces binary trust with roles — a more granular identity-and-authority system where each source of code or data is its own role, and cross-role interaction is the security-relevant event.


Core Concept GitHub issue

vibecode
{"vibecode": {
    "section": "core_concept",
    "key_properties": ["code_runs_under_exactly_one_role", "all_other_roles_untrusted_by_default",
        "boundary_equals_cross_role_call", "no_global_trusted_tier"]
}}

Key properties:

There is no global "trusted" tier. Trust is per-role-pair, decided by each role on its own terms.


The %role System Surface GitHub issue

vibecode
{"vibecode": {
    "section": "role_system_surface",
    "surface": "%role",
    "dual_form": "value_and_namespace",
    "bare_form_returns": "current_role",
    "namespace_methods": ["delegate_to"]
}}

%role returns the role the current code is running under. Same shape as %chain, %puck, %self — a system method, always available, context aware.

$current = %role    # the role currently in effect

%role also serves as a namespace for role-system operations. Same dual shape as %puck (both a callable lookup and a namespace).

%role.delegate_to GitHub issue

vibecode
{"vibecode": {
    "method": "%role.delegate_to",
    "form": "%role.delegate_to(<role>) do ... end",
    "lifetime": "block_scoped",
    "from_role": "implicit_current_role",
    "to_role": "explicit_argument",
    "grant": "full_every_permission_the_current_role_has",
    "selective_or_subset_delegation": "out_of_v1_0_scope",
    "motivating_use_case": "agent_yield_as_self_style_block"
}}

Block-scoped permission delegation. Inside the block, the named role is granted the current role's permissions. When the block exits, the grant lifts. The "from" role is implicit (the current role); the "to" role is the single positional argument.

%role.delegate_to($agent.object.role) do
    # inside this block, the agent's role can do anything
    # the current role can do
end

The grant is full — every permission the current role has is extended to the named role for the duration of the block. Selective delegation (granting only some permissions), subset delegation, and hierarchical delegation are out of V1.0 scope; they can be revisited if a forcing function appears.

The named role retains its own identity throughout. Only its permissions are temporarily extended — auditing and ownership chains continue to attribute actions to the named role, with the elevation visible in source as the enclosing delegate_to block.

Object ownership is unaffected by delegation. Objects the named role creates during the delegation are owned by that named role — the same way any object is owned by whoever created it. If such an object survives past the delegate_to block (held by a reference outside the block, stored in a long-lived structure, etc.), it remains owned by the named role and is subject to the same cross-role security rules as anything else going forward. The temporary elevation that allowed the object to be constructed does not persist with the object; accessing or invoking methods on it post- block crosses into the named role's normal permissions. An object that could only be created under elevation may end up stranded — present but unusable — outside the block. That's the intended outcome from a security perspective: delegation grants scope-bounded capability, not persistent capability that leaks through the objects produced.

Primary V1.0 use case: $agent.yield. The agent's connection runs under a fresh sandboxed role by default; %role.delegate_to lets the caller explicitly extend their own role's permissions to the agent's role for a region where the developer wants the agent operating with their authority.

Frame-scoped delegations (the canonical mechanism) GitHub issue

vibecode
{"vibecode": {
    "subsection": "frame_scoped_delegations",
    "where_the_delegation_lives": "on_the_stack_frame_the_delegate_to_block_creates",
    "lifetime_tied_to": "frame_existence_on_the_stack",
    "cleanup_mechanism": "automatic_via_stack_unwinding",
    "no_separate_lift_handler_needed": true,
    "properties_falling_out": ["alarm_safe", "nests_naturally", "drinian_reflects_truth_no_separate_state"]
}}

Delegations live on stack frames, not on roles. Entering a %role.delegate_to(X) do ... end block pushes a new frame whose delegations record indicates "this frame grants the current role's permissions to X." When a permission check fires, the engine walks up the stack looking only for delegations whose target role matches the role the checking code is running as. Match → the elevation applies. No match → the role's normal permissions apply. When the frame is popped — by normal block exit, alarm propagation, or any other unwinding — the delegation goes with it. No separate "lift the delegation" cleanup logic exists, because none is needed.

A useful consequence of "look for delegations targeting the current role": role transitions are clean. If execution crosses from agent into stdlib (e.g., calling a string method), the stack walk from stdlib finds no delegation targeting stdlib — Frame 1's grant points at agent, not at stdlib — so stdlib uses its own permissions. If execution then crosses back from stdlib into an agent-owned method, the stack walk finds Frame 1's grant to agent again, and the elevation applies. The mechanism is symmetric per role, not per call-chain branch.

This design ties the delegation's lifetime exactly to the block's scope by reusing the call stack as the bookkeeping mechanism. Three properties fall out:

A role-level convenience view ("what delegations are currently extending this role's permissions?") can be derived by walking the stack and unioning the active grants — but it's derived, not stored. The frame stack is the source of truth.

See drinian/ § role delegations for the Drinian representation.


Engine Startup: Initial Roles GitHub issue

vibecode
{"vibecode": {
    "section": "engine_startup_roles",
    "minimum": ["user", "stdlib", "clock", "randomizer", "utils"],
    "engine_dependent": ["directory jails", "network_faucets", "stdin", "env_vars", "cli_args", "puck"]
}}

When the engine launches a Caspian instance, it wires up all the roles necessary for the objects it's about to pass into the runtime. At minimum:

Engines will typically wire up more depending on what they're exposing: roles for STDIN, env vars, CLI args, the puck, each directory jail, each network faucet, etc. The minimum above is what every engine has; the rest varies by engine configuration.

Role nicknames like user, clock, randomizer are informal. Formal reference syntax is TBD.


Role Transitions GitHub issue

vibecode
{"vibecode": {
    "section": "role_transitions",
    "rule": "functions_and_closures_always_run_as_their_defining_role",
    "transition_is_implicit_on_call": true,
    "elevation_via_trickery_is_impossible": true,
    "chain_wiped_at_boundary": true,
    "closure_invocation_does_not_escalate_invoker": true
}}

Functions always run as their owner. When code running under role A calls a function-object owned by role B, that function runs as B for the duration of the call. When the function returns, control returns to A.

The transition is implicit in the call. There is no separate syntax or system method for changing roles — calling a function owned by a different role is the transition. %role inside the called function returns B; back in the caller, it returns A.

This means a less-trusted role cannot "elevate" itself by trickery. The only way for code to be running as role B is to be executing inside a B-owned function-object. Calling into B's code doesn't make the caller B; only the called function runs as B.

Closures follow the same rule, with "owner" meaning "defining role." A closure runs as the role of the code that defined it, regardless of who invokes it. A closure defined in user code always runs as user, even when an agent (or any other role) invokes it. The closure's captured variables are accessible as same-role reads from inside the body, since the body is running as the defining role.

This is the security-critical property: passing a closure to a different-role caller does NOT grant that caller the closure's authority. If user code passes $my_closure to $agent.yield(...) and the agent invokes it, the closure's body still runs as user. The agent cannot use the closure to do things user wouldn't authorize. The authority follows the closure's definition site, not its invocation site.

Closures don't have class ownership (unlike functions, which are bound to a class), so "owner" for a closure is determined by lexical scope — the role of the code in which the closure literal was evaluated. Same as the object-ownership rule: the conceptual creator of the value is the relevant role.

%chain is wiped at role boundaries. When code in role A calls into role B, B starts with a clean %chain — A's chain entries are not visible to B. Symmetric on the way back: B's chain writes don't reach A. Closes the ambient-state side channel; roles communicate via params if they need to pass anything across the boundary.

A's chain is preserved across the call from A's perspective — when B returns and execution resumes in A, A's chain is exactly what it was before the call. The wipe is bounded to the cross-role function's lifetime; A doesn't lose state because it called B.


What Is NOT Checked at a Boundary GitHub issue

vibecode
{"vibecode": {
    "section": "no_method_level_gating",
    "rule": "anything_with_object_access_can_call_any_method",
    "narrowing_mechanism": "wrap_in_jail_before_passing"
}}

Anything with access to an object can call any of its methods. Full stop. The role of the caller does not gate which methods can be invoked. If A passes an object with a delete_database method into B's function, B can call delete_database. The runtime won't stop it.

The boundary crossing wipes %chain, transitions execution, and passes params — but it does not filter or gate operations on the objects that flow across. Method calls on passed objects are unrestricted.

The developer's job:

This is why the framework provides a lightweight way to create a jail on any object — $foo.object.jail(:method, :method). Given the "caller must decide what to expose" rule, narrowing has to be cheap or developers won't do it. Example:

$foo = &some_fancy_object()
$jail = $foo.object.jail(:safe_method, :harmless_method)
&untrusted_function $jail   # callee can only call safe_method or harmless_method

$foo retains its full surface for the caller; $jail exposes only the listed methods. The general "Jail (Object Firewall)" mechanism in caspian/caspian-runtime.md covers this; the directory jail rules below are one specialization. The same pattern applies to any object the developer wants to restrict before handing it across a role boundary.

This is consistent with the framework's "no nanny code" principle. The runtime won't second-guess what you pass to another role; that choice is yours, and the consequences are yours. The framework's job is to make the narrowing easy.


%chain.isolate do ... end GitHub issue

vibecode
{"vibecode": {
    "section": "chain_isolate",
    "method": "%chain.isolate",
    "form": "do_block",
    "creates": "fresh_ephemeral_role_for_block_duration",
    "wipes": "%chain"
}}

A voluntary, inline version of the cross-role chain wipe. Lets code drop its own ambient context for a bounded block:

%chain.isolate do
    # %chain is wiped here
    # a fresh ephemeral role is in effect
    # %role returns the new role, not the outer role
end
# back to the outer role with its original %chain restored

Mechanics:

Use cases:

%chain.isolate is the inline-block version of what cross-role function calls already provide naturally — calling B's function wipes the chain. Use isolate when you want a clean slate without defining or calling another function.


Exceptions and Alarms GitHub issue

vibecode
{"vibecode": {
    "section": "exceptions_and_alarms",
    "regular_exceptions": "travel_up_stack_catchable_normal_unwinding",
    "alarms": "fatal_no_unwinding_go_straight_to_engine"
}}

Two error categories with distinct behaviors:

Regular exceptions travel up the call stack via normal unwinding. Any catch handler along the way can intercept. If uncaught, the exception reaches the engine and becomes an uncaught-exception error (exact engine-side handling TBD). Any code — including untrusted code — can raise exceptions that travel all the way up; the chain unwinds gracefully via try/finally-style cleanup along the way.

Alarms are always fatal. They go directly to the engine — no unwinding, no finally blocks, no cleanup, no catch handlers from Caspian code. The runtime bails to the engine immediately, and the engine handles termination (logging, process exit, whatever the engine decides — exact behavior TBD).

The model:

Things that raise alarms:

The "no unwinding" rule is what makes alarms different from exceptions in kind. Untrusted code cannot gain control during the failure by hooking into finally blocks or catch handlers. The engine takes over directly.

A more elaborate model where alarms can be caught at role boundaries was considered and dropped — preserved in ideas/catchable-alarms.md for possible revisitation.


How Objects Get Their Owning Role GitHub issue

vibecode
{"vibecode": {
    "section": "object_ownership_assignment",
    "external_objects": "owned_by_faucets_role",
    "internal_objects": "owned_by_role_of_code_that_conceptually_creates_the_object_not_necessarily_the_runtime_frame_doing_the_allocation",
    "engine_builtins": "owned_by_engine_assigned_role",
    "key_distinction": "frame_role_is_who_is_executing_right_now; object_role_is_who_conceptually_owns_the_value; they_are_different_questions"
}}

Three rules:

Stdlib's internal scratch state is stdlib-owned. When stdlib allocates a buffer, intermediate hash, or any temporary purely for its own implementation use — never returning it to user code — that state belongs to stdlib. The "conceptual creator" rule above only matters for values that flow across the boundary; internal stdlib bookkeeping is correctly stdlib's.

Frame role and object role are different questions. A frame's role in Drinian's call_stack tells you whose code is executing in that frame right now (stdlib's + method runs in a frame with role: "stdlib"). An object's role in Drinian's objects table tells you who owns the value (the string + produced is role: "user" because user-code's expression conceptually created it). These coincide in most cases but they're answering different questions.

The engine itself has a role, but it stays under the hood — developers don't reference it directly. Bootstrap layer, mostly invisible.

Once assigned, an object's role is immutable. Objects move between roles (passing values into a different-role function, returning values to a different-role caller) but the value's owning role follows the value; it doesn't change because the value's location changed.


Faucets GitHub issue

vibecode
{"vibecode": {
    "section": "faucets",
    "definition": "any_resource_through_which_objects_enter_the_runtime",
    "rule": "faucets_are_the_only_inbound_path",
    "examples": ["filesystem_directory_jail", "database_connection", "http_client", "stdin", "env_vars",
        "cli_args"]
}}

The Puck vocabulary for source-side resources is faucet — any resource through which objects are pulled into the runtime. Examples: a filesystem directory jail, a database connection, an HTTP client, a socket, an IPC channel.

(Complement: a sink is an operation that consumes a value in a security-sensitive way — filesystem write, eval, query send, network output.)

Faucets are the only way to pull objects into the runtime. There are no backdoors, no implicit injection paths, no FFI escapes — every external value entering Caspian comes through some faucet, and the faucet's role is what owns the value.

When Caspian pulls a value through a faucet, the runtime tags that value with a role that owns it. The owning role is typically created on the fly for the specific faucet rather than being pre-registered.

The user role pulling data from database D ends up holding values that are owned by role-D — not by user. User-role code can hold the values but doesn't own them.

Filesystem: directory jails GitHub issue

vibecode
{"vibecode": {
    "section": "directory_jails",
    "definition": "directory_object_that_hides_its_real_path",
    "rule": "directory_jails_are_only_filesystem_faucets",
    "subdirectory_jail_ownership": "deriver_owns_wrapper_objects_through_still_have_source_role"
}}

The filesystem-flavored jail is called a directory jail — to distinguish it from the broader "jail" concept (a capability-restricting wrapper around any object). Directory jails are jails specifically around directory objects.

The rules:

The key principle: ownership is per-object, and one object owning a wrapper doesn't transfer ownership of what flows through that wrapper. A user-owned subdirectory jail can hand back files that are still source-owned. No laundering by derivation; provenance is preserved naturally.

This principle generalizes: a container's role applies to the container itself, not to what's inside it. A hash created by user code is user-owned, but a value owned by main-fs placed into that hash retains its main-fs role. Reading the value out gives back a main-fs-owned value, not a user-owned one. The hash is one identity; its members are other identities, each with their own role.

(This mirrors the Fiona DBMS design Miko worked out years ago — see ideas/fiona.md. In Fiona, the "object itself" lives in hsa while "what it's connected to" lives in relationships. The role model uses the same structural split.)

Other faucets GitHub issue

The model extends naturally to other faucet kinds. The same baseline rule applies: engine-supplied, has its own distinct role, data pulled through it inherits that role.


Cross-Role Trust GitHub issue

vibecode
{"vibecode": {
    "section": "cross_role_trust",
    "directed": true,
    "per_pair": true,
    "optional": true,
    "details_tbd": true
}}

A role can choose to trust other roles. Trust is:

The framework supplies the mechanism for declaring and querying trust; the content of any role's trust web is up to that role.

Details TBD: syntax for declaring trust, what trust actually grants, revocation, runtime adjustability.


Open Questions GitHub issue

The model is solid enough to adopt; these are refinements within an established framework, not blockers.

Cross-role trust mechanics GitHub issue

Owning-role propagation GitHub issue

Granularity of source-derived roles GitHub issue

Role consolidation pass (revisit later). As the design has proceeded, roles have proliferated — user, per-directory jail roles, per-network-faucet roles, STDIN, env-vars, CLI-args, and counting. The current direction is to keep proliferating; once the model has been used in practice, take a deliberate pass to see which roles can be consolidated without losing meaningful security properties. Candidate consolidations:

Role lifecycle GitHub issue

Interaction with existing mechanisms GitHub issue

Sink-side security GitHub issue

The model so far focuses on what comes in through faucets (role-tagging of pulled values, source-side semantics). The sink side — sending information out — has its own implications:

To explore in a future round.

Default trust setup at startup GitHub issue


Implementation growth path GitHub issue

vibecode
{"vibecode": {
    "section": "implementation_growth_path",
    "role": "per-slice plan for layering the role system in, starting at V0.01 and growing through V1",
    "principle": "roles_are_core_not_bolt_on; bake_primitives_in_from_first_slice; grow_outward",
    "v001_primitives": ["role_registry", "owning_role_on_every_value",
        "role_transition_on_method_call", "role_system_method",
        "chain_wipe_on_cross_role_call"],
    "v001_deferred": ["faucets", "jails", "cross_role_trust_declarations",
        "alarms_with_sink_side_checks", "source_side_propagation",
        "chain_isolate_developer_facing"]
}}

Roles are not a bolt-on. Every value in the runtime carries an owning-role tag at creation; every method call checks the receiver's role and transitions. Both are pervasive concerns — adding them later would mean touching every value-creation path and every method-call path. The primitives bake in from the first slice and grow outward.

V0.01 role footprint GitHub issue

The minimum to support hello-world's single cross-role call (user → string-class role → user):

vibecode
{"vibecode": {"v001_role_footprint":
    {"registry_entries_min": ["user", "stdlib"],
    "value_layer": "every_value_carries_owning_role_slot_immutable_after_creation",
    "dispatcher_layer": "on_method_call_compare_method_owning_role_to_current; if_differ_save_state_set_new_role_wipe_chain_run_restore",
    "sys_methods_implemented": ["role"],
    "chain_state": "empty_placeholder_but_wipeable_on_boundary"}}}

Growth by slice GitHub issue

vibecode
{"vibecode": {"growth_path": [
    {"slice": "v0.01", "adds": "core_primitives_registry_owning_role_transition_role_chain_wipe"},
    {"slice": "v0.02", "adds": "transpiler_role; caspj_emitted_tagged_with_caller_role"},
    {"slice": "v0.03", "adds": "stdout_role; owns_stdout_sink_and_puts_bwc; first_cross_role_boundary_for_engine_supplied_io"},
    {"slice": "v0.04", "adds": "no_new_role_primitives; built_in_hash_class_registered_under_existing_stdlib_role; same_pattern_as_v001_string_class"},
    {"slice": "v0.05", "adds": "no_new_role_primitives; to_json_methods_register_on_existing_stdlib_class_methods"},
    {"slice": "v0_0x_cli", "adds": "stderr_role; per_dirjail_roles_when_allow_fs_flag_used; per_faucet_roles_when_allow_net_flag_used; env_vars_and_cli_args_roles"},
    {"slice": "first_http", "adds": "network_faucet_role; request_body_values_inherit_faucet_role"},
    {"slice": "first_db", "adds": "per_mikobase_instance_role; rows_inherit_db_role"},
    {"slice": "first_uma", "adds": "no_new_role_primitives; uma_objects_user_owned"},
    {"slice": "first_signed_request", "adds": "identity_faucet_role; trust_mechanism_scaffolding"},
    {"slice": "first_deployment", "adds": "no_new_role_primitives; deployment_context_may_add_engine_roles"},
    {"slice": "first_hosted_service", "adds": "per_tenant_roles_tbd; cross_role_trust_mechanics_tbd"}],
    "feature_arrival_triggers": {
        "jails": "when_first_slice_has_values_worth_narrowing",
        "cross_role_trust_declarations": "when_first_slice_needs_to_grant_trust",
        "alarms_vs_exceptions": "when_first_slice_has_sinks_that_must_hard_stop",
        "source_side_propagation": "when_string_provenance_question_settles"}}}
Slice Role additions
V0.01 Core: registry, owning_role on values, transition-on-call, %role, %chain wipe
V0.02 Transpiler role; emitted CaspianJ tagged with caller role
V0.03 stdout role; owns the stdout sink and the puts bwc; first cross-role boundary for engine-supplied I/O
V0.04 No new role primitives; built-in hash class is registered under the existing stdlib role (same pattern as V0.01's string class)
V0.05 No new role primitives; to_json methods register on existing stdlib-owned classes
V0.0X CLI stderr role; per-directory jail roles when --allow-fs is used; per-faucet roles when --allow-net is used; env_vars and cli_args roles
First HTTP Network faucet role; request-body values inherit it
First DB Per-Mikobase-instance role; rows inherit it
First Uma No new role primitives; Uma objects are user-owned by default
First signed request Identity faucet role; trust-mechanism scaffolding
First deployment No new role primitives; deployment context may add engine roles
First hosted service Per-tenant roles (TBD); cross-role-trust mechanics (TBD)

Jails arrive when a slice has values worth narrowing. Cross-role trust declarations arrive when a slice needs to grant trust. Alarms (vs. regular exceptions) arrive when a slice has sinks that must hard-stop. Source-side propagation is deferred until the string-provenance question settles.

Hello-world's role behavior end-to-end GitHub issue

Program starts in role user. The fixture materializes the literal "hello" — a string value owned by user (created by user-role code). Method dispatch on to_string: the method lives on the built-in string class, owned by an engine role. Receiver-method-owning-role differs from current → transition. Wipe (empty) chain, set current role to the string-class role, execute to_string (which returns the receiver, since a string's to_string is identity), restore current role to user. The returned value's owning role is the receiver's (user-owned); the test harness captures it. This proves the role-transition machinery at the smallest possible scale; every later slice exercises more of the model.



© 2026 Puck.uno