valis / Reference / API reference
The edge - API reference
Exported surface for the edge subsystem. Part of the API reference.
Package valis/src/edge/admission
Functions
admission-entries
(admission-entries ports)
The admitted surface PORTS needs, as a list of (PORT . TRANSPORT) ordered as the host agent reads it back, each entry once.
port-transports
(port-transports port)
The transports PORT needs admitted, :tcp first.
read-admission
(read-admission text)
Read TEXT, an admitted surface in the host agent's grammar, as entries ordered as admission-entries orders them. Returns NIL and, as a second value, the first token that is not an admit token when TEXT does not read; the empty text reads as the empty surface.
render-admission
(render-admission ports)
The text a write to the host agent's /admit carries to admit exactly what PORTS needs: 53 and 80 render as "53 53/udp 80", and no ports as the empty string, which closes everything admitted.
Variables
*port-transports*
The transports a served port needs admitted, for every port that needs more than TCP, as (PORT TRANSPORT…). 53 answers names over both, since a resolver asks over UDP and falls back to TCP for a long answer. A port absent from this table is admitted over TCP alone.
Package valis/src/edge/control-client
Conditions
control-client-error
Signalled when the control session fails a client-side expectation: a /ports walk that does not resolve, or a dial that never connects. Distinct from the nine-p-error a server Rerror surfaces through decode-message and from the descriptor-admission-refused the push primitive signals fail-closed.
control-endpoint-refused
Signalled when the host agent's control socket cannot be dialled at PATH for a reason a restart repeats: nothing is at that path, a part of it is not a directory, the path is too long for a local socket, or this account may not connect to what is there. The host agent binds and listens before it starts this resident, so a socket missing or closed to this account at that point is a wrong path or a wrong mode, and a restart only finds it the same way.
control-endpoint-unreachable
Signalled when the host agent's control socket would not accept a connection on any attempt. Separate from the rest of control-client-error because it is the one failure here that a restart of the host agent and this resident together is expected to clear.
control-ports-walk-unresolved
Signalled when the host agent answers the walk to /ports with something other than the single node it serves there. The two binaries disagree about the control namespace, and a restart runs the same two binaries.
Generic functions
control-endpoint-refused-path
(control-endpoint-refused-path condition)
Undocumented: this exported symbol needs a docstring.
Functions
announce-orderly-stop
(announce-orderly-stop endpoint &key (wait-seconds 1))
Tell the host agent on ENDPOINT, the control session the boot opened, that the node has begun an orderly stop, log what came of it, and return that as a list:
(:announced) the agent acknowledged the notice. (:unsupported) the agent serves no stopping file: it is older than the notice. (:refused REASON) the agent answered an error line or a 9P error, REASON its words. (:failed REASON) the agent answered something other than the acknowledgment. (:session-failed REASON) the session ended, or a reply missed its deadline, under the notice. (:session-ended-earlier) this node had already stopped holding ENDPOINT. (:busy) a request still held the session after WAIT-SECONDS. (:defect REASON) the notice met a defect, logged with its backtrace. (:no-control-session) ENDPOINT is NIL.
Call it at the start of the stop, before release-host-declaration-channel closes the session: the notice marks the stop as intended only when it arrives before the session ends.
Never signals an error. Waits at most WAIT-SECONDS for the session, and at most the session's reply deadline for each answer it reads; the bound is per answer, not a total, so an agent that answers each request slowly but in time can make the notice take several deadlines.
The session stays open in every case, and stays held unless it ended or could no longer be trusted, so closing it is still left to release-host-declaration-channel.
announce-served-ports
(announce-served-ports ports)
Re-declare PORTS, the whole set, to the host agent on the session this node holds. Returns T when the agent acknowledged it. Returns NIL and, as a second value, a reason keyword, when there was no session to declare on or the declaration did not complete.
A failure here is narrower than a refusal and a caller must not treat it as one: the caller changed the node's own state before calling, that change stands, and undoing it would lose an edit the owner made because a peer was unreachable.
The declaration is ONE write carrying the whole set, which is what the agent parses: it refuses a fragmented write fail-closed, so an incremental message would land as a silent no-op. The write goes through the same single writer the boot declaration uses, so there is one codec and one framing rule. The Rwrite read carries the deadline every declaration on this session carries, so a peer that never answers cannot wedge the serve thread this runs on.
connect-and-push
(connect-and-push control-socket-path fd ports &key (uname "valis") (aname ""))
Dial CONTROL-SOCKET-PATH, run the 9P control session, and perform the /ports descriptor push of FD carrying the PORTS declaration, the entry point the resident boot calls. Returns two values: the Rwrite-count the receiver reported and the live control ENDPOINT, kept open so the daemon holds the control session for teardown. FD is caller-owned and returned still open by the push, so the daemon keeps its copy to wrap as the steered source. On any failure the freshly-dialed endpoint is closed before the condition propagates, so a failed push leaks no control connection.
declare-ports-and-push-fd
(declare-ports-and-push-fd endpoint fd ports &key (uname "valis") (aname ""))
Drive the single-threaded 9P control session on ENDPOINT and push FD: version -> attach -> walk /ports -> open for write -> the /ports descriptor push of FD carrying the PORTS declaration. Returns the Rwrite-count the receiver reported for the /ports write.
Only the final /ports push obeys the sender-ordering contract (Twrite on the wire -> send FD WITHOUT awaiting the Rwrite -> read the bounded Rwrite); every earlier op is an ordinary synchronous request/reply. The descriptor transfer is race-free at the wire (the receiver reads with seven's exact-count control reader, so the carrier and its ancillary wait queued until it pulls them) and the ordering exists ONLY to avoid deadlocking a receiver that pulls the FD, on its serve thread inside the /ports write, before it answers. FD is validated and pushed by send-admitted-fd (LISTEN-state + AFINET, gated fail-closed); it is caller-owned on every outcome and returned still open on success: the caller keeps its copy to wrap as the steered edge source.
declare-ports-on-endpoint
(declare-ports-on-endpoint endpoint ports &key (tag 3) after-write)
Write the PORTS declaration to /ports on ENDPOINT and return the Rwrite count
the receiver reported. ENDPOINT must already carry ports-fid walked onto
/ports and opened for write, which the boot session does once and then holds, so
a later declaration on the held session needs no second walk and no second open.
One codec and one framing rule serve every declaration: the render is %render-port-declaration and the write is a single Twrite at offset 0, because the host agent parses a declaration as one whole write and refuses any other offset fail-closed. A later declaration therefore REPLACES the declared set rather than appending to it.
AFTER-WRITE, when supplied, runs once the Twrite is on the wire and before the Rwrite is read. The boot push uses it to send the descriptor without awaiting the Rwrite, the ordering that keeps a receiver which pulls the descriptor on its serve thread, inside the /ports write, from deadlocking against us. A declaration carrying no descriptor passes no thunk and the read follows directly.
TAG is reusable across declarations because the session is single-threaded: each Rwrite is read before the next request is issued, so no tag is ever outstanding and there is nothing to demultiplex.
dial-control-endpoint
(dial-control-endpoint control-socket-path &key (retries 5) (retry-interval 0.05))
Dial the host agent's AFLOCAL control socket at CONTROL-SOCKET-PATH and return a seven socket-endpoint over the connected stream. Retries a bounded few times if the connect races the launcher bringing the socket up: the launcher binds and listens before it execs valis, so a connect normally lands in the listen backlog; the retry only covers a narrow startup race. When every attempt fails, signals control-endpoint-refused if the last one failed for a reason a restart repeats (no socket at the path, a path that cannot be a socket, or a socket this account may not connect to), and control-endpoint-unreachable otherwise.
The endpoint carries both the 9P control frames and, sideband over SCMRIGHTS on the same connection, the pushed descriptor. There is no second channel.
The endpoint reads every frame with seven's exact-count reader, because the host agent can send a descriptor back over the session: the UDP socket for DNS that request-host-dns-socket asks for. A buffered reader could take that carrier's bytes as the start of a reply and lose the descriptor riding on them.
exchange-host-admission
(exchange-host-admission text)
Write TEXT, the complete surface this node wants admitted in the host agent's admit grammar, to the agent's /admit over the control session this node holds, and return what came of it as a list:
(:answered REPLY) the agent took the write; REPLY is what it answered on the same file afterwards, either the surface its kernel now holds or an error line refusing TEXT. (:refused REASON) the session refused a step with a 9P error, REASON its words. (:unsupported) the agent serves no admit file. (:no-control-session) this node holds no session with a host agent. (:session-failed REASON) the session ended under the exchange. (:failed REASON) an answer came that could not be read.
Never signals, and never waits longer than the session's reply deadline for any one answer. A session that ends or answers something unreadable is given up, as it is when request-host-restart, this node asking to be restarted, meets the same. The exchange takes the lock every request on this session takes, so it cannot interleave its frames with a declaration.
release-host-declaration-channel
(release-host-declaration-channel endpoint &key (wait-seconds 1))
Stop holding the control session and close ENDPOINT, the session the boot opened, as the node stops. A request on the session at that moment is given WAIT-SECONDS to finish first, because a stop is exactly what an accepted restart brings while its request may still be reading the answer; after that the session is closed regardless, so a request that is stuck cannot hold the node's teardown. ENDPOINT may be NIL, and may already be closed.
request-host-dns-socket
(request-host-dns-socket)
Ask the host agent, over the control session this node holds, for a UDP socket bound to the DNS port in this node's namespace, and return what came of it as a list:
(:received FD) the agent bound the socket and sent it; FD is this process's descriptor for it, and the caller owns it. (:refused REASON) the agent refused, REASON its words: it is stopping, the port is already bound, or the bind failed. (:unsupported) the agent serves no bind file. (:no-control-session) this node holds no session with a host agent. (:session-failed REASON) the session ended under the exchange. (:failed REASON) an answer came that could not be read, or the agent answered before it sent the socket.
I ask the agent because this node runs with no privilege to bind a port below 1024, and the agent keeps that privilege. TCP needs no such socket: every TCP connection already reaches the node over the steered socket.
Never signals, and never waits longer than the session's reply deadline for any one answer. A session that ends, or that can no longer be trusted to carry the next request, is given up, as it is when request-host-restart meets the same. The exchange takes the lock every request on this session takes, so nothing else is outstanding on the session while the descriptor crosses it.
request-host-restart
(request-host-restart)
Ask the host agent, over the control session this node holds, to restart the service the node runs under, and return what came of it as a list:
(:accepted SERVICE) the agent's service manager took the restart job for SERVICE, and the node is about to be stopped. (:probably-accepted) the request went out and the session ended before the agent answered. That is how a restart that stopped the node first looks from inside it, and also what the node's own stop gives a request still waiting on an agent that fell silent. (:refused REASON) the agent answered with a refusal, REASON its words. (:unsupported) the agent serves no restart file, so it cannot restart this node. (:no-control-session) this node holds no session with a host agent: a development image, a node run in the foreground, or a boot that asked the host agent for no port. (:session-failed REASON) the session ended before the request was sent. (:failed REASON) an answer came that could not be read.
Never signals, and never waits longer than the session's reply deadline for any one answer, so an agent that does not answer cannot hold the caller. A session that ends under the request, or answers something unreadable, is given up: the node holds no session afterwards, and a later declaration or restart says so at once. The request takes the lock every declaration on this session takes, so it cannot interleave its frames with one.
wake-host-declaration-channel
(wake-host-declaration-channel &key (wait-seconds 1))
Stop holding the control session as the node begins to stop, and wake any request still waiting on the host agent's answer. Call it before the owner's sessions are stopped. I added it because a host agent going down can fall silent without closing the session, and the owner who asked through it would otherwise have the session severed under them and never hear an answer. A request still holding the session after WAIT-SECONDS and waiting to read the agent's answer, which is where a silent agent leaves one, returns at once with the outcome a session ending under it already gives: after the restart was written, (:probably-accepted). A request blocked writing to an agent that has stopped reading is not woken here. From here on nothing new goes out on the session and a later request reports :no-control-session. The session is closed later, by release-host-declaration-channel. Never signals, and does nothing on a node that holds no session.
Variables
*host-declaration-channel*
The live control session to the host agent, or NIL when this node holds none.
The boot push sets it and both teardown paths clear it, so a declaration made while the node runs goes out on the session the boot declaration went out on rather than dialing a second one. A development boot with no control socket holds none, and a declaration made on such a node changes the node and reaches nobody, which is a reportable outcome rather than a fault.
*rwrite-read-deadline-seconds*
The bounded ceiling declare-ports-and-push-fd waits for the /ports Rwrite after the descriptor is sent, defence-in-depth against a receiver that never answers (a hung serve thread or a broken peer), NOT the primary transfer guard. The descriptor transfer is race-free at the wire: the receiver reads with seven's exact-count, control-armed reader, which cannot over-read the carrier, so the carrier and its SCMRIGHTS ancillary wait queued until the receiver pulls them. A healthy push therefore completes in well under a second, and this ceiling only ever fires when the peer stops responding. iolib puts the carrier fd in non-blocking mode and refills through a poll with no timeout, so a socket read-timeout option does not reach that read; %read-rwrite-bounded shuts the read half down after this deadline instead, turning a silent peer into a fast wire-error rather than a permanent hang at boot.
Package valis/src/edge/controller
Functions
attach-budget-source
(attach-budget-source executor source mux)
Wire the freshly-built listening SOURCE (which already carries EXECUTOR) and its MUX back into EXECUTOR, so the accept path can deregister SOURCE at cap and the exit path re-register it a slot later. Must be called after the source is built (the source does not exist when the executor is constructed) and before the source is registered. Returns SOURCE.
attach-edge-adapter
(attach-edge-adapter name &key (mux (and *listener* (listener-mux *listener*))) port budget)
Put the registered adapter named NAME in front of the running edge, beside every port already bound, and tell the host agent the widened set. Returns the ports bound. PORT and BUDGET stand in for the adapter's own, as start-edge's OVERRIDES do; with neither, the adapter is built exactly as start-edge would build it. MUX defaults to the running listener's multiplexer.
This is how a protocol module installed into a running node starts answering without a restart. Only the new adapter's ports are touched: no port already bound is closed, rebound or re-registered, so a peer connected to another port never notices the attach.
I refuse, by name and before anything is bound, an adapter that is not registered, an edge with no listener or no fabric behind it, an adapter that already has ports bound, and a port another adapter already holds. A second attach is a mistake to report, never a reason to rebind a port somebody may be connected to. A failure part way through releases everything this call put in place, so no port answers without an entry recording it and no entry names a port nothing answers on. Every refusal is an EDGE-ADAPTER-ATTACH-REFUSED.
The declaration follows the bind, as it does in start-edge, because the host agent admits an announced port on the public wire: a port is announced only once it answers, and a refused attach announces nothing.
budget-executor-live-count
(budget-executor-live-count executor)
The number of connections currently inside their per-connection lifetime under EXECUTOR's budget. The accept path increments it and every exit path decrements it under one unwind-protect, so a drain can poll this to wait for in-flight connections to quiesce without matching worker thread names against a port recovered elsewhere.
edge-adapter-names-bound-by
(edge-adapter-names-bound-by owner)
The adapter names under which an adapter of OWNER has ports bound, each once, whatever the registry holds under those names now. A module taken out of service retires these as well as the names it holds in the registry, so no port it bound outlives it.
edge-adapter-names-held-by
(edge-adapter-names-held-by owner)
Every adapter name OWNER holds in the registry or has ports bound under, each once. Taking a module out of service, by retiring it or deleting it, releases all of these, so neither an adapter it registered from its code nor a port it bound under a name another owner took is left behind.
edge-bound-ports
(edge-bound-ports)
Return the list of currently bound edge ports, ascending. A read-only view of the controller registry for the status axis and tests; never the live table.
edge-entry-port
(edge-entry-port instance)
Undocumented: this exported symbol needs a docstring.
edge-entry-protocol
(edge-entry-protocol instance)
Undocumented: this exported symbol needs a docstring.
edge-entry-source
(edge-entry-source instance)
Undocumented: this exported symbol needs a docstring.
edge-port-stats
(edge-port-stats port)
Return the PORT-STATS for the bound edge PORT, or NIL when PORT is not bound. The read entry point the status axis renders one port's budget counters from; never hands out the controller's live table.
edge-port-stats-list
(edge-port-stats-list)
Return the PORT-STATS of every bound edge port, ascending by port. A read-only snapshot for the status axis and tests.
edge-ports-sharing-budget
(edge-ports-sharing-budget port)
The OTHER bound edge ports counting against the same budget as PORT, ascending, or NIL when PORT's budget is its own and when PORT is not bound.
Ports sharing one descriptor pass their connections through one accept queue, one set of live counters and one budget, so /edge/<port>/budget for either of them is the same number and means the pair rather than the port. A counter labelled per-port that is really shared misattributes load: read on its own it says one service is under pressure when the pressure may belong entirely to the other. This is the read that makes the label honest, and it is a read of the live registry rather than a fact recorded at bind time, so it tells the truth after a retire as well.
edge-running-p
(edge-running-p)
Return T when the edge controller has at least one port bound.
held-steered-source
(held-steered-source fd)
The source a boot holds over the descriptor FD, or NIL when it holds none.
hold-steered-source
(hold-steered-source fd &key (mux (listener-mux *listener*)) (budget *default-edge-budget*) (session-ceiling *default-edge-session-ceiling*))
Start accepting on FD, the LISTEN descriptor a resident boot pushed to the host agent, register the source that does it on MUX, and hold that source until release-steered-source lets it go. Returns the source; asking again for an FD already held returns the source already held.
Every connection the source accepts goes to whichever protocol claims the port it was dialed on, so a port that arrives over the steer is answered whether or not an adapter declared it, and a connection to a port nobody claims is closed rather than left waiting in the backlog. start-edge serves a declared port over this same source, so those ports share its accept queue and its counters, held to BUDGET and SESSION-CEILING, which default to what start-edge gives a port of its own.
make-edge-budget-executor
(make-edge-budget-executor &key (budget *default-edge-budget*) session-ceiling)
Build a budget-executor over a fresh set of port counters carrying BUDGET (default default-edge-budget, 64) and SESSION-CEILING. The returned executor caps concurrent per-connection lifetimes at BUDGET exactly as every bound edge port does: at cap it deregisters its listening source so further connections wait in the kernel backlog, and it re-registers the source as slots free. The caller builds the listening source carrying this executor, then calls attach-budget-source to wire the source and mux back in so the cap can act. This is the reuse seam the :53 serve wiring gates its steered TCP source through, so that stranger-facing path gets the same proven concurrency bound and drain counter as the edge ports.
A caller wiring its own source names its own bound on live sessions with SESSION-CEILING. It defaults to NIL, which leaves the port with no session bound of its own: only the request budget speaks for it, and whether a session may be adopted at all is then decided by the adoption entry point's own clause. That is the shipped state of every port wired through this seam.
make-steered-edge-datagram-source
(make-steered-edge-datagram-source port executor &key fd)
Build an edge datagram source over FD, a UDP socket already bound, rather than by binding PORT: the datagram mirror of make-steered-edge-stream-source. The steer carries TCP alone, so UDP is the one transport that needs a socket bound to the port it serves. This process holds no privilege to bind a port below 1024, and the host agent does, so the host agent binds the UDP socket. It reaches the node one of two ways: inherited at launch as the UDP half of the DNS pair, or sent by the host agent over the control session when 53 is in the served set and the node holds no UDP socket for it. Either way this wraps it with no second bind.
PORT is not used to construct the source: the descriptor already carries its bound port, which the serve wiring reads back off the source. PORT stays the registry key the caller threads through. The fd->source wrap lives in the epoll backend so this file names no iolib symbol. DNS is deliberately not an edge-adapters entry, so start-edge never constructs this: the DNS serve wiring invokes it directly.
make-steered-edge-stream-source
(make-steered-edge-stream-source port executor &key fd)
Build an edge stream source over FD, a descriptor already in LISTEN state, rather than by binding PORT. A resident boot holds such descriptors two ways: the one socket it minted on an ephemeral port and pushed to the host agent, which steers every TCP connection in the node's namespace to it, and a listener the launch handed down by inheritance (the :443 edge or TCP :53). Either way valis binds no served port of its own. This is the steered alternative for edge-source-constructor: rebind that variable to a closure over such a descriptor and start-edge accepts on it.
PORT is not used to construct the source: the descriptor already carries its LISTEN state, and the port each client dialed is recovered per accepted connection, not from PORT. PORT stays the registry key the rest of start-edge threads through, so the recovered protocol still dispatches by the port the controller was asked to serve. The fd->source wrap lives in the epoll backend so this file names no iolib symbol.
port-stats-accepted-total
(port-stats-accepted-total stats)
Return the total accepts ever admitted on this port's STATS.
port-stats-budget
(port-stats-budget stats)
Return the fixed concurrent-connection budget for this port's STATS.
port-stats-deadline-cuts
(port-stats-deadline-cuts stats)
Return the count of read-deadline-cut exits on this port's STATS.
port-stats-live-count
(port-stats-live-count stats)
Return the current live-connection count on this port's STATS.
port-stats-port
(port-stats-port stats)
Return the integer port this STATS belongs to.
port-stats-session-ceiling
(port-stats-session-ceiling stats)
Return the fixed live-session ceiling for this port's STATS, or NIL when the port enforces no session bound of its own.
port-stats-session-count
(port-stats-session-count stats)
Return the number of loop-driven sessions live on this port's STATS.
release-steered-source
(release-steered-source fd)
Stop accepting on the descriptor FD and let go of the source a boot held over it, ending every session that source still carries. Does nothing when no source is held over FD, so a teardown may call it whether or not the boot got as far as holding one.
Never signals. It runs on the way out of a boot, where one release that fails must not stop the rest of the teardown, so each step that fails is logged and the others still run. The source accepts on a duplicate of FD, which this closes; FD itself stays the boot's to close.
retire-edge-adapter
(retire-edge-adapter name &key (mux (and *listener* (listener-mux *listener*))) (drain-timeout *edge-drain-timeout-seconds*) (owner nil owner-p))
Retire the live edge ports of the adapter named NAME: drain their in-flight connections, tear them down in start-edge's reverse order, and remove the adapter from the edge-adapters registry so a later start-edge does not bind it again. The ordered teardown per matching port is deregister-source -> drain (bounded) -> close-source -> unregister-protocol -> remhash the edge-entry. A port belongs to NAME when its edge-entry's ADAPTER-NAME matches NAME (compared by STRING / case-insensitively, since an adapter may name itself :http or "http"). The match is on the adapter name the entry recorded at start-edge, NOT on the protocol's PROTOCOL-NAME - a non-resident adapter whose protocol names itself differently from its registry key must still have its ports retired. MUX defaults to the running listener's multiplexer. This is the primitive the delete-module hook calls so deleting a protocol module retires its live ports before the module's packages unbind.
Given OWNER, only the ports an adapter of OWNER bound under NAME are retired, and NAME leaves the registry only while OWNER holds it there. Any admission of the same module counts as OWNER, as same-registration-owner-p says. A module taken out of service retires its ports this way, so a name another owner registered after the module bound its ports neither keeps those ports answering nor loses its own registration. When OWNER holds NAME but ports under it were bound by another owner, those ports go on answering and NAME goes back to the adapter that bound them, so nothing answers under a name with no adapter behind it.
Returns the list of ports retired. Signals RETIREMENT-INCOMPLETE when any release did not happen - a port still bound after a successful-looking retire is the outcome this refuses to produce.
Every port is attempted before anything is reported, so one wedged port cannot strand the others. A port whose release failed is NOT counted as retired and KEEPS its edge-entry: the controller's record of what is bound stays true, the port still shows in EDGE-BOUND-PORTS, and a second retire picks it up again.
A DESCRIPTOR A SURVIVING PORT IS STILL ACCEPTING ON IS NOT CLOSED. A steered boot puts every port it declared on one descriptor, so retiring one adapter can reach a descriptor another adapter's port is still serving. Closing it would take that port down without anything saying so, and would close a number the process may reopen. The retired port's protocol is unregistered either way, so nothing dispatches on it any more; the descriptor goes when the last port using it does. The steered socket a resident boot holds never goes this way, since it answers for every port the host agent steers at the node; retiring the last adapter on it leaves it accepting for the others.
The ports that were released leave the set this node says it serves, and the narrower set goes to the host agent, so deleting a protocol module closes its ports at the agent as well as here. The teardown runs first and the declaration follows it, the same order the bind uses in the other direction.
start-edge
(start-edge &key http-port gopher-port http-budget gopher-budget overrides (mux (listener-mux *listener*)))
Bind the registered adapter protocols' loopback ports over the running listener's multiplexer, fail-closed. The adapter set comes from the edge-adapters registry: start-edge iterates (edge-adapters) and binds every port each registered protocol declares: it names no concrete adapter, so a protocol installs simply by being loaded. MUX defaults to the running listener's multiplexer: the edge registers its sources on the already-running event loop rather than owning its own listener.
Per-adapter overrides: OVERRIDES is the real path, a generic alist mapping an adapter name to a (:port p :budget b) plist; a spec with no override binds its own default port and default-edge-budget. HTTP-PORT / GOPHER-PORT / HTTP-BUDGET / GOPHER-BUDGET are a TRANSITIONAL COMPAT SHIM that maps onto the two resident adapters by name ("http" / "gopher") so the existing call-sites keep working unchanged; remove this surface when HTTP/Gopher relocate out of valis and the only override path is OVERRIDES.
An adapter left to its own port may name one this process cannot bind. Where that port is also one this node declared to nobody, so no pushed descriptor answers for it, the adapter comes up on the loopback port development-loopback-ports names for it rather than taking the whole boot down with it. A declared port is untouched by this, because it is served over the descriptor and never bound here; so is a port this process can bind, whatever its number; and so is a port that is merely busy, which still fails at the bind where the conflict can be seen. %adapter-protocol is where that is decided.
Sequence (no namespace → no port): first the pre-bind gate runs a REAL attach+walk of the anonymous public view through the seam; a grant that does not resolve aborts before any port binds. Then, per registered spec: construct the protocol via its (&key port budget) constructor (passing the resolved override), register the protocol with the port→protocol registry, and for EACH port the constructed protocol declares, build a budget-executor carrying that port's live counters, construct the source through edge-source-constructor carrying the executor, wire the source and mux back into the executor, and register the source on MUX.
SEVERAL PORTS MAY SHARE ONE SOURCE, and everything downstream of the source follows the source rather than the port. A steered boot hands one descriptor to every port it declared, so the constructor answers the second and later of those ports with the source it already built. A source is therefore registered ONCE, and the ports sharing it read the counters built with it: a later port's freshly-made counters are dropped and its entry wired to the first's. That is the honest accounting, because those ports genuinely pass their connections through one accept queue and one budget; /edge/<port>/shares-budget-with names who a port is sharing with so the number can be read for what it is.
A source a resident boot holds (hold-steered-source) is already registered and already counting when the constructor hands it over, so a declared port served over it reads the boot's counters, and the edge never registers or releases it: the socket keeps answering for the rest of the node after the last adapter on it is gone.
Wiring a port's entry to counters of its own instead is the failure worth naming, because it looks correct from every angle. Only the first port's budget-executor ever reaches the shared source, so the second port's counters never move: /edge reports it idle while it is carrying load, and the budget it is really bounded by is reported under a different number.
The port each shared connection was dialed on is still recovered per accepted client, so dispatch still lands on the protocol that declared that port. Sharing a source is not sharing a protocol.
dup(2) on the descriptor, once per declared port, is REJECTED rather than overlooked, and nothing will stop it: the wrap already duplicates, so two sources over one descriptor carry two different numbers and the multiplexer, which keys its table by number, sees no collision to refuse. Duplicates share one open file description and therefore one accept queue, so which of them accepts a given connection is decided by the kernel and not by the port the client dialed. Dispatch would still land correctly, since it reads the recovered port, but a connection to one port could be accepted and budgeted by another port's source, and every per-port counter would then mean something other than its name, unpredictably and with nothing signalled.
Each protocol is tracked for unwind the moment it is registered, each source the moment it is built, and each bound port the moment its entry is created, so a failure ANYWHERE in the sequence (a source constructor that signals, a port already claimed, an N-th adapter beyond the resident two) unwinds exactly what was put in place, in reverse: deregister and close every source, unregister every protocol, and remhash precisely the ports that were tracked (never a fixed two-port literal). Returns the list of bound ports on success.
A successful bind ends by folding those ports into the set this node says it serves and declaring the whole set to the host agent, so loading a protocol module is what puts its ports in front of the agent that admits them. The declaration follows the bind and never precedes it: the agent admits what this node reports, and a port admitted with nothing listening on it is the outcome that order exists to prevent. A node holding no control session declares to nobody and comes up unchanged.
stop-edge
(stop-edge &key (mux (and *listener* (listener-mux *listener*))))
Close every bound edge port and unwire the edge, in reverse of start order (ports close before adapter/seam/fabric teardown). Idempotent and safe to call when the edge was never started: an empty registry is a no-op. Each step is wrapped so one failure cannot block the rest: per entry, deregister and close the source (the wire stops feeding the seam first), then unregister the protocol; then clear the controller registry; then reset the edge seam so the cached anonymous session closes AFTER the wire has stopped feeding it. MUX defaults to the running listener's multiplexer, or nil when no listener is up (the deregister steps tolerate a nil mux by skipping).
Every port goes, so nothing is retained; what is tracked is what has already been released, so a descriptor several declared ports share is closed exactly once no matter how many of those ports are in the registry.
NOTHING IS DECLARED HERE, and the asymmetry with retire-edge-adapter is deliberate. Retiring one adapter revokes it while the node keeps running, so the host agent has to hear the narrower set. Stopping the edge is the node going down, and the set it serves is what it will serve again on the next boot, which it re-declares then. Withdrawing it on the way out would shut the ports at the agent for the length of a restart and buy nothing, since the process is leaving either way.
Variables
*default-edge-budget*
Default per-port concurrent-connection budget. Each bound edge port may have at most this many connections live at once; the budget-th accept deregisters the port's listening source so further connections wait in the kernel listen backlog rather than spending a valis thread (never accept-then-close). 64 sits far above any legitimate loopback load yet bounds the live SBCL threads a single flooded port can spawn. start-edge takes :http-budget / :gopher-budget to override per port; a flood on one protocol cannot starve another because each port counts independently.
*default-edge-session-ceiling*
How many live loop-driven sessions one edge port admits when it is given no figure of its own. A port takes this number at start-edge and holds its own copy, so two ports never spend one allowance and a flood on one protocol cannot close another.
PROVISIONAL. Nothing has profiled this system, and a measured figure replaces this one.
What sizes it is memory a port is willing to hold on behalf of peers that are doing nothing: the resident cost of one idle session, multiplied by this number. That cost is dominated by the session's own read buffer of 4096 octets, so this many of them hold about 1.6 MiB of buffer per port, and something under 3 MiB once the session object, its send queue and its clocks are counted alongside.
It respects a second bound. One process shares its descriptors between every bound port, its fabric, and whatever it dials outbound, and this figure stays well clear of that shared supply. A flood of sessions therefore meets this bound first: reaching it closes one port and leaves the rest of the process serving, where running out of descriptors takes everything down at once.
⛔ It is deliberately unrelated to the request budget beside it, and neither was derived from the other. That one is an abuse bound on work in flight: how much the port will have underway at once. This one is a density decision about what is resident while nothing at all is happening. Sizing either from the other would be a coincidence dressed up as a reason.
*edge-source-constructor*
A function of (PORT EXECUTOR) returning a stream event source bound to PORT. This is the single point where a port valis holds no descriptor for becomes an event source, and the whole tree's development self-bind: start-edge consults it, and so do the :443 and :25 serve loops through their MAKE-BOUND-SOURCE arguments, so none of them restates the question for itself. The resident boot rebinds it to a constructor that hands back the descriptor a declared port arrived on and falls through to this one for every other port, with no call site needing to know which it got. The default-multiplexer-factory discipline applied to the edge.
Package valis/src/edge/delegation-check
Classes
delegated-nameserver
One nameserver a zone is delegated to. NAME is its domain name. ADDRESSES are the addresses a glue record carries for it, which is a list because a nameserver may hold both an IPv4 and an IPv6 address, and empty for a nameserver outside the zone, which needs no glue and must not be given any.
delegation-report
One delegation probe's whole result.
VERDICT is a member of DELEGATION-VERDICTS. EXPECTED is the intended nameserver set and FOUND the set the parent nameservers agreed on, both DELEGATED-NAMESERVER lists; FOUND is NIL when they did not agree, in which case the per-nameserver sets in FINDINGS carry the disagreement. PARENT-ZONE names the zone whose nameservers were asked. FINDINGS holds one PROBE-ATTEMPT per parent nameserver, so a report says which nameserver a reading belongs to and why any of them went unread. DESCENT holds the attempts the walk down made, kept because a walk that stopped early is diagnosed from them. BOUNDED-OUT is true when the deadline stopped the walk.
intended-delegation
What a domain's delegation should be: the ORIGIN and the NAMESERVERS it should be delegated to, each a DELEGATED-NAMESERVER.
This is the whole of the seam a registrar driver would arrive behind. A driver reading a registrar's API produces this value; the record template produces it from its own defaults; both then reach the same comparison, and neither knows about the other.
probe-attempt
One nameserver asked once, and what it said.
READING is a member of PARENT-READINGS. OUTCOME and RCODE are runciter's own reading of the same response, kept alongside rather than replaced, so a report can name the REFUSED behind an unreachable-looking verdict. NAMESERVERS is the delegated set read off the answer. REFERRAL-ZONE and REFERRAL-SERVERS carry the step the walk takes next. CAUSE holds the condition when the nameserver could not be asked.
Conditions
delegation-transport-failure
Signalled by the carrying half of a probe when a nameserver could not be asked: the dial was refused or failed, the answer did not arrive in time, the connection broke, the response was malformed, or it did not bind to the query. CAUSE holds the underlying condition, never a rendered string, so a caller can still discriminate.
Every one of those is a fact about reaching the nameserver, and the probe maps all of them to the UNREACHABLE verdict and never to PENDING. That mapping is the whole reason this is a single condition type: a reading that cannot be trusted must not be able to leak into a reading about the delegation's contents.
unaddressable-nameserver
Signalled when a nameserver value reaches the dialling step carrying nothing to dial. Deliberately NOT a transport failure: every referral names its nameservers and every root hint carries an address, so this can only arrive from a caller that constructed the value wrongly, and mapping it to UNREACHABLE would report a defect here as a fact about somebody's network.
Generic functions
delegation-transport-failure-cause
(delegation-transport-failure-cause condition)
Undocumented: this exported symbol needs a docstring.
delegation-transport-failure-nameserver
(delegation-transport-failure-nameserver condition)
Undocumented: this exported symbol needs a docstring.
unaddressable-nameserver-server
(unaddressable-nameserver-server condition)
Undocumented: this exported symbol needs a docstring.
Functions
delegated-nameserver-addresses
(delegated-nameserver-addresses instance)
Undocumented: this exported symbol needs a docstring.
delegated-nameserver-name
(delegated-nameserver-name instance)
Undocumented: this exported symbol needs a docstring.
delegated-nameserver-p
(delegated-nameserver-p object)
Undocumented: this exported symbol needs a docstring.
delegation-exit-code
(delegation-exit-code report)
The process exit code REPORT amounts to: 0 settled, 1 for a delegation that is not yet what it should be, and 2 for a probe that could not establish anything.
Non-zero on anything but settled, so the check works as a gate in a script rather than as something read by eye. The two failing codes are distinct because they call for different next steps: 1 says look at the registrar, 2 says look at the network.
delegation-report-bounded-out
(delegation-report-bounded-out instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-descent
(delegation-report-descent instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-expected
(delegation-report-expected instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-findings
(delegation-report-findings instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-found
(delegation-report-found instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-origin
(delegation-report-origin instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-p
(delegation-report-p object)
Undocumented: this exported symbol needs a docstring.
delegation-report-parent-zone
(delegation-report-parent-zone instance)
Undocumented: this exported symbol needs a docstring.
delegation-report-verdict
(delegation-report-verdict instance)
Undocumented: this exported symbol needs a docstring.
delegation-settled-p
(delegation-settled-p report)
True when REPORT's verdict is :SETTLED.
intended-delegation-for-origin
(intended-delegation-for-origin origin instance-address &key (primary-ns-label *default-primary-ns-label*) (secondary-ns (resolve-secondary-ns)))
The intended delegation for ORIGIN under the record template's nameserver policy: the in-zone primary, whose glue is this node's INSTANCE-ADDRESS, and the shared out-of-zone secondary, which carries none.
The primary's label is read from the template module rather than restated, so a ruling that moves it moves what this probe expects at the same moment. SECONDARY-NS comes from the operator's configuration, and is NIL on a node that runs no secondary: the delegation then names the primary alone, which is what the record template mints for that node.
intended-delegation-from-names
(intended-delegation-from-names origin names &key glue-address)
The intended delegation for ORIGIN naming NAMES, with GLUE-ADDRESS attached to every name that falls inside the zone.
The glue attaches by position in the tree rather than by the caller saying which name needs it, because that is the rule a registrar applies and getting it wrong is the failure the whole check exists to catch. For an operator naming the set by hand, and for a registrar driver reading it back from an API, which is the other half of the same seam.
intended-delegation-nameservers
(intended-delegation-nameservers instance)
Undocumented: this exported symbol needs a docstring.
intended-delegation-origin
(intended-delegation-origin instance)
Undocumented: this exported symbol needs a docstring.
intended-delegation-p
(intended-delegation-p object)
Undocumented: this exported symbol needs a docstring.
make-delegated-nameserver
(make-delegated-nameserver &key ((:name name) nil) ((:addresses addresses) nil))
Undocumented: this exported symbol needs a docstring.
make-dial-exchange
(make-dial-exchange dial-capability &key (port *delegation-probe-port*) (wire (make-delegation-probe-wire)) (attempt-seconds *dial-deadline*))
The exchange closure a probe carries its bytes through: a function of (SERVER ORIGIN QTYPE DEADLINE) returning (values QUERY RESPONSE AUTHORITY ADDITIONAL), and signalling DELEGATION-TRANSPORT-FAILURE when the nameserver could not be asked.
DIAL-CAPABILITY is required and positional, mirroring the seam it reaches: there is no arity of this that puts a query on a wire without the authority to do so having been handed in.
The exchange runs over TCP rather than UDP. A referral for a busy parent zone, with a nameserver set and its glue, is exactly the answer that does not fit in a bare datagram, and a truncated referral would read as a SHORTER nameserver set: a wrong reading rather than a failed one. TCP costs a handshake per attempt, which a diagnostic run a handful of times can afford.
Each attempt is bounded twice: ATTEMPT-SECONDS bounds every read on the connection, defaulting to the same per-address budget the dial itself uses, and DEADLINE caps that so an attempt can never outlive the whole probe.
make-intended-delegation
(make-intended-delegation &key ((:origin origin) nil) ((:nameservers nameservers) nil))
Undocumented: this exported symbol needs a docstring.
make-probe-attempt
(make-probe-attempt &key ((:server server) nil) ((:reading reading) nil) ((:outcome outcome) nil) ((:rcode rcode) nil) ((:nameservers nameservers) nil) ((:referral-zone referral-zone) nil) ((:referral-servers referral-servers) nil) ((:cause cause) nil))
Undocumented: this exported symbol needs a docstring.
probe-attempt-cause
(probe-attempt-cause instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-nameservers
(probe-attempt-nameservers instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-outcome
(probe-attempt-outcome instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-p
(probe-attempt-p object)
Undocumented: this exported symbol needs a docstring.
probe-attempt-rcode
(probe-attempt-rcode instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-reading
(probe-attempt-reading instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-referral-servers
(probe-attempt-referral-servers instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-referral-zone
(probe-attempt-referral-zone instance)
Undocumented: this exported symbol needs a docstring.
probe-attempt-server
(probe-attempt-server instance)
Undocumented: this exported symbol needs a docstring.
probe-delegation-through-exchange
(probe-delegation-through-exchange intended exchange &key (deadline (+ (get-universal-time) *delegation-probe-deadline*)) (roots *root-hint-servers*))
Probe the parent delegation for INTENDED through EXCHANGE and return a DELEGATION-REPORT.
EXCHANGE carries the bytes: a function of (SERVER ORIGIN QTYPE DEADLINE) returning (values QUERY RESPONSE AUTHORITY ADDITIONAL) and signalling DELEGATION-TRANSPORT-FAILURE when a nameserver could not be asked. Injecting it is what lets the comparison, the referral reading and the whole verdict vocabulary be exercised against constructed responses with no network at all.
DEADLINE is an absolute universal time and the probe is bounded by it: a deadline that has already passed produces a terminal verdict without issuing one query, and one that passes mid-walk stops the walk and returns the verdict the readings already support.
Every parent nameserver is asked, not just the first that answers, because nameservers disagreeing with each other is itself a distinct and expected reading during propagation.
probe-parent-delegation
(probe-parent-delegation intended dial-capability &key (port *delegation-probe-port*) (deadline (+ (get-universal-time) *delegation-probe-deadline*)) (roots *root-hint-servers*))
Probe the parent delegation for INTENDED, carrying the queries out through DIAL-CAPABILITY, and return a DELEGATION-REPORT.
DIAL-CAPABILITY is required and positional. It is the whole of valis's part here: the queries are runciter's and the reading of each answer is runciter's, and what this node contributes is the authority to leave it, the clock, and the walk.
registrar-instructions
(registrar-instructions intended)
The registrar block for INTENDED, as text.
This is what the operator copies from, and a wrong paste is the failure the whole verification exists to catch, so it is unambiguous about the one thing a registrar form gets wrong: which nameserver names need a glue (host) record and which must not be given one. It is deliberately a numbered list rather than a table, because a table's columns are what get lost on the way to a web form.
report-delegation
(report-delegation report &optional (stream *standard-output*))
Write REPORT to STREAM: the verdict, what was expected, what was found, and one line per parent nameserver saying what it said and, when it said nothing, why. Returns the verdict.
The per-nameserver lines are not decoration. A verdict alone cannot distinguish a repoint that is landing from one that was pasted wrong, and the reason a nameserver went unread is the whole difference between waiting and investigating.
Variables
*delegation-probe-deadline*
Seconds a whole delegation probe is given before it returns the verdict it has. The probe is bounded by an ABSOLUTE deadline derived from this, and each attempt under it is bounded again by the per-address dial budget, so one slow nameserver costs one attempt rather than the walk.
*delegation-probe-port*
The port a delegation probe is sent to. A parameter so a test can aim a walk at a loopback receiver.
*delegation-verdicts*
The closed set of verdicts a delegation probe yields.
:SETTLED Every parent nameserver that answered names exactly the intended nameserver set. The repoint is in place and has propagated.
:PENDING The parent nameservers agree with each other and name something other than the intended set, the empty set included. Either the repoint has not been made yet, or it was made wrongly. The report carries both what was expected and what was found, because those two cases need opposite responses and only the operator can tell them apart.
:INCONSISTENT The parent nameservers disagree with each other. During propagation this is normal and expected, and it is NOT the same as :PENDING: a repoint that is landing looks like this, and a repoint that was pasted wrong does not.
:UNREACHABLE No parent nameserver could be asked at all, or the probe ran out of time before one answered. This says nothing whatever about the delegation. It is its own verdict precisely so a network fault can never be read as a delegation that has not propagated; the report carries the cause of each failure.
*parent-readings*
The closed set of per-nameserver readings a probe attempt yields.
:DELEGATES The nameserver named a nameserver set for the origin, whether in a referral or an authoritative answer.
:NO-DELEGATION It answered and names no delegation for the origin: the name does not exist there, or exists with no NS set. A genuine answer, and evidence the delegation is not in place.
:REFERRED It referred the walk further down rather than answering for the origin's parent, so it is not a parent nameserver. This is what every step of the descent produces.
:REFUSED REFUSED: a live nameserver declining the zone. Kept distinct because the operator action differs completely. It means this server does not serve that zone, which is the single most useful discrimination a bring-up gets, and it is why this probe decodes its own responses instead of reusing a path that folds REFUSED into a generic failure.
:SERVER-FAILURE It answered that it could not answer. Retryable, and no evidence about the delegation.
:UNUSABLE It answered something that is not evidence about this query: a malformed message, or an answer to a different question.
:UNREACHABLE It could not be asked: the dial failed, or it did not answer in time. The cause is preserved.
*root-hint-servers*
The root nameservers the walk starts from, as (NAME ADDRESS) pairs.
Held as addresses rather than names on purpose: a name would have to be resolved before the first query, and needing a working resolver in order to check a delegation is exactly the dependency this probe exists without. Held as a parameter so a test can point a whole walk at a receiver it controls.
These are the published root hints and they change rarely. When one moves the walk still works: it stops at the first root that answers, and every root answers the same referral.
Package valis/src/edge/delegation-probe-codec
Classes
probe-codec
Binds runciter's probe-codec provider protocol to c3po's resolver-direction DNS codec. Stateless: it holds no connection, no outstanding set and no clock, because the probe's caller owns all three.
Conditions
probe-response-unbound
Signalled when a decoded probe response carries a message id other than the outstanding query's. The response decoded cleanly and is simply about something else, so it is refused rather than read: a verdict drawn from another query's answer would be worse than no verdict. A caller maps this to the verdict it uses for a nameserver it could not get an answer from, never to one about the delegation's contents.
Generic functions
probe-response-unbound-expected
(probe-response-unbound-expected condition)
Undocumented: this exported symbol needs a docstring.
probe-response-unbound-received
(probe-response-unbound-received condition)
Undocumented: this exported symbol needs a docstring.
Functions
make-delegation-probe-wire
(make-delegation-probe-wire)
Construct runciter's delegation PROBE-WIRE over this codec provider. runciter runs refuse-before-act at construction, so a version skew or a missing operation is a refusal here rather than a probe framed against a half-understood seam.
make-probe-codec
(make-probe-codec)
Construct the probe-codec provider.
Package valis/src/edge/descriptor-admission
Conditions
descriptor-admission-unavailable
Signalled by assert-descriptor-admission when the pinned admission chokepoint symbol is not fbound. The fail-closed elevation guard: an absent chokepoint refuses the admission outright rather than binding a descriptor with no capability check.
descriptor-admission-version-mismatch
Signalled by assert-descriptor-admission when the declared descriptor-admission contract version is outside supported-descriptor-admission-versions. The gate refuses BEFORE any bind side effect, so a cross-repo seam-version skew never wires a steered descriptor to a handler.
Functions
assert-descriptor-admission
(assert-descriptor-admission declared-version)
The version-and-availability precondition that guards the
fulcrum-steered-descriptor -> adapter admission, run by a caller BEFORE any bind
mutation and refusing fail-closed. Signals DESCRIPTOR-ADMISSION-UNAVAILABLE when
the pinned admission chokepoint symbol (admit-module) is not fbound, a presence
check on the chokepoint, NOT a call to it; the caller must not proceed to a bind
when the chokepoint the steered-descriptor path depends on is absent. Signals
DESCRIPTOR-ADMISSION-VERSION-MISMATCH when DECLARED-VERSION is not in
supported-descriptor-admission-versions. Otherwise returns
descriptor-admission-contract-version. This is a PURE precondition: it opens no
socket, performs no bind, and does not itself admit a descriptor: it only
refuses or clears the path. The owner-vouched-hash admission (admit-module) runs
on the path where a descriptor is actually steered in; this precondition is the
gate upstream of that path.
Variables
*supported-descriptor-admission-versions*
The descriptor-admission contract versions assert-descriptor-admission admits. A declared version outside this set is refused fail-closed: a cross-repo seam-version skew is never silently bridged into a bind.
Constants
+descriptor-admission-contract-version+
The contract version of the fulcrum-steered-descriptor -> adapter admission this valis exposes. Distinct from the DNS adapter seam version: this one couples the steered-descriptor admission to its boot caller, that one couples valis to the out-of-tree wire engine.
Package valis/src/edge/descriptor-peer
Conditions
descriptor-admission-refused
Signalled when a descriptor crossing is refused fail-closed: the descriptor is not a LISTEN-state socket of the expected family. In every rejection path the descriptor is closed before the condition is raised, so a refused crossing never leaks a descriptor. This condition covers the family and socket-state axes; a contract-version skew keeps the existing descriptor-admission-version-mismatch / descriptor-admission-unavailable conditions the admission gate signals.
Generic functions
descriptor-admission-refused-reason
(descriptor-admission-refused-reason condition)
Undocumented: this exported symbol needs a docstring.
Functions
admit-received-fd
(admit-received-fd endpoint &key (expected-domain 2))
Receive one descriptor over the SCMRIGHTS seam on ENDPOINT (a seven AFLOCAL control-socket-endpoint, constructed :control t), gate it fail-closed against the versioned descriptor-admission contract and the kernel's own view of the descriptor, and return the validated descriptor. EXPECTED-DOMAIN is the address family the crossing must carry (default 2 = AFINET). The seam's receive-fd-over-endpoint reads only through the exact-count control reader, so a plain endpoint is refused up front; constructing ENDPOINT as a control endpoint is the caller's responsibility.
Refuses fail-closed on every axis, each path leaving nothing leaked: the admission gate refuses a contract-version skew or an absent chokepoint (its own conditions) BEFORE any descriptor is read; the seam itself refuses (and closes) a multi-descriptor or truncated message; and a descriptor that is not a LISTEN-state stream socket, or is of the wrong family, is closed here and signals DESCRIPTOR-ADMISSION-REFUSED with a structured log line. Returns the validated descriptor only when every gate passes; the caller owns closing it on teardown.
datagram-socket-fd-p
(datagram-socket-fd-p fd)
True when FD is a datagram socket of an internet family: SOTYPE is SOCKDGRAM and SODOMAIN is AFINET or AFINET6. This is the gate a UDP socket the host agent sends must pass before DNS answers on it. Returns NIL (never signals) for a descriptor that is not a socket, since the kernel's refusal to answer is itself the answer.
listening-socket-fd-p
(listening-socket-fd-p fd)
True when FD is a stream socket in the LISTEN state: SOTYPE is SOCKSTREAM and SOACCEPTCONN is set. This is the gate a crossed descriptor must pass before it is adopted: a connected or wrong-type descriptor is rejected. Returns NIL (never signals) for a descriptor that is not a socket, so the caller can fail closed uniformly.
send-admitted-fd
(send-admitted-fd endpoint fd &key (expected-domain 2))
Send valis's own LISTEN-state descriptor FD across ENDPOINT (a seven AFLOCAL socket-endpoint) via the SCMRIGHTS seam, gated fail-closed exactly as the receive path is. EXPECTED-DOMAIN is the address family the pushed descriptor must be (default 2 = AFINET).
The admission gate is asserted BEFORE the push (a contract-version skew or absent chokepoint refuses via its own conditions), and FD is validated to be a LISTEN-state stream socket of the declared family against the kernel's own view. A mismatch signals DESCRIPTOR-ADMISSION-REFUSED with a structured log line.
The caller owns FD on EVERY outcome and closes it itself: on a successful crossing FD is returned still open (the seam sends a caller-owned copy; the sender closes its own copy after the handoff), and on a refusal FD is left exactly as it was found: the send half never adopted anything, so it never closes the descriptor it was handed. This keeps ownership uniform, so a caller may wrap the call in (unwind-protect (send-admitted-fd ep fd) (close fd)) without a double close. The carrier ENDPOINT is never closed either.
socket-fd-domain
(socket-fd-domain fd)
Return FD's address family (SODOMAIN): 1 = AFUNIX, 2 = AFINET, 10 = AFINET6. Signals if FD is not a socket.
Package valis/src/edge/device-key
Conditions
device-key-absent
Signalled when nothing is at the device key's path. Nothing is created there and nothing is dialled. The report advises valis key generate, with –out PATH when PATH is not the default device key path at the time it is printed, so the advised command writes the key at the path that was named.
device-key-exists
Signalled when a file or a symbolic link is already at the device key's path. Nothing is written, replaced, or followed.
Generic functions
device-key-absent-path
(device-key-absent-path condition)
Undocumented: this exported symbol needs a docstring.
device-key-exists-path
(device-key-exists-path condition)
Undocumented: this exported symbol needs a docstring.
Functions
default-device-key-path
(default-device-key-path)
The default device key path, read from the environment at call time: $XDGCONFIGHOME/valis/device.key when XDGCONFIGHOME is an absolute path, or ~/.config/valis/device.key when it is unset, empty, or relative. A relative value is ignored, as the XDG Base Directory specification requires, so the path never depends on the working directory.
generate-device-key
(generate-device-key &key (path (default-device-key-path)))
Write a fresh device key to a new file at PATH at mode 0600, creating its directory at 0700 when absent. Returns two values: PATH as a native namestring and the key's Ed25519 did:key. Signals device-key-exists when a file or a link is at PATH; the seed is never returned or printed.
load-device-key
(load-device-key path)
The custody store for the device key at PATH. Signals device-key-absent when nothing is there and owner-keyfile-unsafe when custody refuses the file: a link, another account's file, or one open to its group or others; owner-keyfile-malformed, a kind of owner-keyfile-unsafe, when the file is not exactly the 32 octets of a seed. Never creates a key.
Package valis/src/edge/dns-controller
Functions
admit-and-bind-dns-module
(admit-and-bind-dns-module system root files publisher-vouch owner-credential source &key store loader (name +default-dns-adapter-name+))
Admit a DNS wire module through the two-signature chokepoint and, ONLY on admission, bind runciter's serving handler over SOURCE onto the seam under NAME. Admission is delegated whole to admit-module (content hash, the publisher's vouch rooted at an anchor filed in STORE, the owner's signature, revocation and fence): no parallel trust check; loading the admitted module is its own self-registration onto the seam. Returns (values spec nil) on success, or (values nil reason-string) on a refused admission, in which case the loader never ran and no adapter was bound (refuse-before-act). The registry is snapshotted before admission, so a bind that fails AFTER the module loaded rolls the whole registry back to its pre-admission state: the admitted module's own self-registration is undone too, leaving no orphan adapter behind from a failed admit-and-bind.
bind-dns-view
(bind-dns-view source &key (name +default-dns-adapter-name+) (verify (function %default-bind-verify)))
Bind runciter's DNS serving handler over SOURCE (a zone-data-source; in production a pg-zone-source) under NAME, returning the bound dns-adapter-spec. A SINGLE seam mutation: build the runciter handler over SOURCE (%build-runciter-dns- handler resolves runciter's serving builder late and fails closed when it is absent or the seam versions disagree) and register it under NAME. The registered handler is self-contained (it holds its own SOURCE and pulls its own refresh, reading no valis special) so there is no second view-offer mutation; this is a single-mutation bind.
The no-partial-bind discipline holds on that single mutation: PRIOR-SPEC is captured first, VERIFY runs LAST as a self-check, and on any signal in the sequence the unwind restores whatever adapter was registered under NAME before this bind (or unregisters when none was), so a failed bind never clobbers a pre-existing same-name adapter and leaves no partial state.
runciter's handler builds its per-origin index eagerly at construction by reading the source, so the build+register+verify runs inside an operator-state connection when the pool is armed and BARE otherwise: an in-memory / test bind with no DSN needs no connection. VERIFY defaults to %default-bind-verify and stays injectable so the unwind path is directly assertable with a forced fault.
register-serving-refresh-listener
(register-serving-refresh-listener &key (name +default-dns-adapter-name+))
Wire the resident serving path's post-commit refresh: register a zone-change listener that force-refreshes the LIVE serving handler bound under NAME whenever a zone commits. The handler is captured from the bound adapter spec (dns-adapter-spec-handler), the SAME funcallable object the serve loop dispatches through, so a rebuild is visible to the loop with no swap. The listener fires under fire-zone-change on the committing thread (connected, post-commit, outside the transaction), so the rebuild's pg-zone-source reads run with a live connection, landing the just-committed member in the serving index synchronously.
Refuses fail-closed when no handler is bound under NAME or runciter's REFRESH-DNS-SERVICE is unavailable: a serve loop with no refresh path would strand a just-committed record invisible to the wire. While wired, the lazy-pull interval is pushed far out (the connectionless serve threads can never satisfy a pull), so the commit-hook push is the sole refresh path; the prior interval is restored on unregister. Returns the registered listener spec.
unbind-dns-view
(unbind-dns-view &optional (name +default-dns-adapter-name+))
Retire the controller-bound DNS adapter, the symmetric teardown of bind-dns-view (mirroring stop-edge / retire-edge-adapter). Unregisters NAME from the seam; the runciter handler held the source itself, so there is no separate view special to clear. Idempotent: a second teardown is a no-op.
unregister-serving-refresh-listener
(unregister-serving-refresh-listener)
Retire the serving-refresh listener wired by register-serving-refresh-listener and restore the lazy-pull interval, so neither the listener nor the pushed-out interval outlives the serve loop. Idempotent: a second call is a no-op.
Variables
+default-dns-adapter-name+
The seam name the controller binds the view-resolving handler under when a caller names none. valis names no concrete wire engine; this is only the registry key the bind and its teardown agree on.
Package valis/src/edge/dns-serve
Classes
dns-serve-handle
A handle to a running :53 serve loop, returned by start-dns-serve and consumed by stop-dns-serve. NAME is the admitted DNS adapter name this loop serves under (the key it is recorded under in dns-serve-handles, so a name-keyed retire can find it); SPEC is the adapter spec it was wired over, whose owner is the module the loop belongs to whatever the registry holds under NAME later; PROTOCOL is the registered dns-serve-protocol; TCP-SOURCE and UDP-SOURCE are the two steered event sources; TCP-EXECUTOR is the budget-executor gating the TCP source's concurrency, held so teardown can drain its in-flight connections off its live counter; MUX is the multiplexer they are registered on; PORTS is the list of ports the protocol claims.
Conditions
c3po-serving-codec-unavailable
Signalled when c3po's serving codec is absent or its contract version skews from valis's. The fail-closed codec guard, asserted once at wire time so the hot path never reaches an unresolved codec.
runciter-serve-contract-version-mismatch
Signalled by %runciter-make-serve-exchange when valis's adapter contract version and runciter's serving contract version disagree. The fail-closed seam-version guard: a version skew across the repo boundary is refused, never silently bridged.
runciter-serve-exchange-unavailable
Signalled by %runciter-make-serve-exchange when runciter has not exported its serving-exchange constructor. The fail-closed seam guard: valis never answers DNS from its own composition, so an absent constructor refuses the exchange outright rather than fabricating a response.
Functions
dns-serve-handle-ports
(dns-serve-handle-ports instance)
Undocumented: this exported symbol needs a docstring.
dns-serve-live-ports
(dns-serve-live-ports)
Every port a live :53 serve loop claims, ascending, each once. A port here is answered: a query arriving on it reaches a serving exchange. Safe to call while another thread starts or stops a loop.
dns-serve-names-held-by
(dns-serve-names-held-by owner)
Every DNS adapter name OWNER holds in the registry or has a live serve loop under, each once. Taking a module out of service, by retiring it or deleting it, releases all of these, so neither an adapter it registered from its code nor a loop it wired under a name another owner took is left behind.
dns-serve-names-owned-by
(dns-serve-names-owned-by owner)
The names of the live serve loops wired over a DNS adapter of OWNER, whatever the registry holds under those names now. A module taken out of service retires these as well as the names it holds in the registry, so no serve loop it wired outlives it.
retire-dns-serve
(retire-dns-serve &key (name +default-dns-adapter-name+) (drain-timeout *dns-serve-drain-timeout-seconds*) (owner nil owner-p))
Retire the live :53 serve loop admitted under NAME - the teardown-on-revoke mirror of retire-edge-adapter. Deregister both steered sources, drain in-flight workers (bounded), close both sources, unregister the serve protocol (dropping the captured serving handler from the hot path), forget the handle, and unbind the DNS view (release the admitted handler). Revoking the DNS module's owner-vouched capability runs this: the serve loop, finding no admitted handler, fails closed structurally with no per-query gate on the hot path, and valis composes nothing on that path - UDP silent, TCP closes, never a fabricated REFUSED or answer. Idempotent - a NAME with no live handle still unbinds the view.
Given OWNER, the serve loop under NAME is retired only when an adapter of OWNER wired it, and the view is unbound only while OWNER holds NAME in the registry. Any admission of the same module counts as OWNER, as same-registration-owner-p says. A module taken out of service retires its loop this way, so a name another owner registered after the loop was wired neither keeps the loop answering nor loses its own registration. When OWNER holds NAME but the loop under it was wired by another owner, that loop goes on answering and NAME goes back to the adapter it was wired over, so nothing answers under a name with no adapter behind it.
Returns the list of retired ports, or NIL when no serve loop was wired under NAME. Signals RETIREMENT-INCOMPLETE when any release did not happen.
The view unbind is the LAST release in the revocation path and it is attempted even when the sources' teardown failed, so a wedged descriptor cannot keep the admitted handler alive. A serve loop whose teardown failed keeps its registry entry and its ports are not reported as retired: the handle still describes something live, and a second retire picks it up again.
start-dns-serve
(start-dns-serve &key udp-fd tcp-fd (name +default-dns-adapter-name+) (mux (and *listener* (listener-mux *listener*))) (make-exchange (function %runciter-make-serve-exchange)) (make-tcp-source (function make-steered-edge-stream-source)) (make-udp-source (function make-steered-edge-datagram-source)) zone-source claim-ports)
Wire the two steered :53 descriptors, a UDP datagram fd and a TCP stream fd, behind ONE admitted DNS handler and serve both over MUX. Returns a dns-serve-handle for teardown, or NIL when no DNS handler is admitted under NAME (fail closed: no handler => wire nothing).
TCP-FD may be NIL, for a node holding a UDP socket alone: its TCP queries reach it over the steered socket, so the loop serves UDP over UDP-FD and answers TCP on whatever the steer delivers for the ports it claims. CLAIM-PORTS are the ports such a connection arrives on, claimed for this loop alongside the ports the descriptors are bound to.
Serving here is over descriptors and nothing else: there is no self-bind and no port to fall back onto, because a UDP reply must source from the socket the client addressed.
The admitted handler is captured HERE, at wire time: the serving exchange is constructed ONCE over it (MAKE-EXCHANGE, defaulting to the runciter serving-exchange constructor resolved late) and closed over by the serve protocol, so the hot path holds the handler with no per-query admission consult (mirroring start-edge capturing its protocol). After a later teardown the protocol is unregistered and holds no handler.
The two transports take deliberately different concurrency paths against a stranger-facing port. TCP reuses the edge budget-executor: a connection-oriented port with a blocking read, it gets the proven per-port concurrency cap (at cap the listening source is deregistered and further connections wait in the kernel backlog) plus the live counter teardown drains on. UDP takes an inline executor: a datagram arrives already fully in hand, so its one-shot exchange runs on the mux thread with no per-datagram thread and no lifecycle. Neither transport can be driven into unbounded thread creation by a packet or connection flood.
The ports the serve protocol registers under, which are also the loop's teardown identity, come from the two descriptors themselves. A recovered port is exactly what a connection reports as its destination-port, so registering under it is what lets the registry route a query at all.
Fail-closed, no partial state: the c3po serving codec is version-checked before any source is built, each source is tracked the moment it is built, and the protocol is registered only once both sources exist, so a failure building the second source, recovering a port, or registering tears the first down: never one transport serving while the other failed. MAKE-EXCHANGE and the two source constructors are injectable so a test can drive the wiring against a stub exchange and force the second-source failure that proves the unwind.
The zone-data-source the TCP AXFR path streams off is read from the admitted adapter spec (the source the DNS view was bound over, in production a pg-zone-source) and captured on the serve protocol, so a query on an AXFR name reaches the same zone the ordinary exchange answers from. ZONE-SOURCE overrides that read so a test can inject a stub; NIL leaves an AXFR request failing closed rather than routing to the transfer path.
stop-dns-serve
(stop-dns-serve handle)
Tear down a :53 serve loop wired by start-dns-serve, symmetric to it and error-isolated: deregister both sources from the mux (stop feeding the seam first), close both sources, then unregister the serve protocol so the captured handler is dropped, and forget the handle. Each step tolerates an already-gone piece. Does NOT drain and does NOT unbind the DNS view: the boot path and the module-delete retire own the view's lifecycle. A NIL HANDLE is a no-op (nothing was wired).
Variables
*dns-serve-drain-timeout-seconds*
The bounded wall-clock ceiling retire-dns-serve waits for a port's in-flight :53 connection workers to quiesce after both steered sources are deregistered. A wedged worker cannot hang the teardown: once this many seconds elapse the retire proceeds regardless of any residual worker, so the drain is bounded, never an unbounded block. Mirrors the edge controller's edge-drain-timeout-seconds.
Package valis/src/edge/liveness
Classes
liveness-mixin
The clock state one session carries, mixed into the session class rather than held beside it so there is exactly one place to look for what a session costs and one place for the close path to withdraw.
Two timers are armed per session at the point liveness starts: the repeating idle timer and the one-shot registration timer. A third, the probe deadline, exists only while a probe is unanswered. Anything measuring what a session weighs should count from here.
liveness-policy
What one session's two clocks are set to.
⛔ The two are structurally different mechanisms, not two settings of one, and this is the thing a later reader will otherwise collapse. The REGISTRATION timeout runs from the moment the connection was established and is NEVER reset by activity. The IDLE timeout runs from the last activity and is paired with a probe the protocol supplies.
They answer different questions. The idle clock asks whether the peer is still there; the registration clock asks whether it has done what it connected to do. A peer that talks continuously without ever registering is exactly the case the registration clock exists to catch, and an idle clock can never catch it, because such a peer is never idle. Fold the registration timeout into the idle timeout and that peer holds a pre-registration slot for as long as it cares to keep typing.
Generic functions
liveness-cut
(liveness-cut session reason)
End SESSION for REASON. What ending means belongs to whoever owns the session and its descriptor; the clocks decide only when. REASON is :LIVENESS-TIMEOUT when a probe went unanswered and :REGISTRATION-TIMEOUT when a peer never registered.
liveness-idle-interval
(liveness-idle-interval object)
Undocumented: this exported symbol needs a docstring.
liveness-live-p
(liveness-live-p session)
Whether SESSION is still open. Every timer body asks before acting, because a session can end between a timer being scheduled and its body running and there is nothing left to cut.
liveness-mux
(liveness-mux session)
The multiplexer SESSION's clocks are armed on. Answering NIL arms nothing, which is what makes liveness something a session opts into rather than something every session pays for.
liveness-probe-deadline
(liveness-probe-deadline object)
Undocumented: this exported symbol needs a docstring.
liveness-protocol
(liveness-protocol session)
The protocol that supplies SESSION's probe. Answering NIL means there is nobody to ask, and is treated exactly as a protocol with no probe is.
liveness-registration-timeout
(liveness-registration-timeout object)
Undocumented: this exported symbol needs a docstring.
send-liveness-probe
(send-liveness-probe protocol session)
Ask the peer of SESSION to prove it is still there, in whatever PROTOCOL's own vocabulary that takes. Answer true when a probe was sent and NIL when this protocol has none.
⛔ A protocol that answers NIL has no probe, and a session with no probe gets NO IDLE CUT AT ALL. The idle clock withdraws itself rather than falling back to a plain deadline, and the reason has to be said out loud because the opposite is the tempting default: cutting a session that cannot be probed reinstates a wall-clock cut on exactly the connection that is idle by design, which is the mechanism this file exists to replace. Such a session ends when its peer goes away, when its send queue breaches, or when the edge tears its port down, and never on a clock.
The question is asked at the moment a probe is due rather than at adoption, because whether a protocol can probe may depend on how far the session has got: an exchange that has not yet completed registration may have no well-formed question to put.
The default answers NIL, so every protocol that says nothing keeps a session no clock will ever cut.
session-idle-timer
(session-idle-timer object)
Undocumented: this exported symbol needs a docstring.
session-last-activity
(session-last-activity object)
Undocumented: this exported symbol needs a docstring.
session-liveness-policy
(session-liveness-policy object)
Undocumented: this exported symbol needs a docstring.
session-probe-outstanding-p
(session-probe-outstanding-p object)
Undocumented: this exported symbol needs a docstring.
session-probe-timer
(session-probe-timer object)
Undocumented: this exported symbol needs a docstring.
session-registered-p
(session-registered-p object)
Undocumented: this exported symbol needs a docstring.
session-registration-timer
(session-registration-timer object)
Undocumented: this exported symbol needs a docstring.
Functions
make-liveness-policy
(make-liveness-policy &rest initargs &key idle-interval probe-deadline registration-timeout)
Build a policy. Every interval omitted is taken from its parameter, so the shipped figures are changed by binding those rather than by editing a default into this class.
note-liveness-activity
(note-liveness-activity session)
Something arrived on SESSION. Reset the idle clock and answer SESSION.
⛔ This never touches the registration clock. That clock measures from establishment, and a version of this that reset it too would let a peer stay unregistered for as long as it kept talking, which is the denial of service the two clocks are kept apart to prevent.
note-liveness-response
(note-liveness-response session)
The protocol recognised an answer to a probe. Withdraw the deadline, reset the idle clock, and answer SESSION.
The edge cannot recognise an answer itself, because what counts as one is the protocol's own vocabulary and the edge does not read the exchange it carries. A protocol for which any traffic at all proves the peer is there says so by calling this from its receive path; one for which only a particular reply counts calls it only then.
note-session-registered
(note-session-registered session)
The peer completed what the protocol calls registration. Withdraw the registration clock and answer SESSION.
It withdraws that clock and nothing else. A session that has registered is still subject to the idle clock, because having registered says nothing about whether the peer is still there.
start-liveness
(start-liveness session policy)
Arm SESSION's clocks from POLICY and answer SESSION. A NIL policy arms nothing at all.
Must be called on the loop thread, because scheduling mutates state the loop owns while it is dispatching. Both clocks start here, in one call, so a session's cost in timers is decided in one place and a close path has one call that undoes it.
stop-liveness
(stop-liveness session)
Withdraw every clock SESSION armed and answer SESSION. Idempotent, and safe to call on a session that armed none.
The close path calls this before it releases the descriptor, and the ordering is the whole reason it exists: a timer body that fired against a descriptor the close path had already released is the exact failure the request-shaped read-deadline watchdog carries a lock and a closing flag to prevent, and the same care is owed here.
Variables
*liveness-idle-interval*
Seconds of quiet on a session before its peer is asked to prove it is still there.
Provisional, and it says so because nothing has profiled this system yet. It is deliberately long: on a continuous protocol idleness is the normal state rather than a symptom, so the interval sets how quickly a peer that vanished without a final packet is noticed, and buys that promptness with one probe per session per interval. No figure here is carried over from another implementation. A number that suits a daemon serving one kind of traffic says nothing about this one, and a default that appears to be a convention often turns out to apply only where no configuration matched.
*liveness-probe-deadline*
Seconds a peer has to answer a probe before it is treated as gone.
Provisional. It has to clear a slow round trip and the peer's own scheduling by a wide margin, because the cost of being wrong is a live connection cut, while the cost of being generous is that a dead peer is noticed one deadline later than it might have been. That asymmetry is why this is the interval that gets the benefit of the doubt.
*liveness-registration-timeout*
Seconds from establishment within which a peer must complete whatever its protocol calls registration.
Provisional. It is deliberately far shorter than the idle interval, and the gap is the point rather than a coincidence: a session that has connected and not yet registered is holding a slot with nothing to show for it, and keeping the two figures well apart means an observed cut says which clock made it without anybody having to reconstruct the timing.
Package valis/src/edge/mail-serve
Classes
mail-serve-handle
A handle to a running :25 serve loop, returned by start-mail-serve and consumed by stop-mail-serve / retire-mail-serve. NAME is the admitted name; PROTOCOL is the serve protocol registered with the port->protocol registry; SOURCE is the event source registered on the mux; EXECUTOR is the budget-executor whose live counter the drain polls; MUX is the multiplexer SOURCE is registered on; PORTS is the list of ports recovered from the descriptor and registered under.
smtp-serve-protocol
The inbound :25 SMTP serve protocol both the inherited-fd and the loopback dev-fallback paths dispatch to. Its handle-connection drives one RFC 5321 session through c3po's engine, threading valis's store, fenced head, and the per-listener callout cache into the mail seam. Like tls-edge-protocol / dns-serve-protocol it is directly registered under the descriptor's recovered port rather than installed as an edge-adapters entry: the :25 leg is a stranger-facing serve loop, never a loopback edge port.
Generic functions
smtp-serve-protocol-callout-cache
(smtp-serve-protocol-callout-cache object)
Undocumented: this exported symbol needs a docstring.
smtp-serve-protocol-callout-probe
(smtp-serve-protocol-callout-probe object)
Undocumented: this exported symbol needs a docstring.
smtp-serve-protocol-head
(smtp-serve-protocol-head object)
Undocumented: this exported symbol needs a docstring.
smtp-serve-protocol-store
(smtp-serve-protocol-store object)
Undocumented: this exported symbol needs a docstring.
Functions
bind-mail-config
(bind-mail-config)
Bind the mail router / RCPT-disposition / MX-resolver specials from the operator-state config resolvers. Each resolver reads the node's configuration and fails closed to NIL/empty when unset, so an unconfigured boot leaves each seam at its fail-closed posture. The accept-list keeps its module default when the resolver names none, so an unset accept-list never widens acceptance to a catch-all. Returns no values.
mail-serve-handle-ports
(mail-serve-handle-ports instance)
Undocumented: this exported symbol needs a docstring.
reset-mail-config
(reset-mail-config)
Reset the mail router / RCPT-disposition / MX-resolver specials to their fail- closed defaults: no secondary authority, no configured DoT upstream (so the drain defers every relay), and the module's named-localpart accept-list. The reset twin of bind-mail-config, run on every fabric teardown path so the mail seams never outlive an activation. Returns no values.
retire-mail-serve
(retire-mail-serve &key (name +default-mail-edge-name+) (drain-timeout *mail-serve-drain-timeout-seconds*))
Retire the live :25 serve loop admitted under NAME: deregister the source, drain in-flight workers (bounded by DRAIN-TIMEOUT), close the source, unregister the serve protocol, and forget the handle. Each step is error-isolated. Idempotent: a NAME with no live handle is a no-op. Returns the list of retired ports, or NIL when no serve loop was wired under NAME.
start-mail-serve
(start-mail-serve &key tcp-fd tcp-port store head callout-probe (name +default-mail-edge-name+) (mux (and *listener* (listener-mux *listener*))) (make-tcp-source (function make-steered-edge-stream-source)) (make-bound-source *edge-source-constructor*))
Wire the inbound :25 SMTP edge, threading STORE and HEAD into c3po's engine, and serve it over MUX under NAME. Returns a mail-serve-handle for teardown, or NIL when STORE or HEAD is absent (store-gated open: no source is built and no protocol registered, so the :25 leg never opens with nowhere to land mail), and NIL again when the node has no descriptor for the port and no way to come up on one of its own. Neither refusal signals.
The edge is the structural sibling of start-tls-serve for ONE stream source, and it has no opinion about how its port reaches it. A descriptor in hand is adopted through MAKE-TCP-SOURCE with no second bind; with none, MAKE-BOUND-SOURCE is asked for a source, and that is the node's one development self-bind rather than a decision taken here (a TCP-PORT of 0 binds an ephemeral port). Either way the port the protocol registers under (and the loop's teardown identity) is recovered from the descriptor itself, not from TCP-PORT (which the boot path may leave nil even while handing over a live fd): the recovered port is exactly what a connection reports as its destination-port, so registering under it is what lets the registry route a session at all.
The single stream source reuses the edge budget-executor: a stranger-facing connection-oriented port gets the proven per-port concurrency cap (at cap the listening source is deregistered and further connections wait in the kernel backlog) plus the live-counter teardown drain. ONE make-callout-cache is constructed here and captured on the protocol, so every session run this listener drives shares one bounded-TTL callout cache.
Fail-closed, no partial state: both gates run before any source is built, the source is tracked the moment it is built, and the protocol is registered only once the source exists and its port is recovered, so a failure recovering the port or registering tears the source down rather than leaving a half-wired listener. The fd borrow/close discipline comes from make-steered-edge-stream-source and the teardown path: valis closes only the source it built (its own descriptor), never a descriptor it was handed. MAKE-TCP-SOURCE and MAKE-BOUND-SOURCE are both injectable, so a test can force a second-stage failure that proves the unwind, and a boot that supplies neither cannot open a socket of its own choosing at all.
stop-mail-serve
(stop-mail-serve handle)
Tear down a :25 serve loop wired by start-mail-serve, symmetric to it and error- isolated: deregister the source from the mux (stop feeding the seam first), close the source (valis's own descriptor, never the inherited fd), then unregister the serve protocol, and forget the handle. Each step tolerates an already-gone piece. Does NOT drain. A NIL HANDLE is a no-op (nothing was wired).
Variables
*mail-serve-handles*
Name -> MAIL-SERVE-HANDLE for the live :25 serve loops, keyed by the admitted name (STRING-normalized). start-mail-serve records its handle here on success; stop-mail-serve and retire-mail-serve read it back to tear a loop down by name.
+default-mail-edge-name+
The default name the :25 SMTP edge wires under and is keyed by in mail-serve-handles, so a name-keyed retire can reach the live loop.
Package valis/src/edge/node-pin
Conditions
node-pin-mismatch
The responder authenticated a static key other than the pinned node's. Signalled after the responder's handshake reply and before the client's final handshake message, so the client's static key and the attach never reach it.
node-pin-refused
The responder could not be shown to be the pinned node, so the session was refused.
node-pin-unusable
The pinned DID names no transport key valis compares: it is not an Ed25519 did:key in canonical spelling, an X25519 did:key included, or its Ed25519 key is one mercer refuses. Signalled before any connection opens.
owner-session-refused
The owner client refused before anything owner-bearing crossed the wire: no client static key, no attach, no request. Every such refusal is of this type.
responder-unproven
The responder's handshake reply failed to authenticate, so no responder static key is proven. Nothing is sent after the client's first handshake message.
Generic functions
node-pin-mismatch-presented-did
(node-pin-mismatch-presented-did condition)
The X25519 did:key of the static key the responder proved.
node-pin-refused-endpoint
(node-pin-refused-endpoint condition)
The endpoint dialled or about to be dialled, as host:port text, or NIL when none was resolved.
node-pin-refused-keyfile
(node-pin-refused-keyfile condition)
The key file the client presents, or NIL when not known at the refusal.
node-pin-refused-node
(node-pin-refused-node condition)
The pin name of the node expected, or NIL when the caller gave a DID directly.
node-pin-refused-pinned-did
(node-pin-refused-pinned-did condition)
The pinned node DID exactly as given.
node-pin-refused-pins-file
(node-pin-refused-pins-file condition)
The pin store the pin came from, or NIL when the caller gave a DID directly.
node-pin-unusable-problem
(node-pin-unusable-problem condition)
A keyword: :not-a-did-key when the DID is not an Ed25519 did:key in canonical spelling, or the problem mercer names for the Ed25519 key (:malformed, :non-canonical, :off-curve, :small-order, :not-in-prime-subgroup).
node-pin-unusable-reason
(node-pin-unusable-reason condition)
Text saying what was wrong with the pinned DID.
Functions
pinned-transport-key
(pinned-transport-key did)
The 32 X25519 octets a responder presents when it is the node DID names. DID is an Ed25519 did:key in canonical spelling, mapped through mercer. Signals node-pin-unusable for any other DID, an X25519 did:key included, and when mercer refuses its Ed25519 key.
responder-verifier
(responder-verifier expected-octets &key node endpoint pins-file keyfile pinned-did)
A function of one argument, the responder static a handshake proved or NIL, that returns when it equals EXPECTED-OCTETS. Signals responder-unproven on NIL and node-pin-mismatch on any other key. NODE, ENDPOINT, PINS-FILE, KEYFILE, and PINNED-DID are carried into the condition.
Package valis/src/edge/node-pin-store
Conditions
node-pin-argument-refused
A pin argument the store will not take. Nothing was written.
node-pin-endpoint-unusable
The pin's endpoint carries no port. A version 1 entry may hold one, as ubik records for a host it dials without a port. Its problem is :no-port. Signalled before any connection opens.
node-pin-required
The name given has no pin in the store. Signalled before any connection opens; nothing is dialled to learn what a responder would present.
node-pin-store-refused
The pin store could not be read or written safely: a link, another account's, writable by others, undecodable, unreadable or unwritable, or locked by another writer past the wait or already by the calling thread. Never treated as an empty store.
Generic functions
node-pin-argument-refused-argument
(node-pin-argument-refused-argument condition)
The argument refused, as given.
node-pin-argument-refused-reason
(node-pin-argument-refused-reason condition)
Why it was refused, as text.
node-pin-store-refused-cause
(node-pin-store-refused-cause condition)
The hekate node-pins-error that stopped the read or write.
node-pin-store-refused-pins-file
(node-pin-store-refused-pins-file condition)
The pin store pathname.
Functions
list-node-pins
(list-node-pins &key pins-file)
The node pins held in PINS-FILE, or the shared store, in store order. NIL when no store exists. Signals node-pin-store-refused for a store hekate will not read.
pin-node
(pin-node name endpoint did &key pins-file replace reserved-names lock-timeout)
Record NAME at ENDPOINT, a HOST:PORT, as the node DID names, in PINS-FILE or the shared store, and return the entry. An existing pin of NAME is replaced only when REPLACE is true. RESERVED-NAMES lists names refused outright. The store is read and written under its lock, so a change another writer makes under the same lock is never lost. LOCK-TIMEOUT is the most seconds to wait for the lock, NIL for hekate's default. Signals node-pin-argument-refused for a refused argument or an existing name without REPLACE, and node-pin-store-refused for a store hekate will not read or write, or whose lock another writer holds past the wait or this thread already holds; nothing is written in either case.
pinned-node
(pinned-node name &key pins-file)
The pin of NAME in PINS-FILE or the shared store, as four values: the host to dial, its port, the 32 X25519 octets the node presents, and the hekate node-pin entry. Signals node-pin-store-refused for a store hekate will not read, node-pin-required when NAME is not pinned, node-pin-endpoint-unusable for an endpoint with no port, and node-pin-unusable for a DID that is not an Ed25519 did:key valis takes. Reads only; never dials or writes.
pins-file
(pins-file &optional path)
PATH when given, else hekate's node-pins path.
unpin-node
(unpin-node name &key pins-file lock-timeout)
Remove the pin of NAME from PINS-FILE or the shared store, and return the entry removed. The store is read and written under its lock, so a change another writer makes under the same lock is never lost. LOCK-TIMEOUT is the most seconds to wait for the lock, NIL for hekate's default. Signals node-pin-argument-refused when NAME is not pinned, and node-pin-store-refused for a store hekate will not read or write, or whose lock another writer holds past the wait or this thread already holds; nothing is written in either case.
Package valis/src/edge/owner-client
Conditions
module-change-refused-by-node
Signalled when the node answers a request on /modules/install with an Rerror beginning module install refused: the request was refused before any of the module's code ran. The ename is the node's text. A subclass of nine-p-error: a handler for nine-p-error still catches it and can read the ename.
module-install-failed-after-loading
Signalled when the node answers a request on /modules/install with the Rerror beginning module install failed after loading: both signatures admitted the module and its load then failed, so code of the module may have run. The ename is the node's text. A subclass of nine-p-error, and not of module-change-refused-by-node.
module-install-outcome-unknown
Signalled when the node answers a request on /modules/install with an Rerror that begins neither module install refused nor module install failed after loading, such as the door's catch-all module install failed: the text does not say whether any of the module's code ran, so it may have. The ename is the node's text. A subclass of nine-p-error, and not of module-change-refused-by-node.
module-install-reply-lost
Signalled when the session fails, or any condition other than the node's Rerror stops install-module-remote, once the install request has begun to be written: the door runs a staged request when its fid is clunked, so the node may have installed the module although no reply was read. CAUSE is the condition that stopped it.
module-install-request-malformed
Signalled before anything is dialled when install-module-remote is given a designation or a source field that is empty or holds a line break, or a source of any shape but NIL, (:directory DIRECTORY) and (:git REPOSITORY TAG). DETAIL says which, and never quotes the field, since a repository can carry a credential.
module-manifest-refused-by-node
Signalled when the node answers a read of its module manifest tree with an Rerror, such as module manifest unreadable and its kind, or no such module. The ename is the node's text. A subclass of nine-p-error: a handler for nine-p-error still catches it and can read the ename.
modules-door-absent
Signalled when the walk to a /modules door fails on a node the owner session reached: the node has no such door. DOOR names it, install or manifest. A transport or session failure is never reported this way.
node-key-presented
Signalled when the device key a client would present to a pinned or expected node has that node's own DID: the node's keyfile is never presented as a device key. Signalled before any key is presented or any connection opens.
owner-key-argument-refused
Signalled before any session opens when a DID or label handed to a device-key verb carries whitespace. The /id/ctl line is space-separated and newline-terminated, so such a value would split into a second attribute or a second command.
owner-keyfile-absent
Signalled when nothing is at the resolved owner keyfile path PATH. No key is created in its place and nothing is dialled. A kind of owner-session-refused, so every owner verb reports it as a refusal and never as a node it could not reach.
owner-target-conflict
Signalled when the owner-target arguments may not be combined: a pinned node name with a keyfile, an endpoint, or an expected DID; a pin file with no pinned name; a device key with a keyfile; or a device key with no named node. DETAIL says which. Signalled before any key is read or any connection opens.
owner-view-empty
Signalled when an owner session attaches but its root does not name the owner frame: the node admitted the presented key to an empty view. Signalled before the verb's request is written. A kind of owner-session-refused, so every owner verb reports it as a refusal and never as an unreachable node or a local fault.
zone-not-served
Signalled when an owner verb is asked to act on a zone the RUNNING node does not serve. Distinct from a server refusal and from an unreachable resident, because the three call for three different corrections and a caller that cannot tell them apart gives the operator the wrong one.
Generic functions
module-install-reply-lost-cause
(module-install-reply-lost-cause condition)
The condition that stopped the install once its request had begun to be written.
module-install-request-malformed-detail
(module-install-request-malformed-detail condition)
Which field or source shape was refused, as one line that quotes no field.
modules-door-absent-door
(modules-door-absent-door condition)
The /modules door the node lacks, the string install or manifest.
node-key-presented-did
(node-key-presented-did condition)
The Ed25519 did:key the key file and the node share.
node-key-presented-key
(node-key-presented-key condition)
The key file the client would have presented.
node-key-presented-node
(node-key-presented-node condition)
The pin name of the node, or NIL when the caller named the node by its DID.
owner-key-argument-refused-argument
(owner-key-argument-refused-argument condition)
The name of the refused argument, "DID" or "label".
owner-key-argument-refused-value
(owner-key-argument-refused-value condition)
The refused string, whitespace included.
owner-keyfile-absent-path
(owner-keyfile-absent-path condition)
Undocumented: this exported symbol needs a docstring.
owner-target-conflict-detail
(owner-target-conflict-detail condition)
Undocumented: this exported symbol needs a docstring.
zone-not-served-origin
(zone-not-served-origin condition)
Undocumented: this exported symbol needs a docstring.
Functions
add-record
(add-record origin owner ttl type rdata &rest owner-target &key &allow-other-keys)
Add ONE durable rrset member (OWNER TTL TYPE RDATA) to zone ORIGIN as the owner over the sealed loopback fabric. TYPE is a record type ("A", "MX", :txt, …); RDATA is its presentation text (runciter types it server-side). A rejected add (a CNAME conflict, a name outside the zone, a second apex SOA, malformed rdata) surfaces as a signalled condition. Returns the door's reply text, whose first line says whether the durable edit also reached the serving side.
call-with-owner-frame
(call-with-owner-frame fn &rest owner-target &key keyfile key node &allow-other-keys)
Call FN with (SESSION ROOT) as call-with-owner-session does, taking the same OWNER-TARGET keywords, and first signal owner-view-empty when ROOT does not name the owner frame, so nothing FN would write is sent. Every owner verb reaches its node through this function. call-with-owner-session itself still hands an empty view to its caller, which the view proof and the tests of non-owner sessions rely on.
call-with-owner-session
(call-with-owner-session fn &key keyfile key endpoint expected-did node pins-file (aname ""))
Open an owner-authenticated sealed 9P session and funcall FN with (SESSION ROOT-HANDLE), the promoted seven session and the attached owner-frame root. The endpoint and the owner key come from ENDPOINT and KEYFILE, or from the environment and the data root when you omit them.
EXPECTED-DID, when given, is the Ed25519 did:key of the node the session must reach. Any other DID, an X25519 did:key included, signals node-pin-unusable before any connection opens; a responder that proves another static key, or none, signals node-pin-mismatch or responder-unproven before the client's final handshake message, so neither the client's static key nor the attach reaches it. No keyword skips the check.
Without EXPECTED-DID the keyfile is taken as the node's own key, and the session expects the node that holds it: a responder proving any other static signals node-pin-mismatch naming the keyfile and its Ed25519 DID. A missing keyfile signals owner-keyfile-absent, an owner-session-refused, and an unsafe or malformed one owner-keyfile-unsafe, both before dialling; none is ever created.
KEY is the path of a device key, an owner key enrolled on the node's keyring, presented in place of the keyfile. It is presented only to a node the caller names with EXPECTED-DID, and the node's own keyfile and VALISKEYFILE are not read on that route. KEY with KEYFILE, or KEY without EXPECTED-DID, signals owner-target-conflict before any key is read or any connection opens. A missing device key signals device-key-absent and an unsafe one owner-keyfile-unsafe, both before dialling; none is ever created. A device key whose DID is EXPECTED-DID is the node's own key and signals node-key-presented before dialling.
NODE is a pinned name: its pin in PINS-FILE, or the shared node pin store, gives the endpoint to dial and the node that must answer, and the client presents KEY or the default device key. The node's keyfile, VALISKEYFILE, VALISMGMTENDPOINT, and the runtime endpoint file are not read on that route. NODE with KEYFILE, ENDPOINT, or EXPECTED-DID, or PINS-FILE without NODE, signals owner-target-conflict. A name with no pin signals node-pin-required, an entry with no port node-pin-endpoint-unusable, an unsafe or undecodable store node-pin-store-refused, and a presented key whose DID is the pinned DID node-key-presented, all before dialling; a responder other than the pinned node signals node-pin-mismatch naming the node, the endpoint, the pinned DID, the presented DID, and the pin file.
I end the session on every exit and release its endpoint, which zeroizes the transport session valis holds, and close the initiator node, which wipes its own copies of the keys, its chaining key, its handshake hash and its generated ephemeral key; its static key is released, never wiped. The exception is a thread that stays inside the endpoint past seven's teardown deadline: I leave that endpoint open, because releasing it would free buffers the thread still touches, and report it at WARN as OWNER-SESSION-ENDPOINT-LEFT-OPEN. When the attach never becomes a session, no thread was ever started on the socket, so I zeroize the transport session, close the initiator node and close the socket. valis performs no crypto: mercer owns the handshake and the keys.
create-zone
(create-zone origin instance-ip peer-address &rest owner-target &key fields &allow-other-keys)
Create the domain ORIGIN, served from INSTANCE-IP with the secondary at PEER-ADDRESS enrolled as its transfer peer, through the RUNNING resident's owner control plane over the sealed loopback fabric. Walks to names/zone/create, opens read-write, writes the newline-framed create payload, reads the resident's reply, and clunks. FIELDS carries whichever template parameters and typed override records differ from the defaults. Returns the resident's reply text: line 1 is a result token from a closed set, then one key/value line per field, then a blank line and the BIND fragment the secondary side pastes verbatim. The create mints the record set and enrols the secondary as ONE act, so a refusal leaves neither; a refusal surfaces as the signalled condition the clunk raises, which the operator tool prints before exiting non-zero. There is no field that accepts a zone file: a create carries typed values only, so a typo is a failed construction rather than a plausible wrong record.
delegate-publisher-signing-key
(delegate-publisher-signing-key expiry &rest owner-target &key &allow-other-keys)
Have the RUNNING node's creating identity delegate its signing key until EXPIRY, a Unix time, and return the reply naming the delegation hash and its expiry.
delete-record
(delete-record origin owner type rdata &rest owner-target &key &allow-other-keys)
Delete ONE durable rrset member (OWNER TYPE RDATA) from zone ORIGIN as the owner over the sealed loopback fabric. The member is identified precisely by its rdata; a co-resident sibling at the same name is untouched. A delete that would strand the zone without its apex SOA or NS is refused (a signalled condition). Returns the door's reply text, whose first line says whether the durable edit also reached the serving side.
delete-zone-remote
(delete-zone-remote origin &rest owner-target &key &allow-other-keys)
Delete the zone ORIGIN (whole-zone removal) as the owner over the sealed loopback fabric. Drives Tremove against the per-zone node (names/<origin>); a zone that does not exist fails the walk with an Rerror. Returns T on success.
enrol-owner-key-remote
(enrol-owner-key-remote did label &rest owner-target &key &allow-other-keys)
Enrol the device key DID under LABEL on the RUNNING node's keyring, as the owner over the sealed loopback fabric, and return the node's reply verbatim: enrolled <did>, or error <token>. Only a session presenting the seed enrols, so a device keyfile reads error not-seed-holder. Signals owner-key-argument-refused, before any session opens, when DID or LABEL carries whitespace.
enroll-transfer-peer
(enroll-transfer-peer origin peer key-name &rest owner-target &key notify-ref &allow-other-keys)
Authorize PEER, presenting the TSIG key named KEY-NAME, to transfer the zone ORIGIN from the RUNNING node, and add NOTIFY-REF (defaulting to PEER) to that zone's also-notify recipients. Both facts are written as the owner over the sealed loopback fabric, so they land through the resident's fenced control path rather than around it.
One session, two commits. The axis control node is created fresh per walk and its clunk IS the commit, so each verb is its own walk, open, write and clunk on one owner session. A failure between the two leaves the allowlist row without its also-notify recipient: a peer that may pull but is not told when to, which is benign and is repaired by re-running. Both verbs are idempotent, so re-running an enrolment that already landed changes nothing.
A zone this node does not serve is refused BY NAME before either verb runs, and a token the server will not accept as a DNS name comes back as a signalled condition with nothing written. Returns T on success.
export-zone-master
(export-zone-master origin &rest owner-target &key full &allow-other-keys)
Return the zone ORIGIN's master text as a string, read as the owner over the sealed loopback fabric. By default the DURABLE, re-importable master (names/durable/<origin>); with FULL true, the full serving set the per-zone master node renders (names/<origin>/master), which includes any transient turn-up rows. The per-zone node itself is a directory, so the master child is the only node that reads back as text. A zone that does not exist fails the walk with an Rerror.
fence-session-remote
(fence-session-remote session-id &rest owner-target &key &allow-other-keys)
Fence the session SESSION-ID on the RUNNING node, as the owner over the sealed loopback fabric, and return the node's reply as a string. Walks to id/session-ctl, opens read-write, writes the one command line, and reads the answer back on that same handle before clunking, because this door answers on the handle and never signals into the 9P layer. The reply is returned verbatim and nothing here interprets it: a fence that took and a fence that named no live session are both real answers to a caller who asked, and turning either into a signalled condition would erase a contract the caller needs. A fence empties the named session's view, so it can name nothing from its root until it authenticates again; it does NOT take back a handle that session already holds below its root, so a caller checking the effect must walk from the session root afresh rather than reuse a handle it opened earlier.
import-zone-master
(import-zone-master origin master-text &rest owner-target &key &allow-other-keys)
Import MASTER-TEXT (RFC-1035 zone master file text) as the zone ORIGIN, as the owner over the sealed loopback fabric. Drives create -> write -> clunk against the :names axis; the clunk IS the atomic commit, so a malformed master returns an Rerror that surfaces here as a signalled condition (a non-zero exit for the operator tool). Returns T on success.
install-module-remote
(install-module-remote designation &rest owner-target &key source &allow-other-keys)
Have the RUNNING node install the module DESIGNATION names, a module designation, as its owner over the sealed loopback fabric, from SOURCE: NIL for bytes already under its module source root, (:directory DIRECTORY) for a release directory on the node named by its absolute path, or (:git REPOSITORY TAG) for a tagged release the node fetches. Return three values: the token on the reply's first line as a keyword, :installed-serving, :installed-unserved, :installed-unrecorded (loaded and running, its record not published, so a restart does not stand it up again), :superseded or :already-current; the later lines as (key . value) strings in order; and the reply text. The reply is read before the door is clunked. A key the node gave no owner view meets owner-view-empty before anything is written. A node with no install door signals modules-door-absent. The node's Rerror signals the condition %install-rerror-condition chooses by its first words, carrying the node's text: module-change-refused-by-node only for module install refused, module-install-failed-after-loading or module-install-outcome-unknown otherwise, since code of the module may have run. A failure before the request is written passes through unchanged; any other failure from the write on signals module-install-reply-lost carrying it, since the node may have run the request.
list-certificates-remote
(list-certificates-remote &rest owner-target &key &allow-other-keys)
Return the certificates the RUNNING node holds, read as the owner over the sealed loopback fabric from names/cert/list, as one plist per line in the node's order (sorted by name): :name the string it is filed under, :not-after and :next-renewal each an integer instant or :unknown, :edge the string served, partial, not-served, no-edge or unconfirmed, and :names the list of DNS name strings it carries. A line with fewer than five fields is skipped. This verb opens a session of its own; a key the node gave no owner view meets owner-view-empty.
list-modules-remote
(list-modules-remote &rest owner-target &key &allow-other-keys)
Return the modules the RUNNING node's module manifest view lists, read as its owner over one sealed session, as one plist per module in the node's order: :system the system name; :recorded, :loaded, :divergence and :boot, the strings the module's state file serves; and :diverged, true when the image differs from the record. Reads and changes nothing. A node with no manifest door signals modules-door-absent; an Rerror from the node, such as module manifest unreadable, signals module-manifest-refused-by-node; a directory read that does not decode signals wire-error; a key the node gave no owner view meets owner-view-empty.
list-owner-keys-remote
(list-owner-keys-remote &rest owner-target &key &allow-other-keys)
Return the RUNNING node's key listing from id/ctl, read as the owner over the sealed loopback fabric: the seed's descriptor line, then one line per enrolled key, live or retired, each newline-terminated.
list-records
(list-records origin &rest owner-target &key &allow-other-keys)
Return the DURABLE records of zone ORIGIN as a list of plists, each (:digest :owner :ttl :class :type :rdata), read as the owner over the sealed loopback fabric from names/<origin>/records/list, the structured editing view with a stable per-member :digest identity the delete/replace verbs address a member by. :ttl is an integer; the rest are strings. A zone that does not exist fails the walk with an Rerror.
list-sessions-remote
(list-sessions-remote &rest owner-target &key &allow-other-keys)
Return the live sessions of the RUNNING node as a list of plists, each (:id :principal :admitted-at :last-activity :fenced-at), read as the owner over the sealed loopback fabric from id/sessions. :admitted-at and :last-activity are integer seconds, :fenced-at is an integer for a session already fenced and NIL for one still live, and the rest are strings. The listing is rendered afresh on every read and the node advertises no length for it, because any length it advertised would be a number that was true a moment ago. This verb opens a session of its own to ask, so the node counts it among the sessions it reports. Returns the sessions in the order the node listed them.
management-endpoint-file
(management-endpoint-file)
The path of the loopback management endpoint file (a single host:port line).
obtain-certificate
(obtain-certificate domains contact &rest owner-target &key profile directory-url &allow-other-keys)
Drive an ACME first-obtain for DOMAINS with account CONTACT through the RUNNING resident's owner control plane, over the sealed loopback fabric. Walks to names/cert/obtain, opens read-write, writes the newline-framed obtain payload, reads the resident's reply, and clunks. The obtain runs IN the resident that serves :53 so the dns-01 challenge TXT is refreshed into the serving index by the in-process commit hook: a challenge published from any other process is never served, so a standalone obtain returns invalid. Returns the resident's reply text: line 1 is the token obtained, then one domain line per issued identifier, a next-renewal line, an edge line saying whether the running :443 edge serves the certificate now, and, when the edge took the rebuilt names, a name line for each name the certificate carries saying whether the edge hands it that certificate. A refused or failed order surfaces as the signalled condition the clunk raises (a non-zero exit for the operator tool). valis performs no crypto.
owner-keyfile-store
(owner-keyfile-store &key keyfile)
The custody store for the resolved owner keyfile (KEYFILE, then VALISKEYFILE, then the data root). Signals owner-keyfile-absent when nothing is there and owner-keyfile-unsafe when the file is refused. Never creates a keyfile.
owner-node-named-p
(owner-node-named-p &key keyfile endpoint node key)
True when the operator named the node to reach as its owner: NODE, a pinned name, KEYFILE, or ENDPOINT given, or VALISKEYFILE or VALISMGMTENDPOINT set. KEY, a device key path, names no node by itself and is accepted so an owner-target plist applies whole. Where none is, an owner verb reaches the node this host's resident published, with the data root's keyfile.
owner-transport-key
(owner-transport-key &key keyfile)
The X25519 transport key of the resolved owner keyfile. Signals as owner-keyfile-store does; never creates a keyfile.
provision-publisher-signing-key
(provision-publisher-signing-key &rest owner-target &key replace &allow-other-keys)
Have the RUNNING node provision its publisher signing key, or with REPLACE rotate it, and return the reply. A rotation's reply names the retired key's delegation hash, which is yours to revoke.
publish
(publish slug content-type body &rest owner-target &key &allow-other-keys)
Place the publication SLUG into /pub on the RUNNING node, as the owner over the sealed loopback fabric. CONTENT-TYPE is the media type the edge serves it as; BODY is the content as octets. Walks to pub, creates the slug when it is not there yet, then writes the content-type attribute and the body. A slug that already exists is replaced rather than refused, so an operator can revise a page as often as they like; the create is skipped and the two writes stand in for it. A slug outside the publication charset, or any other refusal, comes back as the Rerror the fabric raises, which surfaces here as a signalled condition (a non-zero exit for the operator tool). Returns T on success.
read-certificate-list-remote
(read-certificate-list-remote &rest owner-target &key &allow-other-keys)
Return the RUNNING node's names/cert/list text exactly as served, read as the owner over the sealed loopback fabric: one newline-terminated line per certificate held, or the empty string when the node holds none. This verb opens a session of its own; a key the node gave no owner view meets owner-view-empty.
read-module-record-remote
(read-module-record-remote system &rest owner-target &key &allow-other-keys)
Return what the RUNNING node's module manifest view holds for SYSTEM, read as its owner over one sealed session, as two values. The first is a plist: :identity, the designation recorded, resident or none; :generation, the manifest's pinned generation as served; :files, the record's relative paths; :explain, a plist of :publisher, :owner-credential, :grant and :admitted-at, or :resident, each the string served; and :state, the plist list-modules-remote answers for the module. The second is the text of each of the five files as served, an alist from its name. Reads and changes nothing. A node with no manifest door signals modules-door-absent; an Rerror from the node, such as no such module or module manifest unreadable, signals module-manifest-refused-by-node; a key the node gave no owner view meets owner-view-empty.
read-node-boot-remote
(read-node-boot-remote &rest owner-target &key &allow-other-keys)
The RUNNING node's boot identity, read as its owner over the sealed loopback fabric: a string that is different at every start of the node, so two reads that differ were answered by two boots.
read-publisher-identity
(read-publisher-identity &rest owner-target &key &allow-other-keys)
Return the RUNNING node's publisher-identity reply: the creating identity its custody derives, its signing key, and that key's delegation, each on a line of its own.
remove-management-endpoint
(remove-management-endpoint)
Unlink the management endpoint file if present, the resident's teardown so a stale endpoint never outlives the listener that bound it.
replace-record
(replace-record origin owner type old-rdata new-ttl new-rdata &rest owner-target &key &allow-other-keys)
Replace ONE durable rrset member at (OWNER TYPE OLD-RDATA) with (OWNER TYPE NEW-TTL NEW-RDATA) in zone ORIGIN as the owner over the sealed loopback fabric, an atomic delete+add of one rrset member, the TYPE unchanged. The old member must exist; its absence is refused (a signalled condition). Returns the door's reply text, whose first line says whether the durable edit also reached the serving side.
resolve-keyfile
(resolve-keyfile keyfile)
Resolve the owner keyfile path: the KEYFILE argument (from –keyfile), then the VALISKEYFILE environment variable, then the data-root keyfile. A PATH only: the key bytes never cross argv.
resolve-management-endpoint
(resolve-management-endpoint &key endpoint)
Resolve the management endpoint to (values host port), in precedence order: the ENDPOINT argument (a "host:port" string, from –endpoint), then the VALISMGMTENDPOINT environment variable, then the runtime endpoint file the resident published. Fail-closed when none is available.
restart-node-remote
(restart-node-remote &rest owner-target &key &allow-other-keys)
Ask the RUNNING node, as its owner over the sealed loopback fabric, to have its host agent restart it, and return three values: the outcome, its detail, and the boot identity the node answered with just before it was asked.
The outcome is :accepted, the detail naming the service being restarted; :requested, when the node took the request and the session ended before its answer arrived, which is how a restart that took the session down first looks; :interrupted, when the session ended while the request was being written, so whether the node was asked cannot be known; or :refused, the detail the node's reason, or why its answer could not be read. A node with no restart door is refused with that said. Anything that stops the request before it is written signals, so a node you never reached is never mistaken for one that said no. A key the node gave no owner view signals owner-view-empty before anything is asked.
retire-owner-key-remote
(retire-owner-key-remote did &rest owner-target &key &allow-other-keys)
Retire the device key DID on the RUNNING node's keyring, as the owner over the sealed loopback fabric, and return the node's reply verbatim: retired <did>, or error <token>. The seed or a live enrolled key retires. Signals owner-key-argument-refused, before any session opens, when DID carries whitespace.
sign-module-remote
(sign-module-remote release-path &rest owner-target &key &allow-other-keys)
Have the RUNNING node sign the module release at RELEASE-PATH, an absolute path on that node, and return the reply: its designation, the signing key, the creating identity, the delegation, and the vouch.
sign-publisher-list-remote
(sign-publisher-list-remote entries &rest owner-target &key &allow-other-keys)
Have the RUNNING node sign, as its creating identity, the publisher list following the last one it filed or signed, naming ENTRIES, each a (STATE . TARGET) pair of :revoked or :withdrawn and a delegation hash as 64 lowercase hex characters or a did:key, and return the reply carrying the list. File the list through /cap/admit to put it in force.
write-management-endpoint
(write-management-endpoint host port)
Publish the resident's bound loopback management endpoint HOST:PORT to the runtime file at 0600, creating the parent directory. Returns the path. The same-host operator client reads this to reach the owner frame without a fixed port.
Macros
with-owner-session
(with-owner-session (session-var root-var &key keyfile key endpoint expected-did node pins-file (aname "")) &body body)
Bind SESSION-VAR and ROOT-VAR to the owner session and owner-frame root for the dynamic extent of BODY, tearing both down on exit. Thin sugar over call-with-owner-session.
Variables
*module-manifest-read-limit*
The most octets a read of a node's /modules/manifest directory takes before it is refused as a wire error. Each module costs one stat record of well under a hundred octets plus its name, so the default holds tens of thousands of modules; the limit bounds what a node that never ends its listing can make the client hold.
*owner-target-keywords*
The keywords call-with-owner-session takes to name the node an owner verb reaches and the key it presents. Every owner verb accepts them and passes them to the seam unchanged.
Constants
+owner-session-endpoint-left-open+
The event logged at WARN when an owner session ends with a thread still inside its endpoint past seven's teardown deadline. I leave that endpoint open rather than release buffers the thread still touches, so each occurrence is one socket and one transport session held until the image exits. Grep the log for this name to count them.
+owner-session-endpoint-release-failed+
The event logged at WARN when releasing an owner session's endpoint signals. The release discards buffered output, so it is not expected to signal at all; when it does, you see it here instead of having it replace whatever the caller's own work signalled.
Package valis/src/edge/seam
Classes
edge-attach
A live 9P attach held by the seam on behalf of edge connections. SESSION is the seven client session; ROOT-HANDLE is the attach's root fid handle, the view everything else is walked from; TEARDOWN is the channel teardown thunk returned by the factory that built the transport underneath the session.
Conditions
edge-attach-teardown-fault
Signalled with WARN when one step of tearing down an edge attach faults. STEP names the step, CAUSE is the condition it signalled. Unless a handler unwinds, teardown still attempts every remaining step, with one exception: a faulting session step leaves the channel open, since nothing then says the session's threads have left it. Whether a faulting step released what it owns is up to that step: a channel teardown that faults before closing its channel can leave it open, and this warning is how you learn of it.
Generic functions
edge-attach-teardown-fault-cause
(edge-attach-teardown-fault-cause condition)
Undocumented: this exported symbol needs a docstring.
edge-attach-teardown-fault-step
(edge-attach-teardown-fault-step condition)
Undocumented: this exported symbol needs a docstring.
Functions
anonymous-view-dark-p
(anonymous-view-dark-p)
Return T when the anonymous view is dark and must not be served: its grant was revoked, or its standing could not be read at all. Either way self-heal will not resurrect it until the grant reads live again. A dark anonymous view serves the protocol's own absence response; the edge never synthesizes policy. ANONYMOUS-VIEW-DARK-REASON says which of the two it was, for an operator; the decision to refuse does not depend on that.
anonymous-view-dark-reason
(anonymous-view-dark-reason)
Return (values reason detail) naming why the anonymous view is dark: :REVOKED, or :UNDETERMINED with the condition's report text as DETAIL. Returns (values nil nil) while the view is serving. Callers deciding whether to serve read ANONYMOUS-VIEW-DARK-P; this is for telling an operator which fault to go and fix.
call-with-request-capability
(call-with-request-capability function capability-string &key principal)
Functional core of WITH-REQUEST-CAPABILITY: call FUNCTION with the root handle of a PRIVATE per-request view, and tear that view down when FUNCTION returns or unwinds.
For the anonymous principal the view is a fresh anonymous-grant attach with CAPABILITY-STRING mounted through its ctl door; teardown unmounts the leaf then closes the private session and channel.
For a keyed principal the view is the base namespace BUILD-BASE-VIEW resolves, the single owner-discrimination site: the owner DID gets its canonical sovereign frame, a non-owner keyed DID gets an empty view that names nothing (sovereignty-as-absence). CAPABILITY-STRING is not mounted on this path; the resolved frame is the view, served read-only. Either way the view rides the same per-request unwind-protected teardown, so the owner view is torn down with its request (stateless elevation) and no owner session outlives a keep-alive connection.
edge-attach-root-handle
(edge-attach-root-handle instance)
Undocumented: this exported symbol needs a docstring.
edge-attach-session
(edge-attach-session instance)
Undocumented: this exported symbol needs a docstring.
normalize-edge-principal
(normalize-edge-principal principal)
Normalize a connection's PRINCIPAL for the seam. Nil (a plain TCP edge connection that performed no identity handshake) resolves to the anonymous sentinel, so callers above the seam always see a principal object, never nil. The principal parameter is structurally carried from day one even though every edge connection resolves anonymous today.
reset-edge-seam
(reset-edge-seam)
Close every cached edge attach, clear the cache, and clear the dark flag. Idempotent: safe to call when nothing is cached. Called by tests between fixtures and by edge teardown so a stopped fabric never leaves the seam holding sessions into a dead namespace or a dark state carried across a restart.
view-for
(view-for connection principal capabilities)
Resolve CONNECTION's namespace view from its PRINCIPAL and request CAPABILITIES. This is the only way an edge module reaches the sovereign core: the seam holds the sessions, the caller gets a view.
Returns (values root-handle session): the attach's root fid handle to walk from, and the seven session it belongs to. Returns NIL when the anonymous view is dark (its grant was revoked) or a self-heal could not re-establish it: the adapter then serves the protocol's own absence response, never an edge-synthesized one.
A nil PRINCIPAL normalizes to the anonymous sentinel. Anonymous callers with no request capabilities share one cached attach made with ANONYMOUS-EDGE-GRANT as the aname: the grant, verified server-side, is the sole scope of what the view contains. Signals if the grant is not set (the fabric is not up): a view must never exist ahead of the authority that scopes it.
A keyed principal resolves its base view through build-base-view, the single owner-discrimination site: the owner DID receives the canonical sovereign frame; a non-owner keyed DID receives an empty view-root, present but naming nothing, sovereignty-as-absence, never the anonymous or owner view. This branch returns a third value, a teardown thunk the caller MUST funcall at request end: the keyed view is per-request and never cached (stateless elevation), so the owner view is torn down at request end exactly like the anonymous request-scoped path, the session leaves the listing at that teardown, and no owner session persists across a keep-alive connection.
Non-nil CAPABILITIES (one capability string or a list of them) resolve to a PRIVATE per-request view (never the shared cached attach) with each capability mounted through the view's own ctl door. This branch returns a third value, a teardown thunk the caller MUST funcall at request end; no wire protocol carries in-band capabilities yet, so its consumers are direct API callers (WITH-REQUEST-CAPABILITY wraps the same machinery with unwind protection and is the preferred entry point).
Macros
with-request-capability
(with-request-capability (root-var capability-string &key principal) &body body)
Execute BODY with ROOT-VAR bound to the root handle of a PRIVATE per-request view into which CAPABILITY-STRING has been mounted through the view's own ctl door. The shared cached anonymous attach is never touched: the request's authority exists only in this view, for exactly the dynamic extent of BODY.
Teardown is unwind-protected: on normal return or non-local exit the mounted name is unmounted (self-narrowing), then the private session is clunked and its channel closed. The mount never outlives the request.
When PRINCIPAL is a keyed (non-anonymous) principal, ROOT-VAR is bound to the base view build-base-view resolves instead (the owner's canonical frame for the owner DID, an empty view otherwise) served read-only with the same per-request teardown; CAPABILITY-STRING is not mounted on that path.
Variables
*anonymous-edge-grant*
The one edge-wide bearer name designating the public view. Every anonymous edge connection attaches with this grant as its aname, so what anonymous callers can see is controlled by exactly one knob, consistent across protocols. Set by start-fabric; cleared by stop-fabric. Nil before the fabric is up. This module owns the binding: never declare a second binding elsewhere.
*edge-channel-factory*
A function of no arguments returning two values: a client endpoint suitable for seven's MAKE-SESSION, and a teardown thunk that closes the channel ends and reaps any server thread the factory started. Defaults to an in-process channel served in-image. This is the single channel-construction point at the edge: rebind to select a different transport (e.g. seven's TCP client path, or a socketpair channel into a separate core process) without changing any call site. Tests rebind it to count constructions and to prove the same adapter serves over a second transport with no code change.
*edge-grant-mint-fn*
A function of one argument (TTL in seconds) returning a fresh bearer name string for the public view. Installed by start-fabric; cleared by stop-fabric. The seam calls it to re-mint an expired grant during self-healing. A function-valued hook so this package never references the fabric package: the fabric is downstream and installs the function at start time, breaking what would otherwise be a package cycle.
Constants
+edge-attach-channel-left-open+
The event logged at WARN when an edge attach's session ends with a thread still inside its channel past seven's teardown deadline, or when closing that session signals, which leaves nothing to say the threads have gone. I leave that channel, and whatever its teardown would have reaped, as it is rather than release buffers the thread still touches, so after each occurrence the channel stays open and its resources are not reclaimed until the image exits. Grep the log for this name to count them.
Package valis/src/edge/send-queue
Classes
send-queue
One connection's outbound backlog, bounded by a hard byte cap.
⚠ The queue carries NO lock, deliberately. Its owner holds one, because the owner also owns the write-interest transition that has to happen with the same view of the fill as the accept did: a queue that guarded itself would make each operation atomic and the pair of them still racy. A second lock added here would buy nothing and would give a later reader two locks to reason about.
An owner therefore calls ENQUEUE and DRAIN under its own lock.
Conditions
message-exceeds-cap
The message is larger than the entire budget, so no amount of draining will ever make room for it. A producer that retries this one retries forever. It is refused on an empty queue too, which is what stops a single large write defeating the bound.
send-refused
Signalled when a queue will not accept a message. Carries the queue, the length of the message turned away and the cap it was measured against, so a report can state all three rather than saying only that a send failed.
A caller that will not retry handles this type and needs no more. A caller that will retry handles the two subtypes, because they differ in whether retrying can ever work.
send-would-exceed-cap
The message fits the cap but the queue does not have room for it right now. Draining changes this: the same message offered again after the peer has read can be accepted, so a producer that can wait should wait.
Generic functions
queued-bytes
(queued-bytes object)
Undocumented: this exported symbol needs a docstring.
send-queue-cap
(send-queue-cap object)
Undocumented: this exported symbol needs a docstring.
send-refused-cap
(send-refused-cap condition)
Undocumented: this exported symbol needs a docstring.
send-refused-message-length
(send-refused-message-length condition)
Undocumented: this exported symbol needs a docstring.
send-refused-queue
(send-refused-queue condition)
Undocumented: this exported symbol needs a docstring.
Functions
drain
(drain queue writer)
Write as much of QUEUE as WRITER will take. Answers two values: how many octets left, and whether the queue is now empty.
WRITER is called with a message and the bounds of its unwritten part, and answers how much of that it actually took. The count is treated as possibly short, which is the normal case against a peer that is slow to read, and the offset advances by exactly what left so no byte is written twice and none is skipped.
The drain stops as soon as WRITER answers anything other than a positive count. That covers both a descriptor with no room and a descriptor that has gone away: neither is this function's to distinguish, and the connection's read side reports an ending either way.
The emptiness answer is what the caller withdraws its write interest on. A caller that ignores it leaves the loop watching a descriptor that is permanently ready to accept output, and spins.
enqueue
(enqueue queue octets)
Accept OCTETS for later writing on QUEUE, or refuse them. Answers QUEUE.
The cap is tested against the length the queue WOULD have, before anything is buffered. Testing the length it already has and appending afterwards would bound it at the cap plus one whole message, and nothing on this path caps a message, so that placement bounds nothing at all.
⛔ QUEUE COPIES OCTETS here, at the moment it accepts them, and the caller keeps its vector and may reuse or mutate it as soon as this returns. The queue holds bytes across callback boundaries, because a short write leaves the tail of a message queued indefinitely, and the first obvious use of this whole seam is sending straight out of a session's read buffer, which is reused on the next read. Taking ownership of the caller's vector instead would put the wrong bytes on the wire silently, under load, and only on a short write. The counterpart rule is stated on the session's receive callback; the two are one contract and must agree.
The cost is one allocation per accepted message, and it is deliberately a visible cost rather than a hidden one: it is what the per-queued-message figure reports.
Refuses with MESSAGE-EXCEEDS-CAP when the message alone is larger than the cap, which draining can never fix, and with SEND-WOULD-EXCEED-CAP when it is the sum that is too large, which draining can. An empty message is accepted and holds nothing.
make-send-queue
(make-send-queue &key (cap *default-send-queue-bytes*))
An empty queue bounded at CAP octets, defaulting to the provisional figure this file declares.
queue-empty-p
(queue-empty-p queue)
Whether QUEUE has nothing left to write.
queue-pressure
(queue-pressure queue)
How full QUEUE is, as a fraction of its cap: 0 when empty and 1 when full.
This is the first line of backpressure. A producer generating more than one message consults it between chunks and stops at SUSPEND-FRACTION, which is how the cap stays a last resort rather than the only thing telling a producer to slow down.
Variables
*default-send-queue-bytes*
How many octets one connection may hold for a peer that is not reading.
Provisional, and it says so because nothing has profiled this system yet. It is expressed as a small multiple of the carrier frame ceiling this tree already declares, and it is derived from that bound rather than adopted from any other implementation's default: a number carried over from a daemon serving short text lines says nothing about a system whose messages are whatever a module puts on the wire.
What it trades is memory resident per slow reader against how long a burst survives before the connection is cut. Raising it lets a peer that stalls briefly catch up; it also lets every simultaneously stalled peer hold that much. A measured figure replaces this one.
*suspend-fraction*
The fill, as a fraction of the cap, at which a bulk producer should stop and wait for the queue to drain.
The cap is the last resort and this is the first. Reaching the cap costs the connection, so a producer that generates more than one message at a time consults QUEUE-PRESSURE between chunks and suspends here, and never reaches the kill at all. A producer that does not consult it is not wrong, only brittle: it discovers the bound by being cut.
Package valis/src/edge/served-dns
Functions
adopt-configured-served-set
(adopt-configured-served-set)
The served-set key's rebinding function: take the configured set into the running node and its firewall as adopt-configured-served-ports does, then have DNS follow it. Returns what adopt-configured-served-ports returns. A caller that bound adoption-report finds what DNS did in its fifth place, as follow-configured-dns-serve returns it.
follow-configured-dns-serve
(follow-configured-dns-serve)
Bring this node's DNS serve loop into line with the served set configuration holds, and return what that came to:
NIL 53 is not configured and nothing answers it. (:answering) 53 is configured and DNS answers on UDP. (:stopped) 53 is not configured, and the loop that was answering stopped and its sockets closed. (:not-answering REASON DETAIL) 53 is configured and nothing answers it.
REASON is the one the host agent's exchange gave: :refused, DETAIL its words; :unsupported when the agent serves no bind file; :no-control-session when this node holds no session with an agent; :session-failed when the session ended under the exchange; :failed when an answer could not be read, the descriptor was not a UDP socket, or the loop could not start on it, DETAIL saying which. :configuration-unreadable means configuration could not be read at all.
Runs under the served configuration lock, so it sees the set the change that called it stored, and two changes cannot both start a loop. A node with no configuration store open has no configured set to follow, and nothing is done.
Never signals: whatever DNS does, the change that called this stands.
Package valis/src/edge/served-ports
Conditions
malformed-served-port
Signalled when an offered member is not an integer at all.
served-port-error
Parent of every refusal this module raises. OFFERED is the value that was not accepted, carried so a caller can report it without re-deriving it.
served-port-out-of-range
Signalled when an offered member is an integer outside the range a service port can occupy.
Generic functions
served-port-error-offered
(served-port-error-offered condition)
Undocumented: this exported symbol needs a docstring.
Functions
admit-configured-served-ports
(admit-configured-served-ports)
Ask the host agent to admit exactly the served set configuration holds on the node's public edge, every transport each port needs, and return what came of it:
(:admitted) the agent's kernel holds that surface. (:not-admitted REASON DETAIL) it does not, or nobody could be asked.
REASON is :refused when the agent turned the write down, DETAIL its words; :read-back-differs when the agent took the write and its kernel holds another surface, DETAIL naming both; :unsupported when the agent serves no admit file; :no-control-session when this node holds no session with a host agent; :session-failed or :failed when the session ended or answered something unreadable; :no-configuration-store or :configuration-unreadable when there is no stored set to admit.
I admit the configured set and not the held one, because the owner's choice is what belongs on the public edge. A port the edge bound for one of its own adapters joins the held set and stays off the firewall.
The write names the whole surface, so it closes whatever it leaves out, and the empty set closes everything. The agent keeps what it admits only in memory, which is why every session that begins has to drive this again.
Never signals: a node that could not have its surface admitted still serves what it serves, and the reason is logged and handed back for a caller to report.
adopt-configured-served-ports
(adopt-configured-served-ports)
Bring the served set into line with node configuration, tell the host agent the whole set when that changed it, and have the agent admit the configured set on the node's public edge. The served-set key's rebinding function runs this first, so a change stored through any configuration surface reaches the running node and its firewall at once.
Returns the held set, whether it changed, whether the agent acknowledged the declaration and the reason it did not, and what came of the admission as admit-configured-served-ports returns it. A caller that bound adoption-report also finds the last four there.
The admission runs on every change to the key, the held set changed or not: a port the edge already serves for an adapter is still a new port for the firewall once the owner configures it.
adopt-served-ports-at-boot
(adopt-served-ports-at-boot launch-ports)
Take in the served set as a boot begins, and return it with a second value saying where it came from.
The first boot to find the key unsettled records a set in configuration: LAUNCH-PORTS when the launch named any, which returns :launch, and the default core when it named none, which returns :default-core. Every later boot returns :configured and serves what configuration holds, whatever LAUNCH-PORTS says, because the owner's choice has to survive the host's idea of what the node was launched to serve. A set configuration holds as empty stays empty; it is never seeded again from the default core.
With no configuration store open, as in a development image before a boot, LAUNCH-PORTS becomes the held set and nothing is recorded, which returns :unrecorded.
change-configured-served-port
(change-configured-served-port port action)
Declare PORT in the owner's served set when ACTION is :declare, or take it out when ACTION is :retire, storing the change in node configuration before the running node takes it in.
Returns what happened: :declared or :retired when configuration changed, :already-declared or :not-declared when it already said what was asked. For a change, the second value is true when the host agent holds the resulting set, and the third says why it does not: the reason announcing it failed, :not-applied when the change is stored and the running node failed to take it in, or :on-restart when the store written is not the running node's, so the change waits for its next boot. The fourth value says whether the host agent admits the resulting configured set on the node's public edge, as admit-configured-served-ports returns it, or why it was not asked: :not-applied and :on-restart mean what they mean for the declaration. The fifth value is what the services that follow the served set did about the change, as the key's rebinding function reports it, or NIL when none acted or the running node did not take the change in.
A change the agent did not admit is still made. It is stored, the running node serves it, and the reason travels with the reply rather than undoing an edit the owner made because the host agent could not act on it.
The change is durable before anything is reported. That is the point of it: a reply saying a port was declared is a promise the node keeps across a restart. A store that cannot be written, or no store at all, signals and nothing changes, and a value that is not a port is refused as the verbs above refuse it.
Reading the configured set, storing the changed one and taking it into the running node happen under one lock, so owners changing different ports at once each keep their change.
configured-served-ports
(configured-served-ports)
Return the served set node configuration holds, ascending, and as a second value where it came from: :default when nobody has ever set it, so the node serves the default core; :seeded when a first boot recorded it from its launch; :operator when the owner chose it.
An empty set is a real answer. With an origin other than :default it is a choice to serve nothing, and it stays that way across restarts. Signals config-store-not-open when the image has no configuration store open.
declare-served-ports
(declare-served-ports ports)
Declare PORTS as the complete set this node serves and return it, ascending and de-duplicated. Whatever was declared before is replaced: this is the whole declaration, not an addition to one.
An empty PORTS declares an empty set and signals nothing. A member that is not a port number refuses the declaration entirely, leaving the previous set in place.
retire-served-port
(retire-served-port port)
Remove PORT from the declared set. Returns the resulting set as a fresh list and, as a second value, :RETIRED when PORT was removed or :NOT-DECLARED when it was not in the set to begin with.
serve-port
(serve-port port)
Add PORT to the declared set. Returns the resulting set as a fresh list and, as a second value, :DECLARED when PORT was added or :ALREADY-DECLARED when it was there already.
Adding twice is not an error. The owner asked for a node serving that port and gets one; the second value is there so a control surface can say which of the two happened rather than leaving the owner unsure the first attempt landed.
served-port-p
(served-port-p port)
Return true when PORT is in the declared set.
served-ports
(served-ports)
Return the declared set as a fresh ascending list. The copy is the point: a caller can sort, nreverse or nconc the answer without reaching the node's own value.
take-up-host-session
(take-up-host-session endpoint)
Hold ENDPOINT as this node's control session with the host agent, and have the agent admit the configured served set over it. Returns what came of the admission, as admit-configured-served-ports returns it.
Call it wherever a session with the host agent begins. The agent keeps the set it admits only in memory, so after the agent restarts the surface it holds is empty, and a fresh session is the first moment this node can put it back.
Taking the session up and admitting over it happen under the served configuration lock, so an owner's change made at the same moment either lands before the admission and is in it, or waits and admits again over the session just taken up.
Variables
*adoption-report*
Bound by a caller that changes the served-set key and wants to know what taking the change in did: a list of five places. adopt-configured-served-ports fills the first four with whether the held set changed, whether the host agent was told, why not, and what came of admitting the configured set. The fifth is for what the services that follow the served set did about the change, filled by the key's rebinding function after it has taken the set in, and NIL when none of them acted. The change reaches the running node through the key's rebinding function, which reports nothing to whoever stored the value, so this is how the owner's door learns what to say.
*served-configuration-lock*
Held across every change to the owner's served set, from reading what configuration holds to the running node taking in what was stored. Without it two owners declaring different ports at once each read the same set, each store it with their own port added, and the second write loses the first.
Recursive because the owner's door holds it while the change runs the key's rebinding function, which takes it as well. It is taken before served-ports-lock and before the configuration store's own write lock, and never while holding either.
+served-ports-setting+
The node key the owner's served set is kept under.
Package valis/src/edge/session
Classes
connection-source
An ESTABLISHED connection presented to the multiplexer as an event source. Readiness on a listening source means a connection is waiting to be accepted; readiness here means bytes are waiting to be read, which is the whole of what this file adds to the loop's vocabulary.
edge-session
One established connection driven by the event loop. It owns its descriptor, its read buffer and its registration, and it hands its protocol nothing but octets and itself. The protocol never sees a descriptor, which is what keeps a module rebuildable from durable state and a peer reconnect.
Conditions
send-on-closed-session
Signalled when a send is offered to a session that has already ended.
It is a refusal rather than whatever the operating system says about a descriptor that is gone, because a caller cannot avoid the race: a session can end between a module deciding to write and the write happening, and the module may not have been told yet. Refusing it as a send-refused means one handler covers every way a send fails to land, and a caller that needs the difference can still ask which one it was.
session-adoption-refused
Signalled when a connection is offered for loop-driven adoption and one of the clauses refuses it. Carries the connection, the protocol claiming its port, and the CLAUSE that refused, so a caller falling back to the thread path can say which invariant it fell back on rather than reporting a bare failure.
The clause is one of :STREAM-DISCIPLINE, the protocol has not declared that it drives the raw descriptor; :CONNECTION-CLASS, the object offered is not the accepted stream connection; :SESSION-CEILING, no bound on live sessions is in force. Refusal is the ordinary outcome and every adapter shipped today produces one.
Generic functions
connection-source-session
(connection-source-session object)
Undocumented: this exported symbol needs a docstring.
protocol-stream-discipline
(protocol-stream-discipline protocol)
Answer how PROTOCOL expects the bytes of an accepted connection to reach it: :BUFFERED-STREAM, the default, or :RAW-DESCRIPTOR.
A protocol answering :RAW-DESCRIPTOR is declaring that it drives the accepted descriptor itself and hands nothing above it. Only such a protocol may have its connections driven from the event loop, and the declaration is the ONLY thing that opens that path.
An adapter that wraps or terminates the stream cannot honestly declare it. Readiness is asked of the descriptor while the wrapper holds transformed bytes above it, so the two disagree: the loop is told there is nothing to read while the wrapper is holding a whole request, or told there is something while the descriptor's bytes are meaningless on their own. On a port whose stream is terminated the failure is worse than a stall, because driving the descriptor puts the exchange on the wire in the clear.
The default is the safe answer, so every adapter that says nothing keeps exactly the thread shape it has today and no existing adapter changes behaviour by this file existing.
session-adoption-refused-clause
(session-adoption-refused-clause condition)
Undocumented: this exported symbol needs a docstring.
session-adoption-refused-connection
(session-adoption-refused-connection condition)
Undocumented: this exported symbol needs a docstring.
session-adoption-refused-protocol
(session-adoption-refused-protocol condition)
Undocumented: this exported symbol needs a docstring.
session-closed
(session-closed protocol session reason)
Tell PROTOCOL that SESSION has ended and why. Called exactly once per session, whatever ended it, and carrying the first reason given.
REASON is one of a closed set, enumerated here so it cannot drift across the files that signal it: :PEER-CLOSE, the peer went away in an orderly manner; :TRANSPORT-ERROR, the descriptor or the protocol failed and the session was cut; :SHUTDOWN, this side ended it deliberately; :SEND-QUEUE-OVERFLOW, the peer stopped reading for long enough that this side ran out of the budget it is willing to hold for it; :LIVENESS-TIMEOUT, the peer was asked to prove it was still there and did not answer inside the deadline; :REGISTRATION-TIMEOUT, the peer never completed what the protocol calls registration within the time allowed from establishment. Later work adds to this set by extending this enumeration and nowhere else.
The default does nothing.
session-connection
(session-connection object)
Undocumented: this exported symbol needs a docstring.
session-mux
(session-mux object)
Undocumented: this exported symbol needs a docstring.
session-opened
(session-opened protocol session)
Tell PROTOCOL that SESSION is registered and live. Runs on the loop thread, after the session's descriptor is being watched, so a protocol that greets its peer has somewhere to send. The default does nothing.
session-protocol
(session-protocol object)
Undocumented: this exported symbol needs a docstring.
session-queue
(session-queue object)
Undocumented: this exported symbol needs a docstring.
session-received
(session-received protocol session buffer start end)
Hand PROTOCOL the octets of SESSION that arrived, occupying BUFFER between START and END. Runs on the loop thread, so the body carries the loop's non-blocking obligation: it must return in the time of the work it does on those bytes, never wait on anything.
The buffer is the session's OWN and is reused by the next read, so a protocol that needs to keep these bytes past the call copies them. The counterpart is written here too, so the pair lives in one place and cannot drift apart: the send path copies what it is given, so handing this buffer straight to a send is safe and needs no copy of its own.
The default does nothing.
session-source
(session-source object)
Undocumented: this exported symbol needs a docstring.
session-state
(session-state object)
Undocumented: this exported symbol needs a docstring.
Functions
adopt-connection
(adopt-connection connection protocol mux &key (liveness (make-liveness-policy)) ceiling release)
Offer CONNECTION, claimed by PROTOCOL, for driving by MUX's event loop. Answers the session on success and signals SESSION-ADOPTION-REFUSED otherwise. Must be called on the loop thread, because registration mutates state the loop owns while it is dispatching.
Three clauses are evaluated before anything is changed, so a refusal leaves the connection exactly as it arrived and the caller can still run it the ordinary way.
First, PROTOCOL must have declared the raw-descriptor discipline. This is what refuses every adapter that wraps or terminates the stream, and it is what stands between this path and serving a terminated port in the clear.
Second, CONNECTION must be exactly the accepted stream connection. This one refuses nothing that reaches here through the shipped construction sites, where every port hands over that same class; it is kept because this function is exported and a future caller can offer it anything. What it actually keeps out is a connection with no descriptor to drive, a datagram being the shape that would arrive the moment a datagram source were wired to a budget executor.
Third, a ceiling on live sessions must be in force. The one in force is CEILING, the bound the admitting party names for itself, and only when it names none does this fall back to the shipped variable. That variable ships closed, so a caller that offers a connection under no bound of its own is refused exactly as it was before this argument existed.
RELEASE is how the count that admitted this session gets given back: a thunk of no arguments, called exactly once when the session ends, whatever ended it. An admitting party that hands one in does not have to watch for the close, and cannot miss an ending it was not watching for.
Only then is the connection put in non-blocking mode and built into a session. The source is registered BEFORE the protocol is told the session opened, so a protocol that greets its peer during open is sending on a descriptor the loop is already watching rather than on one it is about to start watching.
LIVENESS is the policy the session's two clocks are set from, and defaults to one built from the shipped parameters. Pass NIL for a session that arms no clocks and so can never be ended by one. The clocks start before the protocol is told the session opened, so a protocol that completes registration during open finds a registration clock there to withdraw.
close-session
(close-session session &key (reason :shutdown))
End SESSION for REASON and answer it. Idempotent: a second call neither signals nor tells the protocol a second time, and the reason recorded is the first one given.
The order is deliberate. Every outstanding clock is withdrawn first, under the same lock that moved the state, so no timer body can fire against a descriptor this path is about to release. That is the exact failure the request-shaped read-deadline watchdog carries a lock and a closing flag to prevent, and the same care is owed here.
Then the registration is withdrawn, so no readiness callback can arrive for a descriptor that is about to be released; then the connection is closed; then whoever admitted the session is given back what it spent; then the protocol is told. A protocol learning of the end while the descriptor is still being watched would be invited to write to something already gone.
The release sits after the close and not before it because what it gives back is permission to admit a replacement, and the descriptor this one holds is not gone until the close has run. Handing that permission back early lets a port take on a new peer while this one still holds everything it was counted for, which is the bound quietly exceeded rather than enforced. It sits inside the one-shot guard for the mirror-image reason: a second close must not credit the same slot twice, or the count drifts upward by one for every reconnect until the bound means nothing. It is error-isolated, because a party that fumbles its own accounting must not be able to strand a descriptor.
session-send
(session-send session octets)
Send OCTETS to SESSION's peer. Answers SESSION, or refuses.
⛔ SESSION COPIES OCTETS before this returns, so the caller keeps its vector and may reuse or mutate it the moment the call comes back. That is what makes sending straight out of the buffer a protocol was handed on receipt safe: the buffer is the session's own and is reused by the next read, and without the copy the bytes on the wire would be whatever arrived next. The rule is stated here in the caller's terms and on the queue in the queue's, and the two are one contract.
May be called from ANY thread, and that is the property the whole seam exists for: no handler need be running on this connection for the send to land, so a connection can be written to outside the extent of the request that created it. Write interest is loop-owned state, so the request for it is routed to the loop thread rather than made here, and it is only made when the queue goes from empty to non-empty, which keeps the cross-thread hop off the path of every subsequent send.
A send that would breach the cap ends the connection with :SEND-QUEUE-OVERFLOW, and the refusal is signalled to the caller as well. The close is the edge's decision because the queue and the cap are the edge's, and a module cannot enforce a bound it does not hold. What it leaves the module to do is notice the refusal: a send is not an assertion that bytes arrived, and a module that treats it as one will believe it has told a peer something it never told it.
Variables
*edge-session-ceiling*
The fallback bound on live loop-driven sessions for a caller that names none of its own, or NIL.
NIL is the shipped value and it still REFUSES adoption. This is not an absent limit meaning unlimited; it is a closed door. A caller that offers a connection without saying what bound it is admitting it under has no bound, and admitting an unbounded number of long-lived sessions is a denial of service with extra steps.
A party that admits sessions in earnest names its own ceiling at the offer instead, and the count that enforces that ceiling lives with it rather than here. A bound edge port is such a party: it holds its own number and its own running count of what it is holding, so two ports cannot spend each other's allowance and neither of them consults this.
*session-read-buffer-bytes*
How many octets each session's own read buffer holds.
Provisional, and it says so because nothing has profiled this system yet. It trades syscalls against residency: a larger buffer drains a burst in fewer reads, and costs those bytes on every idle session whether or not the peer ever sends. The figure is deliberately small rather than matched to the datagram path's 64 KiB, because the shape being served here is many mostly idle peers, and residency per idle peer is the cost that grows with them while syscalls per burst does not. A measured figure replaces this one.
Package valis/src/edge/tls-serve
Classes
edge-credential-set
The credentials one :443 listener holds: a BASE, a table of credentials keyed by the served name each is filed under, a table of the wildcards those certificates carry, and a table of the other names they list.
BASE is the credential presented when no better choice can be made, and it is the same object the listener was wired against, held by reference so a renewal that reloads that cell is seen here too. BY-NAME answers the question a handshake asks: which certificate belongs to the name this client sent. BY-LISTED answers it for a name no certificate is filed under but some held certificate lists among its names, at any depth. BY-WILDCARD answers it for a name covered only by a wildcard, keyed by the parent domain that wildcard covers. Each table holds the very credential filed under BY-NAME, so a renewal that reloads a leaf in place reaches every name that leaf serves. I keep the listed names and the wildcards apart because one is keyed by the name itself and the other by its parent, and a shared table would let a listed name answer as a wildcard. All four slots are read-only, so the set a reader holds cannot change under it; a boot that admits a new name builds a new set.
tls-edge-protocol
The public :443 HTTPS serve protocol both the inherited-fd and the loopback dev-fallback paths dispatch to. Its handle-connection terminates TLS through the mercer seam against the captured credential cell and hands the plaintext octet stream to the existing HTTP handler, forcing the principal anonymous so the fabric axes are structurally absent. Like dns-serve-protocol it is directly registered by recovered port, not an edge-adapters entry.
tls-serve-handle
A handle to a running :443 serve loop, returned by start-tls-serve and consumed by stop-tls-serve. NAME is the admitted name this loop serves under (its key in tls-serve-handles, so a name-keyed retire can find it); PROTOCOL is the registered tls-edge-protocol; SOURCE is the steered (or self-bound) TCP event source; EXECUTOR is the budget-executor gating the source's concurrency, held so teardown can drain its in-flight connections off its live counter; MUX is the multiplexer it is registered on; PORTS is the list of ports the protocol claims.
Generic functions
tls-edge-protocol-credential-cell
(tls-edge-protocol-credential-cell object)
Undocumented: this exported symbol needs a docstring.
tls-edge-protocol-credential-set
(tls-edge-protocol-credential-set object)
Undocumented: this exported symbol needs a docstring.
Functions
edge-credential-covering-name
(edge-credential-covering-name set name)
The credential SET presents to a client that asked for NAME, or NIL when SET covers nothing for it. NIL too when SET is NIL. In order of precedence: the credential filed under NAME, then one whose certificate lists NAME, then one whose certificate carries a wildcard covering NAME. Within the second and third, the leaf admitted first keeps the name.
I made the edge answer for every name a held certificate verifies for, because custody files a leaf under one name and a leaf names more than that: a leaf filed under example.org that also lists a.b.example.org, or carries *.example.org, would otherwise leave those names meeting the base credential and failing hostname verification. The wildcard follows RFC 6125: it stands for exactly one leftmost label, so it covers www.example.org, never a.b.example.org, and never example.org itself, which only a certificate naming it covers. A name a certificate is filed under always wins, because that is the certificate issued for it.
This answers on the handshake path, once per ClientHello, so it reads no file, opens no store, parses no certificate and takes no lock. Everything it needs was worked out when SET was built.
edge-credential-for-name
(edge-credential-for-name set name)
The credential SET holds filed under NAME, or NIL when it holds none and NIL when SET is NIL. No listed name and no wildcard is consulted: a renewal lands on the credential of the name it renewed and a rebuild adopts the base it holds by name, and an answer from a leaf that merely lists or covers NAME would put one name's certificate on another's leaf. Ask edge-credential-covering-name for the certificate a client asking for NAME is served.
edge-credential-set-base
(edge-credential-set-base instance)
Undocumented: this exported symbol needs a docstring.
edge-credential-set-count
(edge-credential-set-count set)
How many names SET holds a certificate for, counting each name a certificate is filed under, each wildcard it covers and each other name it lists once; 0 when SET is NIL. This is the number an operator reads at boot to see whether the edge picked up every name it was meant to. A count that says one where two were expected is the failure this exists to make visible, and it belongs in the boot output rather than in an off-host discovery weeks later. I count the wildcards and listed names because a leaf whose names went unread serves the one name it is filed under and nothing else, and that loss has to show here as well.
edge-credential-set-names
(edge-credential-set-names set)
The names SET holds a certificate for, sorted, or NIL when SET is NIL. A name a certificate is filed under appears as itself. A wildcard a held certificate covers appears in its own spelling, *.example.org. A name a held certificate only lists appears with (listed) after it, so the boot line tells a name custody holds a certificate for apart from one that rides on another name's leaf. For the boot line and for a test asserting which names were admitted, never for the handshake path.
edge-credential-set-p
(edge-credential-set-p object)
Undocumented: this exported symbol needs a docstring.
edge-custody-domains
(edge-custody-domains &key store-path)
Return (values DOMAINS STATUS): the domain names the ACME custody store holds a complete certificate pair for, and how that answer was reached. STATUS is :READ when the store was enumerated and :CUSTODY-UNREADABLE when it refused to be read. STORE-PATH roots the store (a launcher/test seam); NIL selects the default.
An unreadable store and a store holding nothing both produce an empty list and mean opposite things, so the answer says which it is. A caller that logged them alike would report a node serving no name where in fact it could not see the material it holds. Reached by symbol-call, so this file takes no load-time dependency on the ACME client package.
live-edge-name-coverage
(live-edge-name-coverage filed-name names &key store-path (name +default-tls-edge-name+) handle)
Say, for each of NAMES, whether the live :443 listener hands a client asking for it the certificate filed under FILED-NAME. Returns a list of (NAME . STATUS) in the order of NAMES, or NIL when no live listener is admitted under NAME and no HANDLE was given. A wildcard in NAMES is asked about as the wildcard itself. STORE-PATH roots the custody store; NIL selects the library default.
STATUS is :served; :other-certificate when the listener hands the name a different certificate; or :not-covered when it holds nothing for the name. When the listener holds nothing filed under FILED-NAME at all, every name gets the one reason that is established for it: :not-admitted when custody holds a usable certificate there, so the only thing keeping it off the edge is that FILED-NAME is not a name this node serves; :unusable when custody holds no certificate there that loads; and :custody-unreadable when the store could not be opened to tell those apart.
I made this read what the listener selects rather than what was asked for, so the owner is never told a name is served when a client asking for it would be handed another certificate or none.
load-edge-credential-cell
(load-edge-credential-cell domain &key store-path)
Source the shared mercer server-credential cell for DOMAIN from the ACME custody store the renewal manager persists issued certs to, and return it, or NIL when DOMAIN is NIL or no usable cert+key is in custody yet. This is the cert-gate at the sourcing layer: a boot before issuance sources no cell, so the :443 edge stays dark and bring-up still completes. STORE-PATH overrides the default store location (a launcher/test seam).
The cell is constructed ONCE and returned by reference; the caller passes the SAME object to start-fabric as :edge-credential-cell, so the listener holds it by reference and a renewal's reload-live-tls-credential swaps THIS exact cell.
A present-but-unreadable cert (corrupt PEM, a key that will not parse) is logged and treated as dark rather than crashing the boot; the durable cert is retried on the next renewal.
load-edge-credential-set
(load-edge-credential-set domains &key store-path base-domain base-credential)
Build and return the edge's credential set over DOMAINS: one credential for each name the ACME custody store holds a usable certificate pair for. Returns NIL when not one credential could be built, which leaves the edge exactly as dark as a node holding no certificate is today.
STORE-PATH roots the custody store (a launcher/test seam). BASE-DOMAIN names the domain whose credential is the set's base, the certificate presented when no better choice can be made. BASE-CREDENTIAL, when supplied, is adopted as that base by reference rather than loaded a second time: the listener is wired against that exact cell and a renewal reloads it in place, so a second copy taken here would go stale at the first renewal and be presented for the base name from then on.
Every other name a held certificate carries is indexed here too, each listed name and each wildcard, so a client asking for any name a held certificate verifies for is served that certificate. I read the certificates' names now rather than at the handshake because the handshake must stay a table read. When two held certificates carry the same name or the same wildcard, the one admitted first keeps it, so the answer does not depend on table order.
A name with no usable certificate in custody is skipped and the remaining names are still built, so losing the material for one name costs the node that name and never the edge. The store is opened once for the whole build.
reload-held-credential-from-custody
(reload-held-credential-from-custody domain &key store-path (name +default-tls-edge-name+) handle)
Swap in place, from what custody now holds for DOMAIN, the credential the live :443 listener already holds filed under DOMAIN, and return that credential. Returns NIL, and adds nothing, when no live listener is admitted under NAME and no HANDLE was given, or when the listener holds nothing filed under DOMAIN. STORE-PATH roots the custody store; NIL selects the library default.
I made a certificate ordered again for a name the edge already serves land on the credential the edge holds for it, as a renewal does, because a rebuild of the served names keeps the base name's credential where it stands. Without this, a reissued base certificate would sit in custody while clients were handed the one it replaced, and every name it adds would be indexed to that older certificate. A connection already accepted keeps the snapshot it handshook against.
reload-live-tls-credential
(reload-live-tls-credential domain new-chain new-key &key (name +default-tls-edge-name+) handle)
Hot-swap the live :443 listener's certificate for DOMAIN in place, from the freshly-issued NEW-CHAIN and NEW-KEY (an already-parsed boomer chain and Ironclad key), and return the credential that now carries them. The running serve loop is resolved from HANDLE when supplied, else from the handle admitted under NAME.
I made the swap name its domain because the listener presents a certificate per name now. A renewal of any name the node holds lands on that name's credential and on nothing else, so renewing one name can never put its certificate on another name. Before this, every renewal swapped the listener's one credential, and the only thing keeping a renewal of a second name from taking the primary name off the air was that the renewal driver withheld the request.
A name the listener already holds a credential for is swapped where it stands, so in-flight connections keep the snapshot they handshook against and the next handshake for that name presents the new certificate. A name it holds nothing for yet, which is a first issuance for a name admitted after boot, gains a credential of its own, installed as a whole new set in one reference store.
Returns NIL when no live listener is admitted under NAME (and no HANDLE was given), or when DOMAIN is no name at all. A swap onto a listener that is not up is a no-op the renewal path treats as a miss to log, not an error to raise: the durable cert is still persisted for the next boot.
replace-live-edge-credential-set
(replace-live-edge-credential-set build &key (name +default-tls-edge-name+) handle)
Replace the live :443 listener's credential set with the one BUILD returns when called with the set it holds now, and return the new set and T. Returns NIL and NIL when no live listener is admitted under NAME and no HANDLE was given.
I want a change to the names a node serves to reach the running edge, not wait for a restart. The new set is installed in one reference store, so each connection accepted afterwards reads the new set whole, and a connection already accepted keeps the set it read. Nothing is torn down. When BUILD signals, the set the listener holds stays as it is and the condition is passed on.
BUILD runs without holding the install lock, so a build that reads a slow store never holds off a renewal. A renewal that installs a credential while BUILD runs would be lost if the build's answer were installed over it, so the build is run again from the set the renewal left, and only a build that saw no renewal is installed. That is tried at most credential-set-rebuild-passes times. When renewals land under every pass, config-rebind-incomplete is signalled and the listener keeps the set it holds: building under the lock instead would hold every renewal off for as long as the slowest store takes to answer.
retire-tls-serve
(retire-tls-serve &key (name +default-tls-edge-name+) (drain-timeout *tls-serve-drain-timeout-seconds*))
Retire the live :443 serve loop admitted under NAME: deregister the steered source, drain in-flight workers (bounded by DRAIN-TIMEOUT), close the source, unregister the serve protocol (dropping the captured credential cell from the hot path), and forget the handle. Each step is error-isolated. Idempotent: a NAME with no live handle is a no-op. Returns the list of retired ports, or NIL when no serve loop was wired under NAME.
start-tls-serve
(start-tls-serve &key tcp-fd tcp-port credential-cell credential-set (name +default-tls-edge-name+) (mux (and *listener* (listener-mux *listener*))) (make-tcp-source (function make-steered-edge-stream-source)) (make-bound-source *edge-source-constructor*))
Wire the :443 TLS edge behind the shared CREDENTIAL-CELL and serve it over MUX under NAME. Returns a tls-serve-handle for teardown, or NIL when either gate refuses the open: the credential cell holds no usable chain+key (cert-gated), or the node has no descriptor for the port and no way to come up on one of its own (port-gated). Neither gate signals and neither builds a source or registers a protocol, so the port simply never opens and the resident still comes up. A caller with a good certificate but no port loses :443, not the node it was serving alongside.
The edge is the structural sibling of start-dns-serve for ONE stream source, and it has no opinion about how its port reaches it. A descriptor in hand is adopted through MAKE-TCP-SOURCE with no second bind; with none, MAKE-BOUND-SOURCE is asked for a source, and that is the node's one development self-bind rather than a decision taken here. A caller that supplies neither gets no listener at all. Either way the port the protocol registers under (and the loop's teardown identity) is recovered from the descriptor itself, not from TCP-PORT (which the boot path may leave nil even while handing over a live fd): the recovered port is exactly what a connection reports as its destination-port, so registering under it is what lets the registry route a request at all.
The single stream source reuses the edge budget-executor: a stranger-facing connection-oriented port gets the proven per-port concurrency cap (at cap the listening source is deregistered and further connections wait in the kernel backlog) plus the live counter teardown drains on.
Fail-closed, no partial state: both gates run before any source is built, the source is tracked the moment it is built, and the protocol is registered only once the source exists and its port is recovered, so a failure recovering the port or registering tears the source down rather than leaving a half-wired listener. MAKE-TCP-SOURCE and MAKE-BOUND-SOURCE are both injectable, so a test can force a second-stage failure that proves the unwind, and a boot that supplies neither cannot open a socket of its own choosing at all.
CREDENTIAL-SET is optional and carries the credentials the node holds for the other names it serves. It is held beside CREDENTIAL-CELL, never in place of it: the cert gate still judges the cell alone, so a caller that supplies no set gets exactly the listener it gets today.
stop-tls-serve
(stop-tls-serve handle)
Tear down a :443 serve loop wired by start-tls-serve, symmetric to it and error-isolated: deregister the source from the mux (stop feeding the seam first), close the source, then unregister the serve protocol so the captured credential cell is dropped, and forget the handle. Each step tolerates an already-gone piece. Does NOT drain. A NIL HANDLE is a no-op (nothing was wired).
tls-serve-handle-ports
(tls-serve-handle-ports instance)
Undocumented: this exported symbol needs a docstring.
Variables
*tls-serve-handles*
Live :443 serve loops keyed by the admitted name (normalized via STRING). start-tls-serve records its handle here on success; stop-tls-serve and retire-tls-serve remove it. This is what lets a name-keyed retire reach a wired serve loop, mirroring dns-serve-handles.
Package valis/src/edge/transfer-controller
Conditions
runciter-transfer-authorizer-unavailable
Signalled by bind-transfer-authorizer when runciter has not exported the injection binder. The fail-closed seam guard: valis never leaves the transfer emit path running without its authorization verdict wired in, so an absent runciter binder refuses the injection outright.
transfer-authorizer-contract-version-mismatch
Signalled by bind-transfer-authorizer when valis's transfer- allowlist contract version and runciter's transfer-source contract version disagree. The fail-closed seam-version guard: a version skew across the repo boundary is refused before the binder is funcalled, never silently bridged.
Generic functions
transfer-authorizer-contract-version-mismatch-runciter
(transfer-authorizer-contract-version-mismatch-runciter condition)
Undocumented: this exported symbol needs a docstring.
transfer-authorizer-contract-version-mismatch-valis
(transfer-authorizer-contract-version-mismatch-valis condition)
Undocumented: this exported symbol needs a docstring.
Functions
bind-transfer-authorizer
(bind-transfer-authorizer)
Inject valis's transfer-authorization verdict into runciter's authorize-before-emit gate, returning whatever runciter's binder returns. The upward mirror of bind-dns-view: resolve runciter's binder late (never imported) and refuse fail-closed with runciter-transfer-authorizer-unavailable when runciter has not exported it: runciter is a hard dependency for the transfer emit path and the wire never leaves that gate unauthorized. Assert valis's transfer-allowlist contract version equals runciter's transfer-source contract version BEFORE the funcall (refuse-before-act, signalling transfer-authorizer-contract-version-mismatch on skew) belt-and-suspenders with runciter's own binder-side version guard. On a clean handshake, funcall the binder POSITIONALLY as (binder AUTHORIZER CONTRACT-VERSION) with the self-contained authorizer closure (%transfer-authorizer) and valis's contract version.
Package valis/src/edge/zone-notify
Conditions
notify-source-address-unbound
Signalled when the outbound NOTIFY socket could not be bound to the required egress source address, when it bound a different address than the one asked for, or when the requested source is a wildcard. A wildcard is refused because it delegates the choice of source address to the routing table, which is the failure this whole seam exists to prevent. CAUSE carries the underlying condition when there was one, so the distinction between an unassignable address and a mismatched one survives.
The seam raises its own condition for all three and it is re-signalled as this one at the boundary. An egress source that cannot be held is a fact about this node, and the boot path, the log line and the tests all speak of it as a reason NOTIFY is unavailable here rather than as one dial that failed.
notify-source-of-another-family
Signalled when every recipient a round tried to reach holds no address of the egress source's family. One such recipient is a fact about that recipient and is contained; all of them is a fact about the node, because its source can reach none of the secondaries it is configured to notify, so the round is abandoned as it is for a source the node cannot hold. CAUSE carries the dial failure of the last recipient tried.
zone-notify-transport-unavailable
Signalled when runciter's real transfer wire cannot be resolved. The wire is what frames a NOTIFY, so its absence is a refusal to arm rather than a round that silently notifies nobody.
zone-notify-unavailable
Parent of the reasons this node cannot emit a NOTIFY at all. Distinct from the per-round refusals, which are returned as verdicts rather than signalled: a verdict says a round reached nobody, this says the notifier itself cannot be stood up here.
Generic functions
notify-source-address-unbound-bound
(notify-source-address-unbound-bound condition)
Undocumented: this exported symbol needs a docstring.
notify-source-address-unbound-cause
(notify-source-address-unbound-cause condition)
Undocumented: this exported symbol needs a docstring.
notify-source-address-unbound-requested
(notify-source-address-unbound-requested condition)
Undocumented: this exported symbol needs a docstring.
Functions
arm-zone-notify
(arm-zone-notify dial-capability &key (source-address (or *notify-source-address* (resolve-transfer-master-address))) (port *notify-destination-port*) (recipients-fn (function notify-recipient-set)))
Register the NOTIFY listener on the zone-change feed, so every committed serial advance drives a round through DIAL-CAPABILITY. Returns the registered listener spec.
DIAL-CAPABILITY is the authority every round dials on, handed in rather than reached for: the notifier holds what it was given and can reach nothing else, and what that authority permits is worked out at the node on each dial.
Refuses fail-closed BEFORE registering anything: runciter's transfer wire must be resolvable, and SOURCE-ADDRESS must actually bind on this node. The trial dial is the point. It turns the egress contract into a boot-time assertion, so a node that does not hold the primary's address says so at startup rather than emitting NOTIFYs that every secondary silently discards. A node that is not the primary is expected to fail here; the boot path declines ZONE-NOTIFY-UNAVAILABLE and carries on.
RECIPIENTS-FN, PORT and SOURCE-ADDRESS are captured by the listener, so the round a commit drives is aimed by what was configured at arm time. RECIPIENTS-FN defaults to the operator-state derivation and is the seam a test aims at a loopback receiver.
IDEMPOTENT: re-arming replaces the listener, because the underlying registry is name-keyed and replaces on re-register.
disarm-zone-notify
(disarm-zone-notify)
Retire the NOTIFY listener and drop the held retransmit state, so neither outlives the serve loop. Idempotent: a second call is a no-op. Returns T when a listener was registered and removed.
emit-zone-notify
(emit-zone-notify origin serial dial-capability &key (source-address (or *notify-source-address* (resolve-transfer-master-address))) (port *notify-destination-port*) (recipients-fn (function notify-recipient-set)) (now (get-universal-time)))
Drive one NOTIFY round for the committed revision of ORIGIN at SERIAL through DIAL-CAPABILITY. Returns (values VERDICT RESULTS) exactly as runciter reported them.
DIAL-CAPABILITY is required and positional, mirroring the seam it reaches: there is no arity of this function that puts a NOTIFY on a wire without the authority to do so having been handed in.
VERDICT is runciter's own, and every one of its refusals is passed through unchanged rather than collapsed: :SENT, :WAITING, :EXHAUSTED, :NO-SOURCE-ADDRESS, :NO-TRANSPORT, :NO-RECIPIENTS, :NO-USABLE-RECIPIENTS and :NO-REACHABLE-RECIPIENTS, plus :NO-TRANSFER-WIRE when runciter's transfer subsystem is not loaded here. RESULTS is runciter's per-recipient plist list, so a caller can see which recipient was framed for and which was not. A round that notified nobody is never reported the same way as one that did.
SOURCE-ADDRESS is proved before any recipient is considered, so a node that cannot hold that address refuses the round loudly rather than framing a NOTIFY the secondary would discard. Each recipient is then dialled as it comes up, and the source is bound and proved again on that recipient's own socket, which is where the guarantee actually has to hold. A recipient that cannot be dialled is framed for nobody and reported unreachable, exactly as one that could not be turned into an address was before. When not one recipient tried holds an address of the source's family, the round signals NOTIFY-SOURCE-OF-ANOTHER-FAMILY instead of returning a verdict: the source cannot reach any secondary this zone notifies, which is a fact about the node. The zone the round is for is offered to the node while the round runs, so a grant naming this node's secondaries is decided against the secondaries THIS zone is configured to notify.
RECIPIENTS-FN is called with the canonical origin and defaults to the operator-state derivation, which reads through the caller's live operator-state connection. NOW is injected; runciter reads no clock of its own. The advanced retransmit state is held for the next round.
zone-notify-armed-p
(zone-notify-armed-p)
T when the NOTIFY listener is registered on the zone-change feed.
Variables
*notify-destination-port*
The port a NOTIFY is sent to. Held as a parameter so a test can aim a round at a loopback receiver and read the datagram that actually left.
*notify-source-address*
An explicit override for the address every outbound NOTIFY must leave from, or NIL to take the node's configured authoritative-DNS address at arm time. That is the same value rendered into a secondary's masters{} clause, because that clause is what authorises the notifier: a NOTIFY from a different source is dropped by the receiving nameserver without a reply. Bound explicitly on the socket and asserted after the bind, never left to the routing table.
A node with neither an override nor a configured address holds no egress source, and arming refuses rather than emitting NOTIFYs every secondary discards.
+zone-notify-listener-name+
The zone-change feed registry key the NOTIFY listener is held under.