valis / Understanding the system
The outbound seam: one door out of valis
Every connection valis originates to the world leaves through one verb. This document says what that seam is, why it exists, what its capability model guarantees, what it deliberately does not promise, and what the Linux underneath it decides on its own.
Read ARCHITECTURE.org for where the subsystems sit and CONDITION-HANDLING.org for the failure convention this seam is written to. This file is about how a valis node reaches anything that is not itself.
Why there is a seam at all
valis is designed to run arbitrary computations for its operator, which can include code that she did not self-author. Client code hosted inside an instance, reaching the world through the instance they are attached to, are a core feature of what this is for. That is what makes one managed egress a hard requirement. A subsystem this project wrote choosing its own socket implementation is untidy, and it can be found by reading the tree. Hosted client code reaching the network however it likes cannot be found that way at all, and between those two situations the only thing standing is an authority that has to be presented before a connection exists.
The defect that made the shape concrete was in valis's own code, and it is worth telling in full, because a reader debugging something similar at three in the morning will recognize it.
valis notifies its DNS secondaries the moment a zone is revised. Writing that path, a Fade opened a socket in the file, turned the recipient names into addresses with a library that was already loaded and close to hand, and wrote handling for whatever came back. Every one of those was a locally reasonable decision, the library was a respectable one, and the file passed its tests. Nothing available at the inflection point said otherwise.
On a live node it could resolve nothing at all. The node runs inside a network namespace; the resolver that library carries reads its own nameserver list and opens its own query socket, and inside that namespace it had no route to a nameserver. The failure surfaced as a complaint about polling a file descriptor, so nothing in it said the word "name".
Reading that as a bad choice of library gets it wrong, and so does resolving to be more careful next time. Under a deadline the nearest thing wins, and it should: the developer who adds the next outbound path will reach for whatever is closest, and so will code this project never saw. The design's job is not to forbid the shortcut but to leave it nothing to offer, so that reaching for what is nearest arrives at the thing that was going to be correct anyway and no one has to remember a rule. A subsystem that decides for itself how to reach the network is a second door, and a capability model with several ways out is decorative. So there is exactly one:
src/net/dial.lispis the verb.src/net/resolve.lispis its first half, reachable from the verb and nowhere else.src/net/policy.lispdecides whether a principal may dial a destination.src/net/conditions.lispis the failure vocabulary every outbound path speaks.src/net/exemptions.lispenumerates what is still permitted outside the seam, and why.src/net/tripwire.lisprefuses a connection that skipped the seam anyway.
One verb, and the verb that does not exist
The verb is dial. There is no resolve. Not deprecated, not discouraged: absent, and the absence is asserted by a test over the package's exported symbols.
The reason is the sharpest single finding behind this design. Resolving in one place and connecting in another hides the query and publishes the destination. Picture the case that makes it concrete: a client in a dangerous place asks a valis node in another jurisdiction to look a name up, then opens the connection itself, from where it is standing. The lookup is now private and the connection is not. That is strictly worse than offering no service at all, because it looks like protection while removing none of the exposure, and a capability that can be held but is unsafe to use alone will eventually be used alone.
Resolution therefore happens only as the opening move of a dial that has already been permitted. Three absences hold the verb to it, and each is checkable rather than conventional:
- Nothing resolves without connecting.
- Nothing connects without a decision. The grant is the first required, positional argument, and the permission is evaluated inside the verb, so there is no arity that reaches a socket unchecked. A check applied by a wrapper above the primitive would leave a second way around it, taken by whichever future caller forgets the wrapper exists.
- No exported function answers an address.
A dial answers an integer file descriptor, the peer that answered, and its address family. A caller sharing this kernel takes the descriptor and talks to the peer itself, so valis leaves the data path and nothing costs per byte. A caller reached across the internet cannot be handed a descriptor at all, because a descriptor means something only inside the kernel that issued it; that case is admitted by the same decision and the same walk, with the stream re-framed onto the sealed transport rather than handed back. It is designed and not built.
Dialling is a capability, and grant width is the boundary
A dial requires a grant. A confined agent module and a person's browsing proxy are the same mechanism at different widths: one implementation, one audit surface, one place to look.
Because a destination that may not be reached is never looked up, a narrow grant closes name-based exfiltration as a side effect rather than as a countermeasure. Hostile code cannot smuggle a secret inside a hostname it is not already permitted to reach, because the query never happens.
The honest limit, stated so it is not later assumed away: that property belongs to narrow grants alone. A broad grant is by construction a general egress channel. That is the correct shape for a person browsing the web, and it means a compromised process holding one is exfiltrating freely. The property is not weakened by a wide grant; it does not apply. Grant width should track what a principal is doing now, not everything it might one day need.
The posture is default deny throughout. No grant, an unrecognized grant, and a destination the grant does not name are all refusals, and a refusal is signalled rather than returned, so it cannot be read past by a caller that ignores a value. The refusal also happens before any name is looked up, for the reason above: a caller told no must not first have made this node emit a query naming where it wanted to go.
Two caches, and why they are two
The outbound path holds two caches. They are two objects, in two files, emptied two different ways, and merging them would be a security defect rather than a tidy-up.
| Cache | Holds | Emptied by |
|---|---|---|
name cache (src/net/resolve.lisp) |
a name and the addresses it resolved to | the answer's own lifetime running out |
decision cache (src/net/policy.lisp) |
may this principal dial this destination | the owner withdrawing the grant |
The decision cache has no expiry of its own: no lifetime, no timestamp, no thread sweeping it. Give it one, even as a safety measure, and you have put a clock between the owner's withdrawal and its effect. Share it with the name cache and a withdrawn grant keeps dialling until some name's time to live happens to run out: a delay nobody chose, of a length set by whoever publishes that name, on the one path where an owner's decision is supposed to take effect at once. A decision is held only for a grant carrying its own hash, because the hash is what a withdrawal names; a grant without one is evaluated afresh every time.
A membership scope resolved at the node, such as "the secondaries configured for this zone", is never cached at all. Membership follows operator configuration, which changes with no grant being withdrawn, so a held decision would go on permitting a secondary the operator had already removed.
Failure is typed, and each type asks the operator for something different
The longest-lived defect in the connection-server model this seam follows is error propagation: many branches, one collapsed error string, and because the code sits in a library every caller inherits the loss. valis has condition classes, so it spends them here.
| Condition | What it means | What an operator does |
|---|---|---|
dial-not-permitted |
the capability check refused | widen the grant deliberately, or accept the refusal |
dial-name-not-found |
the name has no record anywhere | fix the destination |
dial-no-address-family |
this node's namespace holds no address of the family asked for | fix the namespace, not the destination |
dial-resolver-unreachable |
the configured nameserver could not be reached | fix resolver configuration or routing |
dial-connection-refused |
the peer answered and declined | the destination is reachable and not listening |
dial-host-unreachable |
the network reported no path | routing or the far network |
dial-timed-out |
the deadline elapsed with nothing back | a blackhole, not a refusal |
dial-source-address-unbound |
the required egress source could not be taken | this node does not hold that address |
dial-outside-the-seam |
code opened a connection without going through the seam | migrate that call site |
Every one of these carries the cause as a condition or an errno, never as a rendered string, because a string cannot be dispatched on and discriminating is the whole point.
The pair that matters most is dial-name-not-found against
dial-no-address-family. The operating system reports both identically.
They mean opposite things, and an operator who reads a confinement
failure as "no such name" stops looking at the place the fault
actually is. The next section is about why.
The Linux underneath, which decides more than the code does
Four facts about the machine are invisible from any line of valis source and expensive to work out from the outside.
A name lookup is not DNS, and the switch decides whether one is possible
getaddrinfo speaks no protocol of its own. It dispatches through the
hosts: line of /etc/nsswitch.conf to loadable name service switch
modules, and which modules are consulted is configuration the process
neither chooses nor can read out of its own source.
A serving valis runs inside a network namespace, and that is where
this stops being trivia. Such a process can hold working sockets, a
correct routing table and a reachable nameserver and still resolve
nothing at all, because the hosts: line it inherited, names a module
that cannot answer from where it is standing. Every layer above
reports that as though the name did not exist.
The rule that follows, for anyone adding an outbound path: a library that implements DNS itself, rather than asking the system, opts out of the one lever an operator holds over a confined node. It does so silently, and only on a deployed host.
Two resolvers live in this image and they do not behave alike
SBCL's own socket layer hands a lookup to glibc getaddrinfo, so it
goes through the switch and obeys the confined view. iolib, which this
seam uses for its sockets and never for names, carries a DNS client of
its own: it reads /etc/resolv.conf itself, opens its own datagram
socket, and sends the query. Inside a namespace whose nameserver that
client cannot reach, it fails on the query socket rather than in the
resolver, so what surfaces is a condition about polling a descriptor.
That failure is also defined outside its own library's condition hierarchy, so it is neither a resolver condition nor a socket condition. It escapes the library's retry path and it escapes a lookup that was asked not to signal. The general form outlives this one library: a condition class outside a hierarchy escapes every handler written for that hierarchy, and a handler broad enough to catch it anyway is one that cannot discriminate.
None of this is a preference between libraries. It is measured behaviour on a confined node, and it is the defect that produced this seam.
A namespace can be given its own view of that configuration, and the bind fails silently
The namespace launcher, ip netns exec, bind-mounts /etc/netns/<name>/*
over the matching files in /etc for the process it starts. That is the
supported way to hand a confined process a different resolv.conf or a
different nsswitch.conf, and it works under a hardened service unit
with a strict read-only system, a private temporary directory and a
full capability bounding set, with both files binding.
The pitfall is silent. A bind that fails prints one line to standard error and still exits zero. The unit reports success while the process runs on the host's configuration instead of the one written for it. Anything depending on that view must assert the bind took effect and must never infer it from an exit status.
This is measured rather than inferred from a manual page. It was run
under a unit carrying exactly those restrictions on two systems four
systemd releases apart: the deployment platform, Debian 13 with
systemd 257 and iproute2 6.15, and a development workstation running
systemd 261 and iproute2 7.1. On both, the file placed under
/etc/netns was what the confined process read, and standard error,
read on its own, carried the unit's ordinary reporting and no failed
bind. A file whose contents cannot be read back leaves only the second
of those checks, which is why it is worth capturing separately.
A missing name and a missing address family are reported identically
Under AI_ADDRCONFIG, which is the ordinary default, the system
declines to look up a family the local machine holds no configured
address of, and it declines before any query leaves. The failure is
EAI_NONAME, rendered "Name or service not known": exactly what a name
that exists nowhere produces, and exactly how an NXDOMAIN from a
nameserver arrives.
Since the answer cannot separate them, the seam asks the kernel a
different question instead of asking the network again. It opens a
datagram socket of the family and fixes its peer at an address
reserved for documentation: TEST-NET-1 for version four (192.0.2.1)
and the documentation prefix for version six (2001:db8::1). Fixing a
datagram socket's peer transmits nothing. It performs a route lookup
and, where one succeeds, binds a local source address that can be read
back. So the question actually asked is whether this namespace holds a
routable address of that family, the answer costs one socket and no
packets, and the reserved destination means even a misreading of that
code cannot put traffic on a wire toward a real host.
A unix socket crosses a network namespace, and that is why it is out of scope
A network namespace partitions interfaces, routes and ports. It does
not partition the mount namespace, so a pathname AF_UNIX socket is
reachable from inside one by path exactly as it is from outside.
That is how a node reaches its own operator-state database over a unix
socket with no TCP listener anywhere and no password on any wire. It
is also why AF_LOCAL connections sit outside this seam's remit: they
never leave the host, so there is no destination to decide about and
no name to look up. The refusing check beneath the socket libraries
makes the same exclusion for the same reason.
The same crossing is a way out that nothing here audits, and it is open
The paragraph above is true and it is not the whole picture. A reader who stops there takes away that the crossing is a convenience. It is also the one path out of a confined namespace that this seam never sees, and on a stock systemd host it is open by default.
On such a host the hosts: line consults nss-resolve, which does not
send a query. It asks systemd-resolved over the pathname socket
/run/systemd/resolve/io.systemd.Resolve, and that socket is mode
srw-rw-rw-. Any user id inside the namespace can drive host-side
resolution through it, for any name, and the namespace's own egress
policy is never consulted because nothing the policy governs is used.
Measured on a development workstation on 2026-07-27, and it is blunter than the description. A namespace holding no configured interfaces at all resolves a public name successfully:
unshare -Urn getent hosts example.com -> 2606:4700:10::ac42:93f3, rc=0
The control isolates the cause. Same namespace, plus a private mount namespace and a tmpfs laid over the socket directory:
unshare -Urnm sh -c 'mount -t tmpfs none /run/systemd/resolve; getent hosts example.com'
-> no output, rc=2
Why this belongs in a document about the outbound seam: the confinement argument for hosting code the operator did not write is that such code has nowhere to send anything, because every destination has to be presented to one verb first. A query name is attacker-chosen text that reaches the network. Low bandwidth is not no bandwidth, and exfiltration over query names is old, well understood work.
Two further points a reader should not have to rediscover. This is not
specific to the seam's own libraries: it is glibc's switch, so it holds
for anything in the namespace that resolves names the ordinary way,
whatever language it is written in. And it should not be read as a
defect awaiting an upstream fix. systemd-resolved.service(8) places
the stub on loopback addresses in a single namespace and describes no
per-namespace operation anywhere, so a host resolver reached from
several namespaces at once is a configuration outside what the service
sets out to do. Closing this is our decision to make, on our side of
the boundary.
The disposition today is open, and stated rather than mitigated. What would close it is visible in the control above: masking the socket directory inside the unit's mount namespace. The cost is that the confined process then resolves nothing at all, so the name path it is supposed to use has to be put somewhere before that mask goes on, which makes this a decision about the namespace's resolver rather than a flag to set.
Holding the line mechanically
An intention is not a boundary. Three mechanisms make the single door a property of the tree rather than a habit.
The whole-tree scan
Every Lisp source under src/ is read with its comments and string
contents removed, and a source naming a verb that dials or resolves
fails unless the exemption registry names it. The sweep walks the tree
and the registry is the set of exceptions taken out of it, never the
other way round. A scan over a written-down list of files checks only
the files somebody thought of, and a subsystem added tomorrow goes
unchecked while saying nothing about it.
The count of files read is asserted to clear a floor, because a walker that found nothing would satisfy every other assertion and read as a clean run.
Prose may name any of these verbs freely. The writing that explains why a file must not call something is exactly the writing that names it, so a sweep punishing prose would be weakened until it checked nothing.
The refusing check at run time
A source sweep is blind to two things: a library valis merely loads, and a call reached through a symbol rather than written as one. Both are real. So a running node installs a check beneath the socket libraries' own connect verbs. Its decision, cheapest first: the seam's own connection proceeds; a connection in the local address family proceeds; otherwise the stack is walked for the innermost frame outside the socket libraries, and a name on the escape list proceeds and is counted. Anything else, including a caller that could not be named, is refused.
The check holds no way of turning a name into an address, and a test reads the file as code to keep it that way. A resolving check would rebuild the very verb this seam does not have, one layer below where it was forbidden.
The node installs the check when it starts and removes it when it stops, and a test compares both definitions by identity across a start and stop cycle.
The exemption registry, and what it costs when an entry is wrong
src/net/exemptions.lisp is one list with two fields: a repo-relative
path for the source sweep, and a package-qualified function name for
the run-time check. They are one subject seen from two angles, and
kept as two lists they drift.
Entries are marked :by-design or :migration-debt. The debt entries
exist in order to be removed, and four things hold the list open,
because intending to empty it is what failed the last time this was
tried:
- every entry carries a full sentence saying why, not a tag;
- a test compares the whole list against a written-down expected value, so growth fails until someone edits the expectation on purpose;
- a runtime report leads with the count of what remains unmigrated, so it can be read off a running node rather than inferred by reading code;
- every entry naming a caller counts the connections it was let through on, so an entry nothing uses is visibly deletable and one used constantly is visibly urgent. A zero is printed rather than withheld.
What it costs when an entry is wrong is worth being blunt about, because it has already happened twice here. The check reads the site a connection was opened from. Two entries named the valis-side function that asks for a connection rather than the one that opens it, not because of any malice on my part but because the knowledge of the function was in my mind and my internal flow state reached for it logically within the procedure I was encoding, just one frame deeper and in a sibling system. I didn't even notice doing this when it happened. A name the check can never match is decorative, which is the one thing an exemption list must not be. Both were corrected once the running node was measured in QA, rather than read.
The failure direction of a wrong entry matters and is deliberate: an unmatched or unnameable caller is refused, never permitted. A wrong entry therefore breaks a path loudly instead of opening one quietly. The exception is an entry naming something too broad. Widening an entry to name a third-party HTTP client, for instance, would permit every caller that client has, which is a wildcard by another name.
Two entries carry a subtlety a reader should not have to rediscover
- One debt entry is knowingly unmatchable. That path reaches an HTTP client by symbol at run time, and the client opens the connection itself, so the check names the client rather than the site that called it. The path is refused. The entry keeps the site the migration has to reach rather than being widened to the client.
- Two permanent entries name sites the check never reaches, because
they are in the local address family and the local-family rule
short-circuits before the stack walk. They are not wrong; they are
simply never consulted. If either ever moves off
AF_LOCAL, its name should be measured rather than assumed.
Do not add a loopback exception to the local-family rule. It would let any library in the image reach any local service, including a proxy that forwards off-host . Where a local destination genuinely needs permitting, the check belongs in that subsystem: the operator-state pool classifies the database host the operator configured and refuses one it would have to leave this node's own network to reach.
What this does and does not provide
valis is first a way for an operator to seize control of their own data. Resistance to a well-resourced adversary is a real secondary capability, not the headline. Where the two conflict, sovereignty wins by default. This section is written for the reader who needs the second thing and is in real danger, because that reader cannot afford an optimistic document.
What the seam does provide.
- One place where an outbound connection is decided, so the reach of a valis node can be enumerated rather than guessed at.
- A refusal that is signalled and typed, so a confinement fault and a wrong destination do not look the same.
- Name-based exfiltration closed for narrow grants, because a name that may not be reached is never looked up.
- Resolution that goes through the system, so an operator who configures a namespace actually governs what the node inside it can resolve.
What it does not provide, plainly.
- A wide grant is a general egress channel. Nothing about the seam changes that, and it matters more rather than less as hosted client code becomes the point: what a hosted client may reach is exactly the width it was granted.
- Putting resolution inside a tunnel removes the content leak and sharpens the pattern. Volume, timing, burst shape and message boundaries stay visible to whoever watches the operator's own network. A page load has a recognisable shape whether or not the names in it were readable.
- No transport property survives a compromised endpoint or physical coercion. A machine that is seized and carried to a laboratory yields what it holds.
- Traffic-analysis resistance against a state-level observer is not claimed. It is a mature adversarial discipline in which home-rolled obfuscation has generally lost. The posture is to interoperate with what has been studied rather than to reinvent it.
- Nothing here defends against a hostile process already holding a wide grant. The seam bounds where code may go; it does not decide whether that code is trustworthy.
Honest documentation is necessary and not sufficient
A person under stress reaches for whatever works now, and a warning read weeks earlier does not survive that state. So the design position is that a convenient path and a safe path must not look equivalent at the moment of use, and that a node should be able to state which mode it is running in rather than relying on anyone to remember what they chose.
What a node can state about itself today is what still reaches the
network outside the seam.
valis/src/net/exemptions:report-outbound-exemptions prints, on a
running node: the count of sites still unmigrated, each one with what
it is blocked on and how many connections it has been let through on
since the counts were cleared, then the permanently permitted set with
the same counts, then the one tracked tree of Lisp sources the sweep
deliberately skips and why. It reads the escape counts off the live
node, so it reports what has actually happened rather than what the
source implies.
What does not exist yet, said plainly rather than implied: there is no client-facing indicator telling the person at the other end of a session which mode that session is running in, and no affordance making a convenience path visibly different from a hardened one at the moment of use. Both are design requirements of this seam and neither is built. Until they are, a reader in real danger should assume nothing about a session's protection from the fact that it worked.
Where to look in the code
| File | What it holds |
|---|---|
src/net/dial.lisp |
the verb, the address walk, the descriptor handback, and the commentary on what the machine underneath decides |
src/net/resolve.lisp |
the lookup, the name cache, and the namespace probe |
src/net/policy.lisp |
the grant, the decision, the decision cache, and named scopes |
src/net/conditions.lisp |
the failure vocabulary and what each branch means |
src/net/http.lisp |
an HTTP request whose socket comes from the seam, used by the certificate path |
src/net/exemptions.lisp |
the registry and the runtime report |
src/net/tripwire.lisp |
the refusing check installed by a running node |
src/edge/zone-notify.lisp |
the path the defect was found on, and the account of it |
The cross-repo obligations this seam still needs from the sister repositories are specified in SEAM-CONTRACTS.org.