Ask first, receive later
Part 3 of 7. Part 2 described the deployment plan and the rule it creates.
Here is the loop that breaks a single-pass evaluator. A game server needs its database's address. The address depends on which machine the database landed on. That placement comes out of the plan, and the plan comes out of evaluating the game server.
A module is two functions
Written as one recursive attribute set, that is a cycle, and a fixpoint is the usual way out. We do not take it. A module is two functions instead: one that asks, one that receives.
modules/mygame/server.nix, trimmed{ mygame, postgresqlDatabase }: # bound by the author's own repo
{ settings, ... }: # bound by the deployment
{
uses.db = { interface = postgresqlDatabase; };
impl = { results, alloc, ... }: {
units.gameserver = {
env = {
DB_DSN = results.db.dsn;
PORT_GAME = toString alloc.ports.game;
};
command = ''${mygame}/bin/gameserver --dsn "$DB_DSN" --port "$PORT_GAME"'';
};
};
} The outer function sees settings and nothing else, so it can always be evaluated. It yields uses.db, a slot with a type and no value in it.
An undeclared dependency is not merely discouraged here, it is unreachable. impl receives exactly
the slots the module declared, and there is no ambient namespace to reach through.
A slot also says how many providers it means, because that decides the type impl reads. reach takes three values and the one above is the default:
uses.db = { interface = postgresqlDatabase; }; # reach = "one"
uses.cache = { interface = nixBinaryCache; reach = "local"; }; # the one on my machine
uses.caches = { interface = nixBinaryCache; reach = "all"; }; # every one of them Under one and local the consumer reads one export set, so results.db.dsn is a string. Under all it reads a set keyed by machine, so results.caches.<machine>.url is the shape and impl maps over it. all keys by machine even when the provider turned out to have a single placement,
because a shape that collapsed at one placement would be a shape the deployment gets to choose.
Which is the whole point of the field. The number of machines a provider runs on belongs to the deployment, and the shape of what a module reads belongs to the module, so the module says it and the planner checks the two agree. Nothing an operator writes can turn one string into a set behind the author's back.
What gets checked is the placements this consumer can read, not the ones that exist. An export the
provider tagged machine-local is readable only from the provider's own machine, and a routable one
is readable from anywhere. So a database placed on six machines and publishing a machine-local socket
presents exactly one readable placement to each of its consumers, and uses.db above needs no field
at all. That is why reach is almost never written: local earns its place only when a provider's
exports are all routable and a consumer specifically wants the one beside it, and all when it really
does want the set. Pairing all with a machine-local export is refused rather than counted, because
no placement makes a set spanning machines agree with a value that never leaves one, and round 3
below is where the row lands.
What happens between the two halves
The planner collects the slots from every service and decides placement. Then it allocates ports,
resolves each slot to a provider, and calls impl with what it decided. Eight rounds do that work,
and the last section of this post traces all of them over one deployment.
Nothing is recursive here. results.db is a function argument, so no order has to be guessed and no
evaluation reads a value that is still being computed.
Why the answers arrive as environment entries
The planner hands each answer over as an environment entry rather than pasting it into the command string. That is not cosmetic. A value interpolated into the command lands in the closure, so moving the database would rebuild every consumer instead of restarting it, and part 4 prices the difference.
An interface is a value
interfaces/default.nix, one interface of four{ korora }:
korora.interface {
name = "postgresql-database";
exports = {
dsn = {
type = korora.string;
locality = "any";
lifecycle = "at-most-probed";
secrecy = "public";
};
username = { type = korora.string; locality = "routable"; lifecycle = "static"; };
version = { type = korora.version; locality = "routable"; lifecycle = "at-most-probed"; };
};
} korora is a Nix type library, and korora.interface is the builder this design adds on top of it.
An author imports the result the same way they import a binary, through the module's own repo rather
than through the deployment, and the import is what makes the slot checkable.
The provider declares the other side of the same value, and it declares its own nature while it is there:
provides.db = {
interface = postgresqlDatabase;
locality.dsn = if settings.listen == "socket" then "machine-local" else "routable";
}; What that value is, precisely: a name and a set of export descriptions. There is no provider in it, no
instance, and no data. postgresqlDatabase is the shape of what will arrive, which is what makes it
safe for a module to hold one without knowing where the database will be.
- A slot is not a container
slot "db" postgresqlDatabasedeclares one end of an edge. Nothing is ever placed inside it. The planner resolves the slot to a provider's capability and the slot stops existing, and only later doesimplreceiveresults.dbholding the values that provider published.
What each field of an export decides
type catches a wrong wire and a mistyped read.
locality says whether a value survives crossing a machine boundary, which decides what has to run
on the same host.
locality and lifecycle are both bounds rather than facts, and the reason is the same for both. A
DSN is a unix socket path for a local postgres and a host and port for a managed one, so it is
machine-local in the first case, routable in the second, and no interface can know which. The
interface caps what any provider may claim and the provider says what it actually is. A bound of any is the interface refusing to guess, so a provider that leaves it unset gets a diagnostic row; a
bound that names one value is a cap a provider inherits and cannot widen.
A bound is the set of tags it admits, and containment is membership of the provider's tag in that
set. at-most-probed admits static and probed, any over lifecycle admits those two and dynamic, and any over locality admits machine-local and routable. One relation covers all
three bounds. at-most-probed reads as a cap because its set leaves out dynamic, and the secrecy floor below reads as a floor because its set leaves out nothing a provider might raise it
to. locality has no order behind it, which is why the relation is membership and not a comparison. machine-local and routable are alternatives rather than endpoints, and a value on another
machine is not less local, it is somewhere else.
Only dsn needs the second declaration above, because username and version are capped at routable and there is nothing left to say about them.
That one field is load-bearing twice over. Part 4 turns the lifecycle tag into a plane, and round 5 below turns the locality tag into co-placement, which is why a bound that leaves a choice has the provider make it rather than the shape both providers share.
secrecy is the fourth field, and it is the only one an interface may leave out. locality and lifecycle are mandatory. A missing cap is a permission: an interface that omits lifecycle admits
a provider feeding the export from a running service, which is the laundering the cap exists to
refuse. The widest bound is never a safe guess about what a consumer may already do with the value.
A missing floor grants nothing, because public is the weakest claim the field can make, so username and version above are public by omission.
The floor runs one way. The interface states the least handling every provider of the export gets, a
provider may raise public to secret, and nobody may lower one. Lowering is refused rather than
warned about, because the interface author knew something about the bytes that the wiring layer does
not. An export whose type is a reference to a credential declares secret at the interface, since a
reference to a credential is a credential.
dsn writes its floor out for a reason. A DSN often carries a password, and this one never does,
because a socket path has nowhere to put one. The floor has to hold for every provider of the
interface rather than for the convenient case. A provider whose own DSN embeds a password raises
that export to secret itself.
The raise belongs to the provider and to nobody else. A deployment writes module, settings, members, placement, wire, and exposes, and none of those six spells a secrecy raise. How a
value is handled is a property of whatever produced the bytes rather than of the fleet that wires
them, so a deployment claiming otherwise would be asserting something it cannot check.
Typing the export also fixes attribution. A provider that reshapes db.dsn breaks its consumers, and
the error names the provider, because the export is checked at the node that produced it.
One module, several services
An instance is one module, and that module may own several services. Inside it, a slot is a value and
so is a member, so an edge between two of its own services is a Nix binding. slot and service mint
those values and arrive as module arguments, the same way lib does. What follows is module source,
not a deployment:
modules/mygame/default.nix, trimmed{ mygame, postgresql, meshPeer }: # bound by the author's own repo
{ slot, service, ... }: # provided by the engine
let
mesh = slot "mesh" meshPeer;
db = service "db" {
module = postgresql.services.database;
defaults.dataDir = "/var/lib/mygame/db";
fixed = { name = "gamedb"; listen = "socket"; };
};
server = service "server" {
module = mygame.services.server;
wire = { inherit mesh; db = db.provides.db; };
};
in
{
services = { inherit db server; };
uses = { inherit mesh; };
provides.endpoint = server.provides.endpoint;
} A member takes a name for the same reason a slot does. The deployment addresses members and slots out
of one namespace, the one that settings, placement, members and wire all write into, so the
two sets of names have to be disjoint and both of them are published. Renaming a member re-keys its
plan entries and breaks every deployment that mentioned it.
db.provides.db is the capability, not a path to it. Misspell it and the file does not evaluate,
which is the same reason an interface is a value rather than the string "postgresql-database".
Why two members cannot deadlock
A handle's provides depends on the member's module and settings and never on its wire. Reading a
sibling's capability therefore forces that sibling's asking half and stops there, so two members can
reference each other without an evaluation cycle. It is the split from the top of this post, one level
up.
A setting reaches a member without anyone forwarding it
settings.server.slots is part 1's instance setting arriving at the
member that reads it. A member's settings namespace is settings.<member> and the engine delivers it,
so the path says which member receives a value and two members may hold the same key name without
colliding. A leaf's own namespace stays flat, because a leaf has no members, and the keying comes from
where a member sits rather than from anything a root writes.
A root forwards nothing. A module with forty knobs writes no forwarding lines, and a knob the author
forgot to forward cannot exist, because there is no forwarding step to forget. What a root writes
instead is which knobs it owns, one decision per knob. defaults is what this composition suggests
and a deployment may overwrite it. fixed is what the composition's own invariants require, and a
definition against a fixed path is a row naming both files rather than a silent win for either. listen = "socket" is fixed above because owning the database is the premise the rest of the module
is written around, and dataDir is a default because an operator who wants the data on the machine's
fast disk is not wrong.
The bill is a path segment. A root with a single member keys that one member all the same, so pg-shared writes settings.main.listen rather than settings.listen.
What no deployment can write is a setting for one placement. A member's asking half is evaluated
exactly once, which is what makes server.provides.endpoint a single value the root hands to client as a binding. Per-placement settings would make a member's provides a set indexed by
wherever the planner put it, and every binding inside every root a fan-out over that set. So one
machine that wants a louder log level than the rest gets its own instance of the same module with the
server and the database cut, wired to the endpoint the original instance exposes. Divergence between
machines is divergence between instances, and an instance already has a name in the plan key. What
the next sections change is only that the divergent instance no longer needs a shape somebody
published in advance.
Inside a module, nothing has to be resolved
There is no resolution rule here at all. Nothing matches by interface and nothing has to be unique. The questions "which of my two postgres siblings did you mean" and "nobody provides this" cannot be asked, because the author wrote the edge. Resolution survives in exactly one place, which is the boundary where the far end is not in scope.
Cutting a member does not add a resolution rule inside the module. It moves one edge out to the boundary, where the deployment names the far end and the same check covers it as covers every other name.
The deployment does the cutting
The module above owns its database, and there is no second module file that does not. A deployment that wants a shared one deletes the member and says what fills the hole:
deployment/instances.nixinstances.mygame-eu = {
module = mygame.services.default;
members.db.enable = false;
wire.server.db = { instance = "pg-shared"; provides = "eu"; };
}; members.<name>.enable defaults to true, and disabling a member deletes it along with every plan
entry it would have produced. Each uses of a kept member that was bound to the cut member's
capability becomes an unfilled use, and wire.<member>.<use> is the address that fills it. server is the member and db is that member's own name for what it asks for, and both halves were published
before anyone wrote this line. Leave the wire out and the diagnostic names that address and lists what
the fleet exposes, the same way it does for a module-level slot nobody wired.
wire.<slot> and wire.<member>.<use> are the whole of what a deployment reaches inside a module.
Naming a cut member under settings or placement is a row rather than a silent no-op. A capability
the root re-exports from a cut member is one the root can no longer offer, which is silent while
nothing asks for it and a row the moment exposes names it.
A slot carries a name because a module may declare two of one interface, and two slots that hash equal
would make a member's wire ambiguous. The name has to agree with the attribute it is bound to under uses, because that attribute is where the deployment reaches it. Member names sit in that same
namespace and have to be disjoint from slot names, which is what makes one name mean one thing under settings, placement, members and wire at once. A collision is a row in the module file.
The plan never learns that any of this happened. A cut member produces no entries, exactly as it produced none under a shape that never had it, so nothing in the planner moved and only what a deployment may say did.
Wiring across an instance boundary
deployment/instances.nixinstances.pg-shared = {
module = postgresql.services.cluster;
settings.main = {
name = "shared";
listen = "tcp";
databases = {
eu = { owner = "mygame_eu"; };
us = { owner = "mygame_us"; };
};
};
exposes = [ "eu" "us" ];
};
instances.mygame-eu = {
module = mygame.services.default;
members.db.enable = false;
wire.server.db = { instance = "pg-shared"; provides = "eu"; };
}; Here the reference is a name. The planner checks it against what the far end publishes and reports a candidate list when it misses, and it can do that exhaustively, because it holds every instance name and every exposed capability in the deployment.
- The whole rule
A reference is a binding when its target is a kept sibling in the same evaluation, and a name otherwise.
Crossing the boundary is written on both sides. The provider lists what it exposes and the consumer names one of those exports. Neither half is implicit, because a module's restart behaviour should not depend on what unrelated instances happen to publish.
Note what the deployment never learns. pg-shared publishes one capability per configured database,
each under the database's own name, so the service inside it that provides them stays private. One
process, two owners, and neither game finds out that the other is behind the same postgres.
Each of those capabilities declares consumers = "one". One database handed to two independent
deployments is two game servers writing one set of tables, so the second wire is refused rather than
warned about. The default is "many", which is what a mesh peer read by six placements needs, and no
interface can decide between the two cases, so the provider says which it is.
Why two instances are not one evaluation
pg-shared and mygame-eu are attributes of the same set, so binding one from the other looks free:
not possibleinstances = rec {
pg-shared = { module = postgresql.services.cluster; };
mygame-eu.wire.server.db = { instance = pg-shared; provides = "eu"; };
}; Three things go wrong and the first is decisive. A deployment is a module set, and module sets merge
across files, so rec binds what this file wrote and cannot see an instance defined next door.
Reaching for config.instances.pg-shared instead is the ambient readback this design refuses
everywhere else, and it is the same pattern that makes upstream's own sub-service wiring unusable here.
Identity is the second. An evaluated instance carries impl, which is a function, and Nix cannot
compare functions, so asking whether two instances are the same value throws instead of answering. A
reference would have to carry the instance's own name to be usable at all, which is the name it set out
to replace.
The third is that the name is load-bearing whatever the authoring surface does. It appears in every
plan key, in every dependsOn entry, and in the allocation table that keeps placement stable across
replans. A binding would be a second spelling of something that has to exist as data anyway.
Inside a module none of that applies. One file is one evaluation, a member handle is a value that never has to survive being written down, and the plan never names it.
What a private database costs
Owning the database is why a private one is cheap. db in modules/mygame/default.nix belongs to mygame, so a second instance of that module gets a second postgres with its own data directory and
its own port claim. That buys isolation and costs a postgres process per instance, which is a real
bill rather than a rounding error.
It also has a placement consequence. A private database is reached over a unix socket, so that
provider tags its own dsn machine-local, and an edge to a machine-local export pins the two
services to one machine. The plan records which slot caused it.
The author publishes one coherent whole and the deployment cuts it. mygame.services.default is the
only root this package ships, and a deployment that wants less of it says so in members, next to the
settings and the placement the cut changes. The cut is checked against what the kept members still
need. A use that pointed at the cut member has to be filled by name. settings and placement.every.db for a member that no longer exists are rows, and a capability the root can no
longer offer is a row the moment exposes names it.
Offering the choice like this has a bill. An operator can assemble a topology the author never
tried, and the only refusals are interface typing and a capability's consumers count. A headless
server is members.client.enable = false and nothing else, a shape no file in the module anticipated
and no operator could write before. What is gone is the guarantee that every deployable shape was one
somebody had looked at.
What the split costs you
You cannot look at a peer while you are asking. This snippet is not expressible:
# not possible: the asking half has no view of anything but its own settings
uses.db = if peers.pg-main.settings.version >= 16 then ... else ...; That is the point of the split. If the asking half could read the answers, it would be a fixpoint again with extra steps.
That snippet bundles three questions, and the split refuses only one of them.
If the module wants to refuse an old provider, it says so as a predicate and the planner answers:
modules/mygame/server.nix, the asking halfuses.db = {
interface = postgresqlDatabase;
requires.version = {
predicate = "in-bounds";
bounds = ">= 16";
severity = "require";
};
}; Nothing here reads the peer. The predicate is data, and the module never learns what satisfied it or
what failed. This is the same declaration the same module already uses for requirements.kernel.module.wireguard, aimed at a slot's far end rather than at a machine, and severity carries the same two values: require refuses the plan, want produces a warning row and
leaves the module to degrade.
The check runs at the provider's node once its exports exist, which is where a mistyped export is
already caught, so the row names the provider and its published value rather than blaming the consumer
that asked. A failed predicate refuses the plan instead of quietly resolving to a different postgres,
for the same reason that asking for alpha and receiving beta is worse than a refused plan.
If the module wants to behave differently per version, that belongs in the receiving half, where results.db.version exists and an ordinary if works. The conditional in the broken snippet is not
forbidden, it is early. Moving it into impl costs nothing.
What stays impossible is asking for a different shape depending on the answer, where the branch changes which slots exist rather than which flags get passed. A module cannot grow a second slot on discovering that its database is old, because the planner would have to resolve the first slot to learn the version and then resolve a slot set that resolution changed. That is the fixpoint again, one level up, and the design has no answer for it. The two adjacent gaps are the same shape: a slot that may go unfilled has no representation, and a slot wanting every provider rather than one has none either.
The version question also has a boring answer that needs none of this. Two postgres versions side by
side are two provider instances, pg-15 and pg-16, and the deployment wires whichever one it means.
The choice moves out of the consumer's evaluation and into one line an operator can read in a diff.
A real cycle survives the split
Two services can want values from each other. A login provider needs the game lobby's redirect URI before it will issue tokens, and the lobby needs the login provider's issuer URL before it can send anyone there.
A third service owns what both need
Neither service can go first, and no evaluation strategy fixes that, because the loop is in the data and not in the evaluator. Give the shared values an owner that is neither service, and let both reach it through the slot they already have:
deployment/instances.nixinstances.oidc = {
module = oidcFacts.services.default;
settings.facts = {
issuer = "https://auth.example";
clients.mygame-lobby.redirectUris = [ "https://lobby.example/callback" ];
};
exposes = [ "mygame-lobby" ];
}; oidc:facts provides oidc-client and runs no processes at all, which is allowed: a service with no
units is still a provider. Both real services hold a slot on that interface and neither holds one on
the other. The fix is not a second mechanism bolted beside the slot, and the values arrive the same
way the database DSN arrives.
A cycle between two services is a reliable sign that a value they share is living inside one of them.
Eight rounds over one deployment
Everything above is authoring. What follows is one planning pass over the deployment this series has been using: three instances, five services, five machines. No planner exists yet, so the state below is traced through the design by hand rather than dumped from a run.
Two facts about a slot cannot be seen in the source at all. A slot stops existing in round 3, and no value exists anywhere until round 7.
Part 5 calls the outer repetition a wave, because a probe that needs a running service forces a second pass. All eight rounds below sit inside one wave.
Round 1: evaluate each instance's module
The planner calls each instance's module with that instance's settings and its members block and
nothing else. Out comes the list of members the deployment kept, the slots the module could not fill
from those members, and the capabilities it re-exports.
mygame members db, server, client
uses mesh <mesh-peer>, oidc <oidc-client>
provides endpoint -> server.endpoint <game-endpoint>
vpn-core members node
provides peer -> node.peer <mesh-peer>
oidc members facts
provides mygame-lobby -> facts.mygame-lobby <oidc-client> mygame has three members and kept all three. The deployment declared none of them: it named one
module, and two of the three turn up in its settings and placement, which is what a member name is
for.
Round 2: evaluate each member
The same shape one level down. Each member's asking half runs with the settings the engine delivered under that member's own name, and with no knowledge of who will fill its slots.
mygame:server
platforms x86_64-linux, aarch64-linux
claims ports.game udp, one port from 27015-27099, exclusive
requires kernel.module.wireguard exists require
kernel.sysctl.net.core.rmem_max in-bounds want
uses db <postgresql-database>, needs version >= 16
mesh <mesh-peer>, oidc <oidc-client>
provides endpoint <game-endpoint>
reactions mesh.address restart, keep-last, 5m
oidc.secretPath restart, fail
wire db -> capability db.db, mesh -> slot mesh, oidc -> slot oidc Read the last line closely, because it answers what a slot holds. db was handed a sibling's
capability. mesh was handed the module's own slot, which is how the module pushed that choice up to
the deployment. Neither entry contains an address, a machine, or a value.
Round 3: turn every slot into an edge
A kept sibling's capability resolves against the binding the author already wrote. A module-level slot
resolves through the deployment's wire, which names an instance and one of its exposed capabilities.
mygame:server.uses.db -> mygame:db/provides/db binding, one file
mygame:server.uses.mesh -> vpn-core:node/provides/peer name, across a boundary
mygame:server.uses.oidc -> oidc:facts/provides/mygame-lobby name, across a boundary
mygame:client.uses.server -> mygame:server/provides/endpoint binding, one file
mygame:client.uses.mesh -> vpn-core:node/provides/peer name, across a boundary
mygame:client.uses.oidc -> oidc:facts/provides/mygame-lobby name, across a boundary Six edges, and each one names a capability rather than a value. The interface is what made every
resolution checkable: a mesh-peer wired into uses.db is caught in this round, at the consumer that
asked, in a row carrying both interface names.
One shape is refused here that no placement can fix. A slot whose reach is all reads a set
that spans machines by construction, and a machine-local export can never be read by the whole of
that set. Seeing that needs no placement output. The row lands in this round and says that no
ordering of the plan makes the two agree. Under the other two reaches the same shape is not an
error, and round 5 says what it becomes.
This is also the round a cut is paid for, which the three instances above never exercise. In the mygame-eu deployment from earlier, db is disabled, so server.uses.db has no binding to resolve
against and goes through wire.server.db instead, which is the same lookup a module-level slot gets.
A use still unfilled after a cut is a row here, naming that address and carrying the candidate list.
Cardinality is checked here too, and it is the provider's half of the counting question rather than
the consumer's. pg-shared exposes eu and us, and their provider declared consumers = "one",
so mygame-eu and mygame-us wiring to the same one is an error in this round rather than a
discovery at runtime. How many providers a single edge denotes is the other half, it belongs to the
consumer, and it cannot be answered here because nothing is placed yet.
After round 3 no slot exists. What the rest of the pass carries is the edge.
Round 4: join requirements against published facts
The planner reads the fact register and opens no connections, which is what keeps planning fast enough
to run on a keystroke. For each member it checks require predicates against the machines placement
gave it, and ranks by want only where there is something to rank.
mygame:server pick, tags always-on = alpha, beta
kernel.module.wireguard alpha pass, beta pass
kernel.sysctl.net.core.rmem_max no fact on either, no table entry
-> candidates alpha, beta, ranked equally, probe queued, plan proceeds
mygame:client every, tags desktop = fred, sally, max
-> members fred, sally, max, nothing to rank The two blocks fail differently here and the difference is not cosmetic. Under pick a machine that
fails a require quietly stops being a candidate, because the point of the block is that alternatives
exist. Under every there are no alternatives, so a failing member drops with a row and a failing
machine that was named rather than tagged refuses the plan. A want with no answer costs a candidate
rank and produces a warning row, and under every it changes nothing at all, because ranking a set
you are placing in full has no effect.
Round 5: turn locality into co-placement
Each edge is inspected for the locality each export in it carries. That is the tag the provider
declared where the bound admits more than one, and the bound's single tag where it admits one. The
rule is over the slot's read set, which is what reads names when a slot declares one and every
export of the interface when it does not. It has to be that way round. What impl touches is not
knowable until round 7, and round 7 runs after the placement this round feeds. No slot in this
deployment declares reads, so here an edge whose read set holds any machine-local export is an
edge that cannot cross a machine boundary.
mygame:server must land with mygame:db
cause uses.db.dsn is tagged machine-local by its provider
mygame:server must land with vpn-core:node
cause uses.mesh.proxy is machine-local
cause uses.mesh.zone is machine-local
mygame:client must land with vpn-core:node
cause uses.mesh.proxy is machine-local
cause uses.mesh.zone is machine-local Nobody wrote any of the three, and each fell out of one field on one export. dsn is bounded any,
so its tag came from the provider that publishes a socket path. proxy and zone are bounded machine-local, a bound that admits one tag and leaves the provider nothing to restate, so the
interface settled both. The mesh constraint appears twice because one slot is handed to two members,
and both exports bind both members even though only the client reads the proxy: the slot never said
it wanted less.
The three cost different amounts, and that is the part worth reading closely. mygame:server drags mygame:db onto whichever always-on machine the planner picked, so that constraint decides where a
service nobody mentioned ends up. The two mesh constraints cost nothing, because vpn-core is placed
across both always-on and desktop, so a node is already wherever a server or a client can land.
Two services in one instance, three constraints, two different bills. That is why the instance cannot be the co-placement unit: it is not that some members are unconstrained, it is that each member's constraint points somewhere else and costs its own amount.
None of the three is a check. A co-placement constraint is an input to round 6, which is the round
that has placement output, and a constraint the placer cannot satisfy is refused there rather than
here. That holds for reach = one and for local, because each denotes a single placement, so
moving the consumer satisfies the read and the placer is the party that decides. all never reaches
this round at all. A set spanning machines cannot agree with a machine-local export under any
placement, and round 3 refused it where the wire was written.
Round 6: place, then allocate
Placement runs against round 4's candidates, honours count, and reuses the persisted choice when one
exists, which is part 4's stability rule. Allocation then satisfies each
claim per placement.
mygame:server count = 1 -> alpha
mygame:client count = "all" -> fred, sally, max
mygame:db no placement -> alpha, from round 5's constraint
oidc:facts no units -> no machine at all
mygame:server@alpha ports.game = 27015
mygame:db@alpha ports.sql = 5432 mygame:db appears nowhere in the deployment and is placed anyway. oidc:facts runs no processes, so
it is a node in the graph with no machine under it.
Placement is also the first moment the planner can count, so this is where a slot's reach is
checked. A slot states how many readable placements its consumer expects: one by omission, local for the one on the consumer's own machine, all for a set keyed by machine. mygame:client.uses.server says nothing, so it means one, and one is what count = 1 above
produced.
mygame:client.uses.server reach = one 1 readable placement ok
mygame:server.uses.db reach = one 1 readable placement ok, machine-local
mygame:client.uses.oidc reach = one 1 placement-free capability ok Raise that count to 2 and the plan is refused here, in a row naming the client's slot and the count that produced the second placement. It has to be a refusal rather than a fan-out. results.server.address is one address under one and a set keyed by machine under all, and the
module that would have to be rewritten belongs to whoever wrote impl rather than to whoever changed
the number.
The count is over placements this consumer can read, which is why the machine-local edges of round
5 need no field. mygame:db publishes a machine-local dsn, so however many times it is placed,
each of its consumers can read exactly one of them: the one it was co-placed with.
The constraints round 5 emitted are settled here too, because this is the first round that knows
where anything landed. A consumer the placer cannot put on any machine carrying its provider is a
row naming the consumer, the provider's placements, and the cause round 5 recorded. Nothing above
is refused: mygame:db had no placement of its own, so it followed mygame:server to alpha, and vpn-core is
already everywhere a server or a client can land.
Round 7: call impl, and a value exists for the first time
impl runs in dependency order. A provider's impl produces its exports, and each export either
carries a value or declares a transport. That is the whole difference between something knowable at
plan time and something that only exists while the service runs.
mygame:db db.dsn value postgresql://postgres@/run/... static
db.username value postgres static
db.version value 16.4 static
vpn-core:node peer.address transport mesh:vpn-core dynamic
peer.proxy transport mesh:vpn-core/socks dynamic
oidc:facts mygame-lobby.issuer value https://auth.example static
mygame-lobby.secretPath transport vars:authelia/clients/... dynamic Each export is checked at the node that produced it, and any predicate a consumer declared on the slot
is checked in the same place. mygame:server asked for version >= 16 and the provider published 16.4, so the edge holds. A provider publishing 15.6 would produce an error row naming the provider
and the value, and no plan would be applied.
Only now does the consumer's impl run, with results holding what those providers published:
results.db.dsn postgresql://postgres@/run/pg-gamedb/gamedb static
results.mesh.address transport mesh:vpn-core dynamic
results.oidc.secretPath transport vars:authelia/clients/mygame-lobby dynamic results.db.dsn is static, and that is worth stopping on. A local postgres publishes its DSN as an
eval-time value, so nothing about it arrives late. The interface permitted exactly that: postgresql-database.dsn is capped at at-most-probed, which allows a static DSN and refuses one fed
from a running service.
Round 8: derive the plane and the key
The last round crosses each value's provider tag with where impl put it. Part 4 is the mechanism and the full table. Below is what this deployment
produces for mygame:server.
DB_DSN static units.*.env env in the key, restart
OIDC_ISSUER static units.*.env env in the key, restart
OIDC_CLIENT_ID static units.*.env env in the key, restart
PORT_GAME static units.*.env env in the key, restart
PLAYER_SLOTS static units.*.env env in the key, restart
PEER_ADDR dynamic units.*.env contract, declared reaction
OIDC_SECRET dynamic units.*.env contract, declared reaction
maps.json static configData content diff, reload
wireguard probed requirements plan-time join Five entries land in env and are hashed into the key. Two become contracts that sit outside it,
which is why renumbering the mesh yields two byte-identical plans.
What the pass says about a slot
A slot appears in round 2 and is gone by the end of round 3. No value ever passes through it. Its whole job is to state, before placement, the shape of what the module will read later, so that the planner can resolve it in round 3, count it in round 6, and check the values against it in round 7. Three rounds read one declaration, and the module that wrote it saw nothing but its own settings.
A plan comes out even when the input is wrong
An evaluator that throws on the first bad input can drive a command line and nothing else. Ask it for a plan when one instance out of forty has a typo, and you get a single error message and no plan. The user interface then has nothing to draw, and no way to tell you the other thirty-nine were fine.
Diagnostics are data. Evaluation returns a plan and a table of problems, always both:
instance severity message
mygame:server error no machine tagged always-on matches platform x86_64-linux
mygame-eu error wire.server.db is unfilled, pg-shared exposes eu, us
authelia:main warning settings.theme is deprecated The interface draws forty instances with two of them marked. The command line consumes the same plan
and refuses to apply it while any row says error. Nothing is thrown, which is the harder half of
the contract: every helper has to return a diagnostic instead of calling throw, and one throw deep
inside a module takes the property away from the whole plan.