0013. Access rules and plain configuration travel with deploys
- Status: accepted and implemented (2026-11-05; steps 2 to 4 of the plan, tests in
apps.rs,tests/deploys.rs,tests/cluster/, the CLI’s unit tests andcrates/wasmkeep-cli/tests/deploy_config.rs, the S3 one run against MinIO). Revised after an adversarial review the same day (old deploy tools over new versions, reused version ids, rollback across the migration, the class an event takes, sockets, instances kept with old values). - Date: 2026-11-05
- Milestone: M3
Context
Two things a deployed app needs are still given to each node on its
command line, not by wasmkeep deploy
(ADR 0008):
- Access rules,
--access CLASS=MODE(ADR 0010). ADR 0010 says “every node of a cluster must get the same rules”, and nothing checks it. A node started without--access monitor=existingservesmonitorasopen, and every request reaching it can create an actor, a lease and a snapshot in the bucket: the hole ADR 0010 closed, reopened by one forgotten flag. - Plain configuration,
--env CLASS=FILE:NAME=valuefiles the actors read withenv.get. The same files also hold secrets.
An earlier design sketch left one question open: is configuration part
of the deploy version? It proposed a separate pointer for configuration,
“so changing a URL neither reruns on-init nor rolls back with code”.
The first half of that reason does not hold. on-init runs once per
component, not per deploy: ActorClass::version is the SHA-256 of the
component’s bytes, prepare compares the actor’s recorded init version
with it, and apply_deploy keeps the resident instances of a class whose
bytes did not change. A deploy that changes only rules or configuration
does not rerun on-init, wherever they are stored.
Secrets are out of scope: storing them in the bucket needs encryption and
key handling (since added, see
ADR 0016). They stay in per-node --env
files, and no secret value may reach the bucket.
What the code did before this ADR that matters here:
- The front door decided access before the event took its class
(
refresh_class, insideprepare), withensure_owner(bucket I/O) in between. A deploy landing in that window ran the new class under the old decision. - An actor’s mode was captured once, when its mailbox was created, and its values once, when its instance was created. A resident instance kept across a deploy kept both.
- A version id was the hash of the class table, and its directory, with
previousin its manifest, was reused when the same table was deployed again later, sopreviouscould point at an older version than the one actually replaced.
Decision
Rules and configuration are part of the version
A version is its classes, their access rules and their plain
configuration. One pointer (current), one sequence, one follower, one
atomic swap, as in ADR 0008.
- Rules must change atomically with the code they protect, in both
directions. Code written for
openmay check names itself; code written forexistingmay rely on the platform to. Running either under the other’s rule is a hole. - Configuration usually changes with code too (new code reads a new
URL), and the sequence check of ADR 0008 then gives “no old configuration on newer data” for actors that already ran the new deploy: a node still on deploy 6 does not run an actor that ran deploy 7, with its code or its values. - A separate pointer would need a second sequence, a second follower and a rule for which of the two an event records. Its only remaining benefit is that a rollback of code keeps the newer configuration; see Rollback for why rolling it back is the safer default.
The cost is that a configuration-only deploy is a new version, whose
directory needs the components. wasmkeep deploy copies them inside the
bucket from the current version (ObjectStore::copy, a server-side copy on
S3 and R2), so nothing is uploaded again. The copy is unconditional, which
is safe because the destination key is content-addressed: an overwrite
writes the same bytes. Nodes check every component’s digest when they load
a version, so a corrupt source is refused there, not run.
Bucket layout
apps/<tenant>/<app>/current "<version> <seq> [2]"; If-Match only
apps/<tenant>/<app>/<version>/manifest immutable
apps/<tenant>/<app>/<version>/<class>.wasm immutable (unchanged)
apps/<tenant>/<app>/<version>/<class>.config immutable, new: one per class with configuration
No new pointer and no new prefix. A version directory holds everything a node needs to run that version, and nothing another version refers to.
Version ids
The id is the first 16 hex digits of the SHA-256 of the manifest’s
identity, followed by its previous line:
- the identity is the
classlines, then theaccesslines, then theconfiglines, each group sorted by class; previous <version>\nis appended when there is one.
Including previous means a directory is never reused under a different
lineage: deploying the same classes again after something else gets a new
directory, whose previous is the version it actually replaced, so
rollback and garbage collection follow the real history. A deploy is a
no-op when the identity equals the current manifest’s (compared on the
text, not the id), so redeploying what is current still changes nothing.
Directories written before this ADR keep their ids; only new ones are
named this way. (ADR 0008’s “the version hashes the class table” is
replaced.)
For bucket garbage collection (ADR 0012,
step 4 of its plan): a version is deleted by deleting everything under <version>/,
not a list of known names, and the manifest first, so a half-deleted
directory has no manifest and no node loads it. No version’s objects are
shared with another. A directory is written again only by a deploy of the
same identity onto the same previous; such a deploy recreates the
objects that are missing (create-only: existing ones are not rewritten)
and reads the manifest back before it swaps current. Collection
therefore re-reads current before each version it deletes, and stops
if it changed.
Manifest
A manifest without rules or configuration is written exactly as before
(wasmkeep-manifest 1). One with either is wasmkeep-manifest 2:
wasmkeep-manifest 2
previous <version>
class <class> <sha256 of the component>
access <class> <spec>
config <class> <sha256 of the .config object>
accesslines: one per class whose mode is notopen.<spec>is the rest of the line after the class and one space:existing,internal, orexisting:NAME,NAME(names may contain spaces,room 1; class names cannot). Names are sorted and deduplicated; a line not in that canonical form is refused. A rule for a class not in the version is refused, bydeployand by the parser.configlines: one per class with configuration. The.configobject holds the parsed values, not the file: oneNAME='value'line per name, sorted (env::parsereads it back unchanged, since nothing inside the quotes is interpreted). Comments and commented-out lines are never uploaded, and reformatting a file does not make a new version. At mostenv::MAX_FILEbytes.- The parser refuses any line it does not know, as before. A node reading a manifest it cannot parse keeps its version, whole (below).
Values never appear in the manifest, only digests. Parse errors name the
object and the line, never a value (the env.rs convention).
The format marker in current
Once a version with rules or configuration has been current, every later
write of current carries a third field, 2 ("<version> <seq> 2"),
rollbacks included. Before this ADR, current was read as exactly two
fields, so tools and nodes from before refuse it as unreadable:
- an old
wasmkeep deploy(a CI image not upgraded) fails instead of writing a format-1 version on top, which would drop every rule; - an old node keeps its version (or, starting, does not start).
The marker also tells a new node whether rules have ever been deployed for the app (Node flags).
wasmkeep deploy
wasmkeep deploy --app T/A --bucket B [--actor CLASS=PATH]... \
[--access CLASS=MODE]... [--config CLASS=FILE]...
-
A deploy changes what it names and keeps the rest. Starting from the current version:
--actorgiven replaces the class table, as before (classes left out are removed, with their rules and configuration);--actorabsent keeps the current classes.--accesssets a class’s rule (CLASS=openremoves it).--config CLASS=FILEreplaces a class’s configuration (CLASS=removes it). A class that is not named keeps its rule and configuration.A purely declarative deploy would move the hole from node flags to deploy flags: a CI job running
wasmkeep deploy --actor ...without the--accessit never knew about would reopen every class. Keeping what is not named makes the safe outcome the default, and the manifest still records the whole version. The base is the manifest of thecurrentread whose ETag the swap is conditioned on, so a concurrent deploy fails with a conflict rather than losing a rule. -
--accessand--configfor a class not in the new version are refused (a typo would otherwise leave the class open or unconfigured). Names with control characters are refused. -
A deploy that removes a class with a rule other than
opensays so; if it also adds a class (a rename), it warns that the new class is open unless given a rule. -
--configis stored in the clear. The flag is not called--envso that a node’s secrets file is not passed by habit. A value that looks like a secret is warned about, naming the class and the variable, never the value: a name containingSECRET,TOKEN,PASSWORD,PASS,KEY,CREDENTIAL,AUTHorDSN(any case), or a URL with a password in it (a user and password before an@). The deploy goes on: the warning is a nudge, the documentation the rule. -
The deploy prints what changes against the current version: classes, rules (one line per class whose mode changes) and the names of configuration values added, changed or removed.
-
Upload and switch as in ADR 0008: components (or server-side copies),
.configobjects and the manifest with create-only writes, thencurrentwithIf-Match.
Rollback
wasmkeep deploy --rollback re-points current to the current manifest’s
previous, as before, with the rules and configuration of that version,
and prints the same diff.
A rollback that would make any class more open (existing to open,
internal to anything else, an existing: list that grows) is refused
unless --allow-reopen is given. The cases it catches: a deploy that only
hardened a rule, then a rollback that would undo it; and a rollback past
the migration point, to a version from before rules were deployed, which
would open every restricted class. Keeping the newer rules on rolled-back
code instead is not safer in general (code written for existing rolled
back under open has nothing checking names), so the version’s rules win,
but only on purpose. An explicit --access CLASS=open in a deploy is
already on purpose and needs no flag.
Nodes
A node following an app reads the version, its rules and configuration
(digests checked), compiles the classes, and swaps everything in at once
with the sequence (apply_version). A version the node cannot read or
build (an unknown format, a digest mismatch, a malformed .config) is
refused whole: the node keeps the previous version, with its rules and
values, logs the error and backs off, as in ADR 0008. While it is behind
for that reason (not for a bucket outage, which every node shares), it
reports not ready, so a load balancer stops sending it requests it would
decide under rules the app no longer has. (ADR 0008 also says the node
reports the error in its heartbeat; that is not implemented, and belongs
with wasmkeep cluster status.)
Rules and values belong to the class an event takes, and an event takes
one. ActorClass carries its version’s rules and its merged values.
Each event (a request, a call, an alarm, a socket open or message):
- takes the class once, at its start (
admit, orrefresh_classfor an alarm); nothing later in the event takes another, soprepareruns on it even if a deploy lands while the event waits for the bucket or a lease; - decides access with that class’s rules: an outside request to an
internalclass is refused (403); to anexistingclass it reaches only an actor that exists (404 otherwise); - runs on that class, with
env.getreading its values.
The front door keeps its check only as an early refusal (it saves a forward and a mailbox): it may refuse with the rules it sees, but admitting is decided in the event. A refusal is never a hole; an early admission under a rule the event’s class does not have would be.
- Sockets. A WebSocket message or close is an outside event like any
other. Under
existingthe actor must still exist (as before); under a class a deploy madeinternal, the socket is closed (1008) at its next event. - Values on resident instances. A deploy that changes a class’s values
but not its bytes drops the class’s resident instances at their next
event: the database is kept and
on-initdoes not rerun (it is keyed on the component), but a guest that cached a value in memory or derived state from it reads the new values, the same on every actor of the class. A deploy that changes neither (rules only, or the same bytes redeployed) keeps them, as before. - Logs. Each applied version logs every class’s effective mode, as
--accessdid at start.ActorEnv,Versionand the deployed configuration inActorConfigprint names and counts inDebug, never values.
Node flags
-
--accesswith--appis accepted only while no version with rules has ever been current (no marker incurrent), and only so that a cluster can be upgraded without changing its command lines first (the steps below). It is then ignored as soon as a version with rules is applied, with a warning to remove it, and it is refused at start once the marker is there. The error says what to run instead.Letting the stricter rule win (the earlier sketch’s proposal) would keep per-node rules, and with them answers that depend on the node: a request is checked where it arrives and checked again on the actor’s owner (ADR 0010), so the outcome would depend on which two nodes are involved, which is indistinguishable from a bug. It also needs intersecting
existing:name lists. And a node flag is a poor break-glass: a rules-only deploy reaches every node in about one heartbeat (3 s), restarting nodes takes longer. Without--app(--actorclasses on one node, orwasmkeep dev)--accessworks as before: there is no deploy to carry it. -
A node with
--app, no--accessand no marker logs a warning at start that the app has never deployed access rules, so every class is open: the case of an operator who dropped the flags without deploying the rules. -
--envstays, and wins by name. Secrets lived only there until ADR 0016 put them in the bucket, sealed. Each class’s values are the deployed configuration with the node’s--envvalues over it. A name set by both is logged once per version applied, as a warning naming the class and the variable, never a value. Refusing the conflict instead would block a later deploy on that node. -
--envfor a class not served is refused at start without--app, as before. With--appit is a warning: a deploy that removed the class must not keep nodes from restarting, and the values reach the class if a later deploy brings it (back).
Migration and mixed versions
A deploy with neither rules nor configuration stays format 1 with no
marker, and nodes from before this ADR take it as before. Everything else
is refused whole by old nodes and old tools: they cannot parse
wasmkeep-manifest 2 or a current with the marker. A running old node
keeps its version and refuses (503) actors that already ran the new
deploy on an upgraded node, as ADR 0008 does for any version a node cannot
build. An old node that restarts cannot start. Both fail closed. Hence the
order, for a cluster started with --access:
- Upgrade every node to the new binary, command lines unchanged. The
deployed version has no rules yet, so each node keeps applying its
--access, as before. - Deploy the rules (and configuration, if wanted) once, without
--actor:wasmkeep deploy --app T/A --bucket B --access monitor=existing. Every node switches to the deployed rules at the same sequence, and warns that its--accessis now ignored. - Remove
--accessfrom the command lines (a node restarted with it is refused from now on).
The marker is set by the first version with rules or configuration, and
from then on nodes ignore --access. A first deploy that carries only
configuration would therefore drop the flags’ rules. The deploy that sets
the marker says so, and lists the rules nodes will apply from then on
(every other class open); step 2 deploys the rules with it.
Upgrading WasmKeep itself is “every node together” in M3
(ROADMAP);
step 1 is that. Between steps 1 and 2, as before this ADR, a node started
without --access serves every class open, and says so in its log.
Alternatives
- Configuration under its own pointer (the earlier sketch’s proposal). Rejected above:
on-initis per component, so its main reason is gone, and two sequences would need their own ordering rule. - A declarative deploy (the flags given are the whole version). Rejected: forgetting a flag in CI reopens a class.
- Stricter rule wins between flag and manifest. Rejected: per-node answers, merging name lists, slower than a rules-only deploy.
- Lineage outside the version directory (
previousincurrent, or a history object per sequence) instead ofpreviousin the id. A history object written before the swap can be orphaned by a crash and then block the next deploy at that sequence;previousincurrentalone cannot walk back more than one step. Hashingpreviouskeeps the manifest as the only record and costs a new directory per change, which happens anyway when the content changes. - Content-addressed components (
apps/<t>/<a>/blobs/<sha256>.wasm, shared by versions) instead of copies. It saves storage, but versions would share objects, and garbage collection would need reference counting. Server-side copies keep each version self-contained.
Consequences
- One forgotten flag no longer reopens a class: a node with no
--accessserves the deployed rules. - Rules and configuration change on every node in about one heartbeat, with no restart, and never apart from the code they were deployed with.
- The window is not zero. Until a node applies a version that hardens
a rule, it decides under the rule it has: a new name of a class just made
existingcan still be created through it, and then exists. For following nodes that is about one heartbeat; a node that cannot apply the version reports not ready instead of deciding for minutes. To close a class at once with no window, there is no substitute for stopping traffic to it upstream. - A configuration-only deploy is a version: it gets a sequence, a line in
the
previouschain, and a server-side copy of each component. Rollback goes back one change, whatever kind it was. serve --appwith--accessis refused once rules are deployed: the one breaking change, pre-release, with the replacement in the error.- Configuration is readable by whoever reads the bucket (already trusted with everything: security). Secrets stay on nodes.
Tests
Each fails before its change; the ones marked mutation-checked are run again with the guarding line removed.
- Manifest (unit,
apps.rs): formats 1 and 2 round trip; a manifest without rules or configuration is written byte for byte as before; names with spaces round trip, and non-canonical lines, unknown lines, rules for classes not in the version and format-1 manifests with rules are refused;previousis part of the id;.configobjects are canonical (comments dropped, quotes and spaces kept, reformatting changes nothing);currentwith and without the marker. - Lineage (
tests/cluster/): deploy X, then Y, then X again: the third is a new directory whosepreviousis Y, and a rollback goes to Y. - The class an event takes (
tests/deploys.rsandtests/cluster/, mutation-checked):- request B is admitted at the front door under
openand queues behind A (held at theopenfault point); a deploy making the classinternallands; B is refused 403 (mutation: let the front door decide); - request B to an existing actor of an
existingclass waits in its existence check (a bucket whose listing is held); a deploy changing the code and making the classinternallands; B runs on the code it took (mutation: take the class again inprepare).
- request B is admitted at the front door under
- Sockets: an open socket of a class a deploy makes
internalis closed with 1008 at its next message. - Configuration (
tests/deploys.rs): a configuration-only deploy changes whatenv.getreturns, without rerunningon-init, and drops the instance (mutation: keep it); a node--envvalue wins by name. - Cluster (
tests/cluster/): two nodes started on a version with rules and no flags: an unknown name of anexistingclass answers 404 through either node and leaves nothing in the bucket; an actor created by a call is reachable through both; configuration reachesenv.geton both; a rules-and-values deploy reaches both, after which outside requests are refused and calls still reach the actor. - Readiness: a node that read a version it cannot apply reports not ready; a bucket outage does not.
- CLI (
crates/wasmkeep-cli/tests/):serve --appstarting on a format-2 version;serve --app --accessaccepted without the marker (and the flag ignored after a version with rules), refused with it;deploy --accessand--configfor a class not in the version refused; a deploy without--accesskeeps the current rules; a configuration-only deploy copies the components;--rollbackthat reopens refused without--allow-reopen, and printsexisting -> open; two concurrent deploys, one with--access, one with--actor, end with one conflict and no lost rule; a secret-looking value is warned about, and no value appears in the output or the logs.
Plan
- This ADR.
- Manifest format 2, canonical
.configobjects,previousin the id, the marker incurrent,AppRefreading and writing them (S). - Rules and values carried by
ActorClass; one take per event;apply_version; sockets; dropping instances on new values; readiness; the race, configuration and cluster tests (M). - The CLI:
deploy --access/--config, keeping what is not named, server-side copies, diffs and warnings,--rollback --allow-reopen,serve --appwith or without--accessand the marker; docs (docs/cluster.md,docs/security.md, ADR 0010’s cross-reference) (S).