The scripts this node has accepted, and every door they arrive and leave through — the context both script tools wrap and the node's own callers use.
A script is an Elixir module kept as a row and compiled into this VM. What makes it runnable is not that it is stored: it is that the node has accepted the exact code the row holds, and that this boot compiled it.
Acceptance
run/3 refuses unless accepted_hash == code_hash. That single comparison is
the whole security claim: code that changed after it was accepted is code
nobody accepted, so the node stops rather than running it. Today every door
that writes code also accepts it in the same breath — an authored script, a
pushed one and the example the build plants are each a deliberate act by
someone who read the code — and the unaccepted state exists for the door that
does not exist yet, registry sync, where code arrives from elsewhere and no
one here has looked at it.
stateDiagram-v2
[*] --> Runnable : create, push, update, plant
Runnable --> Unaccepted : sync replaces the code (later)
Unaccepted --> Runnable : accept
Runnable --> Broken : a boot this code no longer compiles against
Runnable --> Broken : a restore whose recompile fails
Broken --> Runnable : update with code that compiles
Runnable --> [*] : remove
Unaccepted --> [*] : remove
Broken --> [*] : remove
note right of Runnable
accepted_hash == code_hash, and
this boot compiled it. run/3
refuses in every other state.
end noteBroken is never reached by storing: a write compiles first and is refused if
that fails, so nothing unrunnable is ever stored on purpose. It is reached at
boot — after a node upgrade moves the contract or a battery underneath it, the
boot compile fails, the row is kept with its diagnostics, and run/3
refuses it with them; dropping the row would destroy the only copy of code the
operator now has to fix — and by one write-side path that leaves the row
unchanged: a refused re-accept, or a write whose candidate failed, puts the
stored code back by compiling it again, and a body that cannot run twice in
one VM (a named table it creates, a process it registers) fails that compile.
The row is kept with the diagnostic exactly as at boot, and a restart repairs
it.
What acceptance refuses
Five things, and where each is refused:
| refusal | where |
|---|---|
a url_action actions/0 does not declare | YmerNode.Scripts.Compiler, at every compile |
a url_action whose schema has no url property | YmerNode.Scripts.Compiler, at every compile |
a name in web, file or other | here, before the compile |
| a host another accepted script already claims | here, after the compile — inside the loader's message at every write door, in the open at the build's |
| a throttle another accepted script declares with other parameters | here, beside the host rule and wherever it runs |
The first two are properties of the code alone, so the compiler settles them
before a row exists and no write can get past them. The last three depend on
what else is in the database — the references vocabulary and the other
accepted rows — which the compiler cannot see. All five are checked at every
acceptance door: create/1, update/2, push/1 and accept/1 — and at
check/1, which promises every refusal create/1 would give. The build's
door, plant_example/0, checks the host and throttle rules and not the
name's: its name is derived from a file this repo ships, never from code
arriving from outside.
Where each of the last three sits is load-bearing rather than tidy. Compiling
Script.X replaces whatever Script.X this VM is running, so a refusal taken
after the compile has already displaced a live script for as long as it
takes to put it back. Everything readable off the derived name therefore goes
first — the reserved-name rule, and create/1's own "that name is taken" —
which is what stops a create from ever reaching the compiler under a name that
belongs to another script. Only the host and throttle rules need the compiled
module, and only they are answered afterwards — and they are answered, with
the row write behind them, inside the loader's own message, as the commit
YmerNode.Scripts.Loader.load/2 runs between the compile and the recording of
the facts. That is what makes the far side of the compile gapless: a run
queued behind the compile is never handed the candidate a refusal is about to
put back, and the second of two writes claiming one host reads the first's
row rather than racing it. The row itself is read again inside that message,
so the name rule and the module rule the door answered from a pre-image are
answered once more from the row as it stands. check/1 answers the host and
throttle rules too, but after the loader has answered and put the stored code
back — an advisory read, like its existing flag: it stores nothing, so a
stale answer costs nothing but the refused write it failed to predict.
The host rule exists because YmerNode.References.Sources resolves a uri to
the first claiming script by name. Two scripts claiming one host would still
give a stable answer, but a silently-losing script is worse than a refused
one: its author would watch references resolve elsewhere with nothing to read.
The throttle rule exists because a throttle is one process per name
(YmerNode.Scripts.Throttle), and every request hands it the parameters of
the script making it: two accepted scripts declaring one name differently would
have it obey whichever asked last, and a disagreement between two authors about
one account would be settled by timing. The refusal names the accepted script
that declared the name first; a script declaring it with the same parameters
shares the throttle.
Coordination
flowchart TD
S[YmerNode.Scripts]
subgraph owned
Script[Script schema]
Compiler
Loader
Runner
end
subgraph external
Repo[(YmerNode.Repo)]
Sources[References.Sources]
end
S -->|"parse before every write"| Compiler
S -->|"load with the store as its commit, unload, facts"| Loader
S -->|"run/3"| Runner
S --> Script
S --> Repo
S -->|"reserved names, claimed hosts"| SourcesThe store and the VM are two different truths and this module is where they
are joined. The row says what the node accepted; the loader says what this
boot managed to compile. list/0 and describe/2 answer both, so a script
that will not compile is visible as such rather than missing.
Design decisions
- A write compiles before it lands, always.
create/1,update/2,push/1andplant_example/0compile the code before touching the database, so a row the node holds has compiled at least once on this node and the caller's error is a diagnostic rather than a row that never runs. - Exactly one compile per call, through the loader. The loader compiles and records the facts in one message, so the write path never compiles a second time to learn what it already knows — script code runs once per write, not twice.
- The far side of the compile is the loader's message too. The host and
throttle rules and the row write are handed to
YmerNode.Scripts.Loader.load/2as itscommit:, and a remove's row delete tounload/3's, so compile, refusal and store — and purge and delete — are one queued operation each. Why is § What acceptance refuses above. - A compile that is not kept puts the old code back. Compiling
Script.Xunloads whateverScript.Xthis VM had, so a write refused after its compile would otherwise leave the running script unloaded while its row still read accepted. The loader restores the row's code inside the same message that compiled the candidate; where there is no row, it forgets the name instead. The one refusal that restores nothing is the loader's ownrun_in_flight: it compiled nothing, and a restore there would purge the live run's tree. check/1restores nothing, because nothing observes its candidate.YmerNode.Scripts.Loadercompiles the candidate and puts the stored code back inside one message, and records no facts for the candidate at all. This module hands it the stored code to put back and reads the result; a reader arriving mid-check sees the state before it or the state after it, never a live script's name answering code nobody accepted.- The name is derived, never given.
YmerNode.Scripts.Compiler.parse/1reads it from the code's top module, soupdate/2refuses code whose name derives to something other than the row being updated — a rename is a remove and a create, and doing it silently would leave the old row behind. The same rule holds one level down:Macro.underscore/1is not injective, soScript.JiraAPIderives the nameScript.JiraApialready holds, and a replace that landed it would leave two module trees under one row with nothing ever purging the first.update/2andpush/1refuse a top module that differs from the stored code's; that rename, too, is a remove and a create. create/1refuses an existing name rather than answering the existing row the wayYmerNode.References.create_reference/1does. Two references with one uri are the same pointer; two scripts with one name are different code, so a silent success would hide a write that did nothing.- Every door that replaces or removes a script's code refuses while a run of
it is in flight —
create/1,update/2,push/1,accept/1,remove/1andcheck/1alike, because every one of them purges the tree before it loads anything.:code.purge/1kills a process still executing the old code, so landing any of them over a live run would kill a half-finished run to make room — the node breaking the read-before-write rule it asks scripts to keep. Each door asksYmerNode.Scripts.Runner.in_flight?/1first, for an early refusal that names the script; the refusal that holds isYmerNode.Scripts.Loader's, taken in the same message as the purge, because a run can start between a door's own look and the purge it leads to. The wait is bounded by the timeout cap. accept/1on an already-accepted row changes nothing at all. No recompile, no purge, no fresh acceptance time — it answers the row. Every door today accepts in the same breath as it writes, so that is every accept this node can currently be asked for, and a re-affirmation an operator makes out of caution must not be the thing that kills a run. The compile is kept for the stale case registry sync will bring, which is the only case that needs one. It follows that aBrokenrow is repaired byupdate/2alone, as the diagram above says: accept has nothing to recompile there, because the hashes already agree.- No version history. An
update/2replaces the code and the previous text is gone: a standard script's history is its repository's, an ad-hoc script's is the session that wrote it. Revisit if a script is ever lost to a bad update. - Declarations are written string-keyed. The row's
declarationscolumn round-trips through JSON, so what goes in atom-keyed comes back string-keyed; writing the string form is what makes a freshly-inserted row and a re-read one the same shape, and the references seam reads exactly one of them. plant_example/0compiles outside the loader, and it is the only write that does. It runs inside the migration that plants the example script, beforeYmerNode.Scripts.Loaderexists; nothing else runs then — no live tree to displace, no run to refuse — and the loader compiles the row with every other accepted one a moment later at the same boot. What the loader's message buys the other doors, it does not need; what acceptance refuses, it refuses like them: the host and throttle rules read the accepted rows, and the build's example is refused where an operator's own script already claims its host.
Summary
Functions
Marks the stored code accepted, as of now.
Compiles code and answers what it would become, writing nothing.
Creates a script from new code, accepted at exactly those bytes — the
script_author door, origin authored.
One script in full — everything list/0 carries plus the contract, the
acceptance time, the declarations and each action's whole schema.
Where the example script sits in this build.
One script's row by name.
Every script, slim: what a worker reads to choose one.
Plants the example script the image ships as an accepted row — the build's
door, origin shipped — unless a row of its name is already here, which is
then answered untouched: an operator's own copy is never overwritten.
Whether the migration that plants the example script plants at all
(config :ymer_node, YmerNode.Scripts, :plant_example).
Lands a file as a script, creating or replacing by its derived name — the CLI
door, origin pushed.
Deletes a script and unloads its module tree.
Runs one action of one script.
Replaces one script's code — the script_author door.
Functions
Marks the stored code accepted, as of now.
A row already accepted at its current code is answered unchanged — nothing is compiled, nothing is purged, and the acceptance time stays where it was. That is every accept this node can be asked for today, because every door accepts in the same breath as it writes.
The rest is the verb registry sync will need: stored code nobody here has looked at is compiled, and the acceptance refusals run as they do at any other door.
Compiles code and answers what it would become, writing nothing.
The door an author knocks on before create/1: the derived name, the
contract, the description, the actions and the declarations, plus any compile
warnings and existing — whether a script of that name is already here. Every
refusal is the one the write would have given.
existing: true says create/1 would be refused and update/2 is the call to
make. Answering it here is the round trip this door exists to save, and it is
never left out: a flag that disappears when it is false is a flag a reader
stops checking.
The compile itself goes through YmerNode.Scripts.Loader, which compiles the
candidate and puts the stored code back as one operation — reading the row for
that restore itself. This module reads the row for existing and for the
module rule a replace would apply, and answers the host and throttle rules
after the loader has restored; all of it is advisory: a check racing a write
of the same name can answer a flag or a refusal that is already out of date,
which costs its caller one refused write. The restore is what must not race,
and that is why it is not this module's.
Checking compiles, and compiling replaces the tree of that name, so this is refused while a run of that name is in flight for the same reason a write is. Nothing else about it writes anything.
Creates a script from new code, accepted at exactly those bytes — the
script_author door, origin authored.
Refuses a name that already exists, pointing at update/2.
One script in full — everything list/0 carries plus the contract, the
acceptance time, the declarations and each action's whole schema.
Pass code: true for the code itself. It is left out by default because a
worker asking what a script does should not pay for every line of it, and a
Crosskey-sized script is most of a context window.
Where the example script sits in this build.
One script's row by name.
Every script, slim: what a worker reads to choose one.
Each entry carries the row's name, description, origin and accepted?,
the action names, and — from this boot's compile — loaded? plus the error
that explains a script that will not run. Ordered by name, so two calls agree.
Plants the example script the image ships as an accepted row — the build's
door, origin shipped — unless a row of its name is already here, which is
then answered untouched: an operator's own copy is never overwritten.
The one write that compiles outside YmerNode.Scripts.Loader. It runs inside
the migration that calls it, before the loader exists, and nothing else runs
then: no live tree to displace, no run to refuse, no facts to record, since
the loader compiles every accepted row a moment later at the same boot. The
tree is purged as soon as the metadata is read, so the loader meets the VM as
it would have without this. What acceptance refuses, this door refuses too:
the host and throttle rules read the accepted rows, not the loader, and run
here in the open between the compile and the row.
{:error, {:host_claimed, detail}} is the refusal every write door gives when
an accepted script already claims a host the example declares — here, an
operator's own script, written before this build arrived, which is a choice
this build has no business overriding: the migration plants nothing, says so,
and records itself all the same, so that install never gets the example from
a boot. Any other {:error, {reason, detail}} is a shipped file that does not
compile as a script — a build's defect, which the suite compiling those bytes
exists to catch before any image carries them. An {:error, changeset} from
the row write is this codebase's own bug — the compiler produced every value
the changeset checks — and the migration lets it crash the boot loudly rather
than naming a next step nobody has.
Whether the migration that plants the example script plants at all
(config :ymer_node, YmerNode.Scripts, :plant_example).
Defaults to enabled. config/test.exs turns it off, so a test database never
holds a row no case wrote and a listing a case asserts on starts empty;
YmerNode.ScriptsTest calls plant_example/0 itself.
Lands a file as a script, creating or replacing by its derived name — the CLI
door, origin pushed.
Create-or-update rather than one or the other: a human pointing at a file has said what they want, and making them know first whether the node already has it would be a question with no purpose.
Deletes a script and unloads its module tree.
Refuses while a run of that script is in flight, for the same reason
update/2 does.
Runs one action of one script.
The node's own callers reach a script through here and not through
YmerNode.Scripts.Runner directly, so a scheduled run inside the node gets the
same acceptance check, the same batteries, the same deadline and the same
isolation an MCP call gets.
Replaces one script's code — the script_author door.
Refuses code whose derived name is not name, and refuses while a run of that
script is in flight.