Roles GitHub issue
- Motivation
- Core Concept
- The %role System Surface
- Engine Startup: Initial Roles
- Role Transitions
- What Is NOT Checked at a Boundary
- %chain.isolate do ... end
- Exceptions and Alarms
- How Objects Get Their Owning Role
- Faucets
- Cross-Role Trust
- Open Questions
- Implementation growth path
- Related Documents
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)
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:
- One binary tier meant everything either got the keys to the kingdom or none of them. Real systems have many sources with different risk profiles.
- "Trusted" as a global property didn't capture relationships between different sources.
- The escalation mechanism (
%chain.trust) had to be invoked at every site where untrusted data flowed through a trusted sink.
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:
- Code runs under exactly one role at a time. Not zero, not two — one. Switching to another role is a discrete operation (described in "Role Transitions" below).
- All other roles are untrusted by the current role by default. From inside role A, every other role B, C, D, ... is suspicious until A explicitly trusts them.
- A "security boundary" is any call into code the current role doesn't own. That call is where the framework's security model engages.
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:
- Alarm-safe. An alarm that unwinds the stack also unwinds delegations. There is no "make sure to lift the delegation" handler that could be forgotten, skipped, or short-circuited.
- Nesting works naturally. Nested
delegate_toblocks become stacked frames. Permission resolution walks the stack in order; outer delegations are established first, inner ones layer on top, and unwinding undoes them in reverse. - Truth is the stack. There's no separate "currently active delegations" registry that could drift from reality. The stack IS the registry. Drinian snapshots show delegations as fields on frames; what's in the snapshot is what's active.
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:
user— the role the program's own code runs as. Bootstrap state: every Caspian program begins life here unless something explicitly transitions it elsewhere.stdlib— owns the built-in classes the engine ships (string, hash, array, number, etc.) and their methods. One role for the whole built-in type system, regardless of which specific class a value belongs to. When user code calls a method on a built-in value ('hello'.to_string,[1,2,3].length,{a: 1}.keys), the dispatcher transitions intostdlibfor the duration of the method call. Same pattern asutilsowning the%utilsnamespace.clock— owns the time-related objects the engine provides (e.g., what%nowreturns). User code using a clock value crosses into the clock role for the duration of any method call on it.randomizer— owns the random-source objects the engine provides. Same shape asclock.utils— owns everything that comes out of%utils, the engine-granted convenience-utility capability (%utils.now,%utils.rand.uuid, etc.). One role for the whole%utilsnamespace, regardless of which specific method was called.
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:
- Decide what to expose by deciding what to pass.
- To give a callee a restricted view, wrap the object in a jail that exposes only the methods you want them to have.
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:
- A fresh ephemeral role is created for the block's duration. Inside,
%rolereturns that fresh role. The role goes away when the block returns. %chainis wiped inside the block. No chain entries from the outer role are visible. The block writes to its own (initially empty) chain.- Outer scope is still captured. The block is a closure, so outer-scope variables are still in scope.
%chain.isolateisolates the chain, not the closure's captured locals. - System methods still work.
%puck,%now, etc. remain available. The isolation is chain-and-role, not full capability. - Original chain restored on return. When the block ends, the outer role takes over again with its original
%chainintact.
Use cases:
- Defensive coding around risky operations ("about to process untrusted input — drop my ambient context first").
- Bounded sandbox for a small piece of code without defining a separate function in another module.
- Reduced blast radius for unintended chain reads inside the block.
%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:
- Use regular exceptions for anything user code might want to recover from. Standard
try/catchsemantics with normal unwinding. - Use alarms for situations where the program is in serious trouble and recovery isn't appropriate. Alarms are the engine's hard stop.
Things that raise alarms:
- A sink refused an operation due to role mismatch
- An engine-enforced limit was breached (e.g.,
%utils.timeout) - Anything where the engine specifically wants to ensure no Caspian code — including cleanup code — can interfere with the failure
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:
- External objects (pulled through faucets): owned by the faucet's role. The system assigns the role when introducing the object into the runtime.
- Internally-created objects (functions, classes, instances, hashes, anything made by running code): owned by the role of the code that conceptually creates the object — the role evaluating the expression that produces the value. This is the role from the program's perspective, not the role of any internal frame the engine routes through to do the work. A string produced by user code's
'a' + 'b'is user-owned, even though the string-concatenation method lives on stdlib's string class and runs in a stdlib frame internally; the conceptual creator is user code. A function defined inside role A's code is owned by A.$class.new(...)called from A produces an A-owned instance. - Engine-supplied built-ins (stdlib,
%puck-resolved capabilities, etc.): assigned roles by the engine at startup, before any user code runs.
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:
- A directory jail is barely more than a directory object that won't tell you where it lives. Same methods, same navigation, same permissions — just a hidden real path.
- Directory jails are the only filesystem faucets. No filesystem access in Caspian without a directory jail.
- The engine creates and stamps the main directory jails with their own role — not
user. Each engine-introduced directory jail has its own owner role, distinct fromuser. User can choose to trust the directory jail's role but doesn't own the directory jail itself. - Files pulled through a directory jail are owned by the directory jail's owner role. Includes directory objects, file contents, anything coming out of the directory jail.
- Subdirectory jails (derived via
.jail()) are themselves owned by the deriver — the deriver created the wrapper object, so the deriver owns it. But the objects coming through the subdirectory jail are still owned by the source role, not the deriver. - Subdirectory jail authority can never exceed the parent's — operations route through the parent, which is engine-bounded.
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.
- STDIN faucet. Engine-introduced STDIN object with its own role. Data read from STDIN is owned by that role. STDIN is not ambient — it does not live on
%chainand is not a system method (no%stdin). The engine hands the script a STDIN object at bootstrap; functions that need it must receive it as an explicit parameter. A function not given the object has no way to read STDIN — there's no side channel to reach through. This is capability-style security: passing the object grants access, not passing it denies access. - Environment variables. Env-vars faucet has its own role. Each value read from the environment is owned by that role.
- Command-line arguments. CLI-args faucet has its own role. Each value read from
argvis owned by that role. - Network faucets. Engine-granted, distinct role, responses pulled through are owned by the faucet's role. HTTP is the worked example so far; other protocols follow the same shape.
- Puck. See puck.md for the full puck model; internally a puck holds fetchers, which hold faucets, with per-fetcher roles.
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:
- Directed. A trusting B does not imply B trusts A.
- Per-pair. A trusting B implies nothing about A trusting C, B trusting C, etc.
- Optional. No defaults. Two unrelated roles have no trust relationship until one explicitly declares one.
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
- Syntax for declaring "role A trusts role B."
- Where the declaration lives — in the role's definition, in
%chain, in some registry, in code at runtime. - What "trust" grants — call permission, data-passing permission, resource access, all of the above.
- Transitivity — almost certainly not transitive (A→B→C doesn't imply A→C), but should be explicit.
- Revocation and scoping — can trust be temporary (block-scoped)?
Owning-role propagation GitHub issue
- When a value owned by role D is used to produce a derived value (a substring, a hash containing it, a function-of-it), does the derived value also get tagged as owned by D?
- This overlaps with ideas/string-provenance.md. Worth aligning rather than designing in parallel.
Granularity of source-derived roles GitHub issue
- One role per database? Per connection? Per query? Per record?
- Same question for network sources, file sources, etc.
- Trade-off: fewer roles = simpler reasoning, less precision; more roles = better isolation, larger runtime namespace.
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:
- All filesystem faucets → one
fsrole. - All network faucets → one
netrole. - STDIN + env-vars + CLI-args → one
system-inputrole. - Engine-supplied capabilities → one
puck(orengine) role.
Role lifecycle GitHub issue
- When a source becomes unreachable (db disconnected, endpoint deleted), what happens to its role?
- Garbage collection — when can the runtime drop a role?
- Persistence — does a role survive process restart? Probably not, but the values owned by such a role might be stored across restarts.
Interaction with existing mechanisms GitHub issue
- Jail permissions (filesystem read/write/execute) — roles cover the "who can do this" question; jails cover the "what bounded scope" question. Composition needs spec'ing.
- Engine firewall rules — adapted to operate on roles instead of trust tags.
- ideas/trusted-database-filtering.md — the laundering-vector concern. Probably rephrased in role terms: writes from role A to a database whose owning role is B are gated by A's trust of B.
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:
- When code in role A sends a value through a sink, the runtime presumably checks the value's owning role against the sink's role. What's that check?
- Outbound HTTP requests with bodies, database INSERT/UPDATE writes, network sends, filesystem writes — each carries a value out the door.
- An HTTP faucet is also a sink (request bodies go out). Both directions need to play under the model.
To explore in a future round.
Default trust setup at startup GitHub issue
- Does the engine establish any default trust at boot — e.g.,
usertrusts the stdlib's role, or trusts certain built-in capability sources? - Or strict cold-start: no trust until the developer wires it themselves?
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"}}}
- Role registry. Engine maintains a name → role-object map. Populated at startup with
userand the engine role owning the built-in string class (and any other built-in classes V0.01 touches). The broader minimum isuser,clock,randomizer,utils; V0.01 needs only what it actually exercises. The others arrive as their values get wired up in later slices. - Owning role on every value. Each value carries an
owning_roleslot, set at creation, immutable thereafter. - Role transition in the dispatcher. Method dispatch compares the method-object's owning role to the current role. If different: save current role + chain, set new role, wipe chain, execute, restore.
%rolesystem method. Returns the current role.%chainwipe at boundaries. Even if%chainis just an empty placeholder in V0.01 (hello-world uses no chain entries), the wipe machinery is in place so later slices add chain entries without retrofitting boundary behavior.
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.
Related Documents GitHub issue
- puck.md — the puck object model, which builds on role concepts (per-fetcher roles, version windows, etc.).
- ideas/catchable-alarms.md — preserved alternate design where alarms could be caught at role boundaries.
- ideas/string-provenance.md — deferred idea for fine-grained string provenance.
- ideas/trusted-database-filtering.md — becomes more direct under per-source owning roles.
- ideas/firewall.md — engine firewall rules will be rephrased in role terms.
- ideas/fiona.md — the DBMS design that inspired the "container vs. contents" ownership principle.