Ask first, receive later

#design#nix#module-system

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.

mygame:serverplanneeds db addressneeds mygame: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.

settings.server.slots = 64ask: uses.db : postgresql-databaseplanner: place, allocate, resolvereceive: results.db.dsn = postgres://10.0.0.4:5432unit + closure

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" postgresqlDatabase declares 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 does impl receive results.db holding 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.

mygame:lobbyauthelia:mainneeds issuer URLneeds redirect URI

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:factsmygame:lobbyauthelia:main

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.

1 evaluate each instance2 evaluate each member3 slots become edges4 join facts5 locality to co-placement6 place, then allocate7 call impl8 derive plane and keymembers, module slots, re-exportsclaims, requires, uses, provides, wireedges, and every slot is gonecandidate machines per memberco-placement constraintsmachines, then portsexports, then resultsplanes and keysaskplanreceiveslot existsvalue existsall eight rounds are 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.

Next: change one value, move one service.

Β© 2026 Qubasa Β· Galaxy Deploy