The Dyn layer: code the agent writes, compiles and runs on the device itself (tools and screens), next to the fixed Core (the loop, sessions, sign-in, the core tools, the approval gate and the repair path, which ship in the app and which the agent can't change). The Core changes Dyn only through this module, one generation at a time:
stage_put/3 ... ──► propose/2 ──► activate/3 ──► probation ──► proven
(staging copy) check, (approval) │
compile G<n>, └─► reverted (crashes,
selftest failed launch, by hand)- Stage: edits go to a staging copy of the current generation's
sources (
stage_reset/1,stage_put/3,stage_delete/2). - Propose (
propose/2): static check (Rho.Dyn.Check), compile intoDyn.G<n>.*next to the running generation (Rho.Dyn.Compiler), selftest each module in a contained process (Rho.Dyn.Selftest). Nothing live changes: the result is a proposal (generation, diff, rationale, test results) or a rejection saying which step refused and why. - Activate (
activate/3) with a token from the approval seam (request_approval/2,Rho.Dyn.Approval): the human approves with the screen lock. - Probation, revert, safe mode:
Rho.Dyn.Keeper. A new generation is unproven until it ran 60 s without a Dyn crash and survived an app restart; 3 crashes in 60 s, or a launch that dies before it is stable, revert it; two failed launches in a row start the next one in safe mode, with no Dyn code loaded, so the Core can always repair it.
Generations, not files: every accepted change is a generation, its
full source set, binaries, selftests and diff (Rho.Dyn.Store);
activation and revert flip a pointer. Versioned modules: the agent
writes Dyn.Foo (the namespace is the host's, Rho.Dyn.Config) and
generation n compiles it as Dyn.G<n>.Foo, so a candidate loads next
to the running code, never over it (no purge kills a running process);
an old generation is unloaded only once no process runs its code.
The registry maps logical names ({:tool, "weather"}, {:screen, "Notes"}) to the current generation's modules (Rho.Dyn.Registry);
the loop sees Dyn tools through Rho.ToolRegistry (Rho.Dyn.Source),
from the next model call after a switch.
Keeping Dyn code out of Core processes (best effort) where Dyn
hands something over (Rho.Untrusted): crashes are formatted where
they happened (the tool's task, the selftest's or the compile's
process, bounded), and the loop, the Keeper and a proposal's caller
only describe a reason they get, without an exception's message/1 or
a Dyn struct's protocols. A Dyn tool's answer is plain data before it
leaves its task, and its declaration is read in a bounded process
outside the loop and kept (see Rho.Tool). Log events that hold a Dyn
term are described before the logger formats them (Rho.Dyn.LogGuard).
A Dyn screen's own process runs its code, so whatever the host's screen
layer does with its crash is the host's (mob's) business.
Threat model: Dyn guards against the model's mistakes (a built tool
that crashes, hangs, or calls something it shouldn't by accident), not
against deliberately hostile code. The static check is a best-effort
deny-list, not a sandbox (Rho.Dyn.Check lists the known ways around
it); Rho.Untrusted and Rho.Dyn.LogGuard lower the chance that a Dyn
crash takes a trusted process down.
Dyn compiles Elixir on the device: the app must ship the Elixir compiler
(:elixir's compiler modules and :compiler), so no slim build that
strips them.
The host's seams (Rho.Dyn.Config): the namespace, the Core's module
prefixes and what Dyn code may call (Rho.Dyn.Check), the screen
integration (Rho.Dyn.Screens), the approval (Rho.Dyn.Approval), the
launch's boot (boot/1) and :on_stable hook, and the seed it ships
(Rho.Dyn.Seed).
Every function takes the Keeper (its registered name, which is also the registry table's) last, defaulting to the app's.
Summary
Functions
Activates candidate generation n (it goes on probation). Needs an approval token.
The system prompt section that explains the Dyn layer and its tools to
the agent: rho's rules, the screen rules of the host's
Rho.Dyn.Screens, then the host's own (Rho.Dyn.Config's :guide).
Returns once this launch's background rebuild is over (at once if none runs).
The Dyn step of the host's boot: loads the current generation (or
decides on boot probation / safe mode, see Rho.Dyn.Keeper) and has the
first rendered frame start the clock to "stable" (the host's
Rho.Dyn.Screens.after_first_render/1). opts go to
Rho.Dyn.Keeper.boot/2 (core:, core_rolled_back:).
Generation n's unified diff against its parent.
Drops the pending candidate n (no approval needed: nothing that runs changes).
Is module Dyn code: a generation's versioned module (Dyn.G<n>.*) or a
protocol implementation for one (Rho.Dyn.Compiler.generation_of/1)?
Generation n's record (Rho.Dyn.Generation); 0 is the empty generation.
Every generation, newest first, generation 0 last.
The last limit reports (crashes, reverts, safe mode, load failures),
oldest first. Values are as stored (strings for atoms).
The current generation's module for a logical name ({:tool, "weather"},
{:screen, "Notes"}, {:module, "Helpers"}, see Rho.Dyn.Registry);
:error when it has none.
Turns the staged sources into a candidate generation without activating
it. {:error, rejection} names the step that refused (:check,
:compile, :selftest, :names) and why; a rejected generation's
modules are unloaded and the current one is untouched.
{:error, :proposal_limit}: this launch made too many proposals (see
Rho.Dyn.Keeper); the app has to restart first.
{:error, :rebuilding}: the current generation is being rebuilt for a
new Core in the background; propose once it's loaded.
The generation this launch is rebuilding for a new Core in the background (nothing of it is registered yet), or nil.
A token for subject from the configured approval (in the app: only
after Rho.Dyn.Approval.Biometric.confirm/1).
Makes generation n (one that was active once, or 0) current. Needs an approval token.
Did this launch start in safe mode (no Dyn module loaded)?
The current generation's screens, [{name, module}].
Installs the seed (Rho.Dyn.Seed, the default front) or a
newer one: merge gets the current generation's sources and returns the
full source set to install (Rho.Dyn.Seed.merge/3 keeps the
user's edits). It is checked, compiled and selftested like a proposal,
then made current and proven by Rho.Dyn.Keeper.install_seed/3
with no approval, because the seed ships with the Core and the merge
only replaces seed files nobody changed. Waits (an error, retried next
launch) in safe mode or while a proposal is pending; {:error, :no_changes} when the merge changes nothing.
Generation n's sources (what runs, for the current one).
Creates path in staging without replacing an existing source.
Removes path from staging (:ok if it wasn't there).
Writes source to path (relative, .ex) in staging.
Reads path from staging.
Resets staging to the current generation's sources.
Reads path from staging, passes its source to fun and writes back what
fun returns as {:ok, new_source} (anything else is returned and nothing
is written), as one step: edits of the same file never interleave, so two
dyn_edit calls the model makes in parallel both land. Returns
{:ok, old_source} after writing.
The staged sources, %{path => source} (empty when the Keeper isn't running).
The current generation, its status, its parent, the mode and the pending candidate.
{:rho_dyn, event} for every crash, revert, activation, proof, candidate, safe mode.
The current generation's tools, [{name, module}].
Stops watching pid (watch/2): its exit is no longer a crash, for a
host that stops a process it watched in an orderly way.
Has the app's Keeper watch pid if module is Dyn code, so its crash
counts against the generation (Rho.ToolRunner calls this for every
tool call, through Rho.Dyn.Source; a host calls it for a process of
its own that runs Dyn code, a screen say). Its abnormal exit is a
crash; exiting with {:rho_crash, text} (Rho.Untrusted.crash/3)
reports text as it is. A no-op for Core modules.
Types
@type proposal() :: %{ n: pos_integer(), parent: non_neg_integer(), rationale: String.t(), diff: String.t(), modules: [Rho.Dyn.Generation.module_entry()], selftests: [Rho.Dyn.Generation.selftest()], compile_ms: non_neg_integer(), reused: non_neg_integer(), warnings: [String.t()] }
@type rejection() :: %{ n: pos_integer() | nil, stage: :check | :compile | :selftest | :names | :superseded | :install, reason: String.t(), violations: [Rho.Dyn.Check.violation()], selftests: [Rho.Dyn.Generation.selftest()] }
Functions
@spec activate(pos_integer(), Rho.Dyn.Approval.token(), atom()) :: {:ok, Rho.Dyn.Generation.t()} | {:error, term()}
Activates candidate generation n (it goes on probation). Needs an approval token.
@spec agent_guide() :: String.t()
The system prompt section that explains the Dyn layer and its tools to
the agent: rho's rules, the screen rules of the host's
Rho.Dyn.Screens, then the host's own (Rho.Dyn.Config's :guide).
@spec await_rebuild(atom()) :: :ok
Returns once this launch's background rebuild is over (at once if none runs).
@spec boot(keyword()) :: Rho.Dyn.Keeper.boot_report()
The Dyn step of the host's boot: loads the current generation (or
decides on boot probation / safe mode, see Rho.Dyn.Keeper) and has the
first rendered frame start the clock to "stable" (the host's
Rho.Dyn.Screens.after_first_render/1). opts go to
Rho.Dyn.Keeper.boot/2 (core:, core_rolled_back:).
@spec diff(non_neg_integer(), atom()) :: String.t()
Generation n's unified diff against its parent.
@spec discard(pos_integer(), atom()) :: :ok | {:error, :not_pending | :not_running}
Drops the pending candidate n (no approval needed: nothing that runs changes).
Is module Dyn code: a generation's versioned module (Dyn.G<n>.*) or a
protocol implementation for one (Rho.Dyn.Compiler.generation_of/1)?
@spec generation(non_neg_integer(), atom()) :: {:ok, Rho.Dyn.Generation.t()} | {:error, term()}
Generation n's record (Rho.Dyn.Generation); 0 is the empty generation.
@spec generations(atom()) :: [Rho.Dyn.Generation.t()]
Every generation, newest first, generation 0 last.
@spec log(pos_integer(), atom()) :: [map()]
The last limit reports (crashes, reverts, safe mode, load failures),
oldest first. Values are as stored (strings for atoms).
@spec lookup(Rho.Dyn.Registry.key(), atom()) :: {:ok, module()} | :error
The current generation's module for a logical name ({:tool, "weather"},
{:screen, "Notes"}, {:module, "Helpers"}, see Rho.Dyn.Registry);
:error when it has none.
@spec propose(String.t(), atom()) :: {:ok, proposal()} | {:error, rejection() | :no_changes | :rationale_required | :not_running | :proposal_limit | :rebuilding}
Turns the staged sources into a candidate generation without activating
it. {:error, rejection} names the step that refused (:check,
:compile, :selftest, :names) and why; a rejected generation's
modules are unloaded and the current one is untouched.
{:error, :proposal_limit}: this launch made too many proposals (see
Rho.Dyn.Keeper); the app has to restart first.
{:error, :rebuilding}: the current generation is being rebuilt for a
new Core in the background; propose once it's loaded.
@spec rebuilding(atom()) :: pos_integer() | nil
The generation this launch is rebuilding for a new Core in the background (nothing of it is registered yet), or nil.
@spec request_approval(Rho.Dyn.Approval.subject(), atom()) :: {:ok, Rho.Dyn.Approval.token()} | {:error, term()}
A token for subject from the configured approval (in the app: only
after Rho.Dyn.Approval.Biometric.confirm/1).
@spec revert_to(non_neg_integer(), Rho.Dyn.Approval.token(), atom()) :: {:ok, Rho.Dyn.Generation.t()} | {:error, term()}
Makes generation n (one that was active once, or 0) current. Needs an approval token.
Did this launch start in safe mode (no Dyn module loaded)?
The current generation's screens, [{name, module}].
@spec seed( (%{required(String.t()) => String.t()} -> %{required(String.t()) => String.t()}), String.t(), atom() ) :: {:ok, pos_integer()} | {:error, :no_changes | :safe_mode | :proposal_pending | :not_running | rejection()}
Installs the seed (Rho.Dyn.Seed, the default front) or a
newer one: merge gets the current generation's sources and returns the
full source set to install (Rho.Dyn.Seed.merge/3 keeps the
user's edits). It is checked, compiled and selftested like a proposal,
then made current and proven by Rho.Dyn.Keeper.install_seed/3
with no approval, because the seed ships with the Core and the merge
only replaces seed files nobody changed. Waits (an error, retried next
launch) in safe mode or while a proposal is pending; {:error, :no_changes} when the merge changes nothing.
@spec sources(non_neg_integer(), atom()) :: %{required(String.t()) => String.t()}
Generation n's sources (what runs, for the current one).
@spec stage_create(String.t(), String.t(), atom()) :: :ok | {:error, :bad_path | :exists | :not_running | File.posix()}
Creates path in staging without replacing an existing source.
Removes path from staging (:ok if it wasn't there).
Writes source to path (relative, .ex) in staging.
@spec stage_read(String.t(), atom()) :: {:ok, String.t()} | {:error, :bad_path | :not_found | :not_running}
Reads path from staging.
@spec stage_reset(atom()) :: :ok | {:error, :not_running}
Resets staging to the current generation's sources.
@spec stage_update(String.t(), (String.t() -> {:ok, String.t()} | term()), atom()) :: {:ok, String.t()} | term()
Reads path from staging, passes its source to fun and writes back what
fun returns as {:ok, new_source} (anything else is returned and nothing
is written), as one step: edits of the same file never interleave, so two
dyn_edit calls the model makes in parallel both land. Returns
{:ok, old_source} after writing.
The staged sources, %{path => source} (empty when the Keeper isn't running).
The current generation, its status, its parent, the mode and the pending candidate.
@spec subscribe(atom()) :: :ok
{:rho_dyn, event} for every crash, revert, activation, proof, candidate, safe mode.
The current generation's tools, [{name, module}].
@spec unwatch(pid()) :: :ok
Stops watching pid (watch/2): its exit is no longer a crash, for a
host that stops a process it watched in an orderly way.
Has the app's Keeper watch pid if module is Dyn code, so its crash
counts against the generation (Rho.ToolRunner calls this for every
tool call, through Rho.Dyn.Source; a host calls it for a process of
its own that runs Dyn code, a screen say). Its abnormal exit is a
crash; exiting with {:rho_crash, text} (Rho.Untrusted.crash/3)
reports text as it is. A no-op for Core modules.