valis / Understanding the system
Cross-repo Seam Contracts: versioned interfaces
This is the versioned interface contract for the cross-repo seams every sister builds against. valis authors it because it is the interface contract (not any sister's internals) and valis is the lead repo. A sister builds against whatever this document says at the version it pins; nothing here describes a sister's own machinery.
Seams A and B are the v1.6 DNS resolution, serving, transfer, and secure-transport surfaces. Seam C is the v1.8 ACME-spine addition: the zone-write mutation surface the ACME manager drives to answer a dns-01 challenge out of valis's own authoritative zone.
Read CONSTELLATION.org first for who owns what. This file is the contract between those owners for the specific surfaces v1.6 turns on.
Governance: versioned interfaces and fail-closed behaviour
- A seam is versioned before either side builds against it. The pinned version is the contract of record. A build against a mutating interface is the failure this document exists to prevent.
- Evolution is additive-via-vN, only. A surface never mutates in place. A changed obligation is a new version N+1 carried by a new named contract-version constant; the old version keeps its meaning. Sisters negotiate the version they speak; a skew is a refusal, never a reinterpretation.
- Every seam surface carries its own named contract-version constant. The surfaces
below each pin a constant (e.g.
+dns-resolver-codec-contract-version+). The value is what one side declares and the other admits; an admitted set is explicit. - Fail closed on an absent pinned symbol or a version skew. A surface whose pinned
call symbol is not present, or whose declared contract version is outside the admitted
set, refuses: it never falls back to a stale local answer and never falls back to a
cleartext channel. The refusal is the contract's safety property, and it is directly
modelled on two proven valis precedents:
- The versioned register-time refusal: valis's DNS adapter seam
(
src/plugin/dns-adapter.lisp).register-dns-adapterruns a fail-closed version check first: a declaredcontract-versionoutside*supported-dns-contract-versions*signalsdns-adapter-version-mismatchand adds no registry entry (refuse-before-act, no partial state). Its pinned constant is+dns-adapter-contract-version+(currently1). Every seam surface below mirrors this shape: a named version constant, an admitted set, and a refuse-before-act check on skew. - The late-bound refuse-on-absent-symbol: valis's serving seam
(
src/operator-state/runciter-serving-seam.lisp). Each wrapper resolves the real runciter symbol at call time viafind-symbolinrunciter/src/mainand delegates; a wrapper whose contract symbol is notfboundpsignalsrunciter-serving-unavailablerather than fabricating a local answer (%require-servingis the guard). Every seam surface below mirrors this: an absent pinned symbol is a hard refusal at the boundary, never a locally-invented result. No secret ever crosses such a seam: only public opaque references (the serving seam carries only a public opaque upstream-master reference).
- The versioned register-time refusal: valis's DNS adapter seam
(
- The symbols below start as valis's proposal as the lead, and a sister confirms the exact
names + arity before ship. The shape (which side owns framing, which argument carries
the injected stream, the fail-closed behaviour) is locked from the outset and does not
move. The transfer-codec (A.3) and TSIG (B.2) surfaces are confirmed: their names and
arity are pinned at contract version
1. The remaining surfaces stay marked proposed until their owning sister confirms them the same way.
Seam A: runciter ↔ c3po
runciter (the DNS service module) drives c3po's transport-agnostic codec over an
injected stream. c3po never owns a socket and never owns TLS: it encodes onto, and
decodes from, a stream some other layer hands it. runciter owns the outbound usocket
socket and all policy; valis routes the inbound fd and interprets no bytes. Three
versioned sub-surfaces.
A.1: Resolver DoT codec
Net-new for v1.6: the resolver wire was stubbed in v1.5; this surface turns it on.
- Direction. runciter → c3po encode of an outbound query; c3po → runciter decode of the upstream response. The bytes flow over an injected, already-TLS-decrypted stream (mercer opens and seals it, Seam B.1); c3po sees only the plaintext DNS stream.
- Call interface (proposed).
encode-query (query stream)→ writes one RFC 7766 length-prefixed DNS message to the injectedstream.decode-response (stream)→ reads one length-prefixed message from the injectedstream, returns the decoded response.
- Framing ownership: c3po. c3po owns the RFC 7766 §8 2-octet length-prefix framing over the stream. It owns compression-pointer-loop rejection and a hard name-expansion cap (a parser-DoS bound, not best-effort), the RFC 1035 message-length ceiling, and RR-count/decompressed-size bounds before allocation.
- Response binding: c3po. Each response is bound to its outstanding query by message ID + exact question (QNAME/QTYPE/QCLASS); an unmatched or duplicate response is discarded (mandatory for correctness over a reused RFC 7766 connection, not merely anti-spoofing).
- Consumer seam: runciter. runciter drives the codec through its
resolve-over-wireconsumer seam and owns the outboundusocketsocket, MX policy, lookup budget, timeouts, and the TTL/negative cache. c3po holds none of this. - Contract version.
+dns-resolver-codec-contract-version+=1(net-new). - Fail closed. An absent codec symbol or a version skew makes runciter refuse and map
the outcome to DEFER at the mail drain, never a cleartext
:53retry, never a fabricated answer. A transient or transport failure is DEFER, never a drop and never a bounce (ambiguity resolves toward DEFER).
A.2: Serving :53 codec exercise
- Direction. Inbound: c3po → runciter decode of a query arriving on the injected
:53stream; runciter → c3po encode of the composed response back onto that stream. The stream is the steered:53fd fulcrum hands valis and valis admits to the wire module. - Call interface (proposed).
decode-query (stream)→ reads one query from the injected:53stream(UDP datagram or the RFC 7766 2-octet-prefixed TCP form), returns the decoded query.encode-response (response stream)→ writes the composed response to the injectedstream.
- Robustness: c3po. Malformed-packet robustness is c3po's: a truncated or garbage datagram or TCP stream is dropped or answered FORMERR, never a crash and never a leak. c3po echoes the query's txn-id and mirrors QNAME/QTYPE/QCLASS exactly; it never rewrites the question.
- What c3po does NOT own. No socket, no
:53serve loop, no accept loop, no RRL, no answer composition, no authority reasoning. RRL and EDNS size discipline are the wire engine's serving concern; answer semantics (AA, NXDOMAIN/NODATA, apex-SOA, wildcard, CNAME chase) are runciter's: c3po frames what runciter composes. - Contract version. This surface additively exercises the existing serving seam over
the wire: it introduces no new message format. It rides valis's existing DNS adapter
seam constant
+dns-adapter-contract-version+(currently1) and stays at that version; any future change to the decoded-query → response handler contract is an additive bump of that same constant, not a new format here. - Fail closed. The steered
:53fd is served only after capability-gated admission succeeds (valis's concern); an absent decode/encode symbol or a seam-version skew refuses the bind: the fd is not wired to a handler, never a partial or unauthenticated responder, never a valis-local answer.
A.3: AXFR/NOTIFY framing + canonical multi-message stream digest
- Direction. c3po frames AXFR (TCP-only, QTYPE 252) and NOTIFY (OPCODE 4) messages in both directions over the injected transfer stream, and computes the canonical byte digest that mercer signs/verifies (Seam B.2). c3po holds no keys and no policy; runciter owns the transfer source/sink policy and the allowlist; valis owns the zone data and the permitted-key-name allowlist.
- AXFR framing: c3po. The stream begins with the zone SOA and ends with the identical SOA; intermediate messages MUST NOT carry that SOA and the closing SOA is the terminator. Any RR ordering/grouping and multiple RRs per message are accepted; the transfer is a multi-message stream.
- Canonical digest: c3po. c3po computes the RFC 8945 §5.3 canonical byte digest for the TSIG stream chain: the first request's MAC seeds the chain; each response message's digest prepends the prior message's MAC; the first and last messages are signed; a receiver accepts up to 99 unsigned intermediary messages but verifies at least every 100th and MUST verify first + last. c3po produces the canonical bytes only; mercer performs the HMAC over them (Seam B.2): no key or MAC-secret is in c3po.
- Call interface (confirmed). The sisters have confirmed the exact names and arity; the
arity below is pinned at contract version
1.encode-axfr-message (message stream &key framing tsig-rr-fields): frame one transfer message on the injected stream (SOA-first/SOA-last invariant enforced by the framer). A non-niltsig-rr-fieldsframes the TSIG RR (TYPE 250) last in the additional section (RFC 8945 §5.1) and increments ARCOUNT; omitting it yields an unsigned message.decode-axfr-message (stream &key framing)→(values message tsig-rr-fields|nil): anilsecond value means no TSIG RR was present (an unsigned message).encode-notify/decode-notify: frame a NOTIFY / its reply.encode-tsig-rr/decode-tsig-rr: the TSIG RR RDATA ↔ keyword-plist wire codec (owner and algorithm names carried uncompressed).canonical-transfer-digest (messages prior-mac)→ the canonical octet vector mercer HMACs. The first argument accepts either a single message or the ordered list of messages framed since the last TSIG (the signed message last); a single message is the one-element run. The byte layout is:prior-mac=nil(chain origin / a true seed):canonical-wire(m1) || … || canonical-wire(mk).prior-macnon-nil(envelope):(uint16, network order, = len(prior-mac)) || prior-mac || canonical-wire(m1) || … || canonical-wire(mk).
Each
canonical-wire(mi)is that message with its TSIG RR removed and ARCOUNT decremented; on the verify path it is the exact received wire bytes, retained, never a re-encode. AXFR permits name compression (RFC 5936 §2.2), so a re-encode would drift the bytes and spuriously fail the MAC. The crypto holder folds the TSIG-variable tail after this vector; the codec performs no crypto.
- Prior-MAC and full-vs-timers are orthogonal. The full/timers-only choice is mercer's
(Seam B.2,
first-message-p); the seed-vs-envelope layout is this digest's. They do not imply one another: (1) the AXFR request isnil-seeded and carries full variables; (2) the first answer message folds the request's MAC as a non-nilprior-mac(so envelope, length-prefixed layout) yet still carries full variables; (3) subsequent answer envelopes fold the running prior-MAC and carry timers-only. A first-message flag does not imply seed layout. - Streaming. The transfer source signs every message (so each digest is a one-element run; sparse per-100th signing is a deferred optimization). The sink accumulates the up-to-99-unsigned-intermediary run and digests it as the list when the next signed message arrives, required to verify a primary that batches unsigned intermediaries.
- Ownership and socket roles. The transfer service (runciter) owns the prior-MAC and first-message chain-state threading (per stream), the up-to-99-unsigned / verify-first+last+every-100th counter, and the authorize-before-emit gate (TSIG-verify and peer allowlist, fail-closed, zero zone RRs before both pass). Source-side AXFR serving and inbound NOTIFY arrive as a steered/injected stream: the service never opens a listener; the sink-side AXFR pull is the service's own outbound TCP connection authenticated by TSIG (not TLS in this slice). The NOTIFY-send retransmit schedule (RFC 1996 §3.6) is the service's; the notify-recipient set (zone NS records + also-notify) is the zone-data owner's (valis).
- Contract version.
+dns-transfer-codec-contract-version+=1(net-new; the arity above is the confirmed shape of v1, pinned before ship, not a bump). - Fail closed. An absent framing/digest symbol or a version skew refuses the transfer: no RR, and no leading SOA, reaches the wire; a lax verifier that accepts an unsigned last message or skips the chain is a contract violation. Authorization (TSIG-verify + allowlist) is a hard precondition upstream of any byte of zone data (see Seam B.2 and runciter policy); the refusal emits zero zone RRs.
Seam B: c3po ↔ mercer
mercer holds all key material and owns the transport cipher; c3po frames bytes and computes canonical digests but touches no key and no socket. Two versioned sub-surfaces. No secret ever crosses this seam: a key is referenced by name only, mirroring the serving seam's "no secret crosses this seam; only a public opaque reference" discipline.
B.1: DoT authenticated transport stream
The single highest-risk surface in this contract. An encrypted-but-unauthenticated channel silently defeats the milestone: it looks secure while being MITM-able, and a passing MX lookup and a MITM'd MX lookup are indistinguishable from the drain's side. This surface is why this contract exists.
- Direction / ownership. mercer's secure-transport provider (its charter's "TLS later",
added this milestone alongside the existing Noise provider) opens, seals, and unseals
the authenticated stream that c3po's resolver codec (A.1) runs over. mercer owns the
TLS; c3po never sees the socket or the TLS; runciter owns the outbound
usocketsocket mercer seals over. - Call interface (proposed): provider-agnostic.
open-authenticated-stream (socket authentication-domain-name)→ an authenticated duplex stream, or a hard transport error.authentication-domain-name(ADN) is the identity the peer certificate must match.seal (stream octets)/unseal (stream): the provider-agnostic sealing verbs (Noise today, the TLS/DoT provider added this milestone); the resolver codec reads and writes the unsealed plaintext DNS stream.
- Strict-Privacy mandate (RFC 7858 / RFC 8310). TLS 1.2+; full PKIX certification-path
verification (RFC 5280) with ADN identity match against
subjectAltNameper RFC 6125: SAN, never theSubjectfield; SNI = ADN on every handshake; TLS compression off. - Contract version.
+mercer-secure-transport-contract-version+=1for the DoT/TLS provider surface (the provider-agnostic seam predates it; this pins the TLS provider's admitted version). - Fail closed: no cleartext fallback. Any TLS or peer-authentication failure returns a
hard transport error, never a plaintext socket, and there is no
:53cleartext fallback path anywhere in the contract. runciter maps that error to DEFER at the mail drain boundary, and valis re-asserts the no-cleartext-fallback contract at its injection point. A launch test proves a deliberately-wrong-cert upstream yields connection-abort → mail-deferred, not delivery.
B.2: TSIG sign/verify over c3po's canonical digest
- Direction / ownership. c3po computes the canonical multi-message digest (A.3); mercer HMAC-SHA256 signs it (as source) and verifies it (as sink). mercer is the sole holder of the secret; c3po and runciter reference a key by name only.
- Call interface (confirmed). The sisters have confirmed the exact names and arity; the
arity below is pinned at contract version
1.sign-digest (key-name canonical-digest &key fudge first-message-p)→(values mac tsig-rr-fields): the TSIG MAC and the TSIG RR fields forcanonical-digest(Seam A.3) under the key namedkey-name.verify-digest (key-name canonical-digest tsig-rr-fields &key first-message-p)→t, or a TSIG error condition (errors carried under RCODENOTAUTH(9)).key-nameis a domain name identifying the shared key;canonical-digestis c3po's canonical octet vector; only the key name (never key bytes) crosses the seam.first-message-pselects RFC 8945 §4.3.3 full TSIG variables (t: the first or a standalone signed message) vs §5.3.2 timers-only (nil: an envelope / subsequent message). The caller (the transfer service) supplies it on both sign and verify.- No
prior-time-signedargument. Anti-replay is delivered by the fudge-window BADTIME check plus the MAC chain, not a monotonic-timestamp slot. - Signed error responses are out of this slice (RFC 8945 §5.2.3, e.g. a signed
BADTIME): a verify failure signals the TSIG error condition (carrying the code and the
server's 48-bit time); no signed error RR is emitted. Supporting signed errors later is
a purely additive
&key (error other-data)that defaults to today's behaviour, still v1. tsig-rr-fieldsis a keyword plist, not a shared struct, keeping the type from crossing the package boundary between the crypto holder and the codec. Its keys are the RFC 8945 §4.2 TSIG RDATA wire fields plus the owner name::key-name:algorithm:time-signed:fudge:original-id:error:other-data:mac. MAC-size and Other-Len are derived from octet-vector lengths, not carried as keys. The codec parses/serializes this plist; the crypto holder normalizes it into its own internal representation at the boundary and reads/writes:macand:time-signed=/:fudge= as opaque wire values.
- Invariants mercer owns. The secret never leaves mercer (referenced by key name only across the seam). Constant-time MAC compare is mercer's own job: Ironclad supplies the HMAC-SHA256 primitive only, and its non-hardened caveat is exactly why the constant-time compare is mercer's, not Ironclad's. The fudge window (default 300 s) and anti-replay (reject a later message bearing an earlier Time Signed) live in mercer. HMAC-SHA256 is the baseline; HMAC-MD5 is never originated.
- Error set.
BADSIG (16)MAC mismatch,BADKEY (17)unknown key/algorithm,BADTIME (18)clock outside fudge (with the server's 48-bit time in Other Data),BADTRUNC (22)MAC below policy minimum; all carried under RCODE 9 (NOTAUTH). - Contract version.
+tsig-contract-version+=1(net-new; the arity above is the confirmed shape of v1, pinned before ship, not a bump). - Fail closed. An absent
sign-digest/verify-digestsymbol or a version skew refuses: the transfer is not authenticated and no zone data is served. The authorization decision (TSIG-verify against an allowlisted key AND an allowlisted peer) is a hard precondition before any zone byte is read or framed; TSIG gates both AXFR requests and NOTIFY messages. No key bytes cross the seam.
B.3: mercer server-TLS termination seam
Net-new for v1.8, contract-version 2: the server mirror of B.1's client seam
(open-authenticated-stream), consumed by valis's public :443 edge.
- Direction / ownership. mercer owns the server-authenticated TLS termination seam; the valis edge is the consumer. The edge accepts a raw TCP connection and hands the already-accepted socket to mercer; mercer terminates a TLS 1.3 handshake presenting a certificate it holds custody of, and returns an authenticated plaintext octet stream the edge then reads and writes directly. The TLS record layer lives inside mercer's provider; the edge never sees TLS, key material, or the certificate store.
- Call interface (the whole consumer surface).
(open-authenticated-server-stream socket credential &key alpn-protocols credential-resolver)→ an authenticated raw(unsigned-byte 8)stream on a completed handshake, else a signalled transport error.socketis an already-accepted octet socket-stream the edge owns; mercer borrows it.credentialis an opaque mercer custody handle.alpn-protocols, when supplied, is offered to the client for negotiation (the edge's h2/http1.1 list).(with-authenticated-server-stream (var socket credential &rest opts) &body body), the scoped form: closes the TLS layer on exit while leaving the borrowed socket open. This is the form the valis:443edge consumes, atsrc/edge/tls-serve.lisp.credential-resolver, added in contract version 2, is a function of one argument: the server name the client asked for, a string, orNILwhen the client sent none. It answers with the credential to present for that name, orNILto leave the basecredentialstanding. The name arrives unmodified, because the consumer owns the selection policy and mercer must not decide which names a node serves. boomer consults it once per ClientHello, so twice on a hello-retry, and it runs inside the handshake: a resolver this seam can carry has to be cheap and free of side effects, or it stalls the connection it is deciding for. A resolver that signals fails the handshake closed rather than presenting the wrong certificate.
- Custody handle (mercer-owned constructors; the edge only holds the result).
(make-server-credential &key certificate-chain private-key)→credential(load-server-credential &key certificate-file private-key-file)→credential(load-server-credential-from-store store domain)→credential(reload-server-credential credential new-chain new-key)→credential
- Questions a consumer may ask about a credential (contract version 2). Both answer,
neither signals, and neither returns key material. They exist so a consumer gating a
listener, or choosing which certificate to present, stops reaching the seam's internal
chain and key readers.
(credential-usable-p credential)→Twhen the current snapshot holds both a certificate chain and a private key,NILfor a credential missing either half andNILfor anything that is not a credential. Both halves come from one snapshot read, so the answer is never assembled out of a pair torn by a concurrent reload.(credential-matches-hostname-p credential hostname)→Twhen the leaf certificate answers tohostnameunder boomer's RFC 6125 identity matcher, which carries wildcard scope, IDNA normalization of the requested name, and outright rejection of an embedded NUL or other non-LDH byte. A string compare or a hand-written wildcard rule loses all three, and this predicate decides which certificate an anonymous internet client is shown.
- Server-authenticated, NOT mutual.
verifyis hard-pinned+verify-none+and the surface exposes no verify parameter: a consumer cannot request a client certificate or a no-certificate bypass. Mutual TLS, if ever wanted, is a separate deliberate surface. - Fail closed: no cleartext. Every boomer failure (cancel/deadline mid-handshake, a
handshake/alert/protocol failure, or an absent/unusable credential) is a signalled
transport-session-error(mercer's existing transport family, no new vocabulary), never a returned plaintext stream. There is no cleartext/downgrade path in the provider. - FD-borrow. mercer borrows the accepted socket and never owns its lifecycle: on any
handshake failure it signals and leaves the socket open for the edge to reclaim; on
success, closing the returned stream tears down the TLS layer (
close_notify) while the borrowed socket stays open. (boomer's tls-stream close unconditionally closes its underlying stream, so the seam interposes a no-op-close shield to hold the borrowed fd.) The edge owns connect/accept/timeouts/close. - Custody opacity. Leaf key+cert come from mercer custody (the ACME-issued cert). The
credentialencapsulates the key material; no exported accessor returns it, and the seam hands the edge only an octet stream. - Hot-reload without dropping connections.
reload-server-credentialpublishes a new immutable custody snapshot and atomically swaps the credential's single reference; it never mutates a live snapshot in place. The edge calls the seam once per accepted connection and reads one consistent snapshot per call, so a renewed cert is adopted on the next handshake while in-flight connections keep their own record-layer keys and are never dropped. Maps onto a systemdnotify-reloadhandler invokingreload-server-credential. - Contract version.
+server-tls-contract-version+=2. A consumer pins its cross-repo guard against this; bumped only on an incompatible surface change. Version 2 added the per-connectioncredential-resolverhook and the two credential predicates above. Both are additive, so a version-1 caller still compiles unchanged, and the bump is deliberate anyway: a consumer's guard is an equality check, and the bump is the only way that guard can tell it has been loaded against a mercer too old to carry the hook it is about to use. The valis:443edge holds that guard insrc/edge/tls-serve.lispas a load-toplevel refusal, so a drifted seam dies at load rather than mis-terminating a live handshake. - Scope. This seam is the mercer server-termination provider only. The valis
:443=/:80= edge that consumes the stream (accept loop, protocol dispatch, systemd socket, the reload signal handler) is a valis responsibility layered above this contract.
Seam C: valis zone-write ↔ ACME manager
Net-new for v1.8: the mutation the dns-01 ACME challenge drives. valis is its own
authoritative nameserver, so it proves control of a name to a CA by writing a transient
_acme-challenge TXT into its own live zone, letting the CA validate against valis's own
NS, then retracting it. This seam is the valis-side write surface the ACME manager calls;
it is built general (any rrset type), so it is also the substrate for operator dynamic-DNS
name control, not only ACME.
- Direction / ownership. The ACME manager drives the seam; valis owns the zone data and
performs the mutation. mercer computes the dns-01 key-authorization digest that becomes the
TXT rdata (Seam B holds the account key; no key crosses into this seam: only the public
digest string); runciter serves the published member on
:53; the CA validates against valis's authoritative answer. The ACME manager's home is a v1.8 decision (it is either a valis active-module holding capability-scoped egress, or mercer's outbound driver), so this surface is deliberately placement-agnostic:my-generation(the store write-epoch) is an explicit argument, never read from an assumed caller context. - Call interface (proposed).
publish-transient-rrset-member (origin owner type ttl rdata my-generation)→(values resolved-serial changed-p). Inserts one rrset member as a transient row and advances the zone serial once via the RFC 1982 auto-bump, mirroring the full-zone commit at single-row granularity and never the destructive full-zone replace. Idempotent: a re-publish of an identical member is a no-op:changed-pnil, no serial bump, no change signal.retract-transient-rrset-member (origin owner type rdata my-generation)→(values resolved-serial changed-p). Deletes only the transient member (the delete is keyed by the composite identity and the transient marker) and advances the serial once. Idempotent: retracting an absent member is a no-op with no bump.with-transient-rrset-member ((origin owner type ttl rdata my-generation) &body body): publishes, evaluatesbody, and retracts on every exit path including a non-local one. A convenience for a single in-process caller that owns the member for the macro's dynamic extent; a production ACME manager that spans an asynchronous CA poll owns its ownunwind-protectaround the publish/retract pair instead.export-durable-zone-master-text (origin)→ the zone's RFC 1035 master text built from durable rows only: a live transient member is never rendered, so it can never be baked into an exported master and re-imported as a permanent record. Distinct from the serving read, which continues to answer the transient member normally while it is live.
- The transience marker is the safety invariant. Every stored row carries a non-key
transientboolean. Because it is not part of the composite key, a transient member and a durable member can never coexist under one key: a retract can only ever remove a transient row, and a publish whose key collides with a durable record refuses (transient-durable- collision) rather than shadow or later delete it. A full-zone re-import preserves surviving transient members and warns the operator that one is live. The net guarantee: a published-then-retracted member leaves the durable zone byte-identical, serial advanced monotonically, with no leaked row. - Guards (targeted). A publish refuses a member into a zone that is not answerable as primary
(
zone-not-answerable), an owner outside the zone (name-outside-zone), a CNAME coexistence violation (cname-conflict), or a transient SOA (transient-soa-refused: a zone has exactly one SOA, so a transient one is never valid; an NS set, being legitimately multi-member, is allowed). Whole-zone structural invariants a single-member add cannot violate (the apex SOA's presence, apex NS presence) are not re-checked. - Contract version (proposed).
+zone-write-seam-contract-version+=1, to be introduced and pinned when the ACME manager side is built (v1.8) and its home is decided; the shape above (placement-agnostic epoch argument, idempotent publish/retract, the transient-marker safety invariant, the durable-only export) is locked from the outset and does not move. - Fail closed. Each mutation asserts the instance fence first inside its transaction: a superseded instance signals fenced-out and writes nothing (the split-brain stop). Every guard refuses before any row is touched (refuse-before-act, no partial state). Boundary not defended here (single-writer): a full-zone re-import that lands between a publish and the CA poll drops the member; detecting that and republishing is the ACME manager's concern (v1.8), not this surface: the fence guarantees no torn write, not member survival across a full replace.
Seam D: consumer ↔ hekate marshalling
Net-new: the marshalling sister. hekate is the crypto-free home of the identity and
capability wire codecs: the did:key codec, the capability-token wire codec, and the
capability-name wire codec. It is a pure library (no socket, no crypto, no service
logic) consumed by more than one sister: mercer signs the canonical bytes hekate lays out,
valis verifies and attenuates over the fields hekate decodes, and ubik decodes tokens and
names to render them. This seam is the API those consumers build against.
Unlike Seams A-C, no byte ever crosses a network here; the surface is an in-image library API. The governance shape still holds, translated: an absent exported symbol is a hard load-time failure (never a locally-invented codec), and the safety invariant is byte-identity rather than a fail-closed refusal, see below.
- Direction / ownership. hekate owns the codecs; the consumers own the meaning. Every entry point is a field-taking function over raw values (issuer/audience DID strings, the capability list, expiry, nonce, a bound generation, a raw key's octets), never a method that reads a valis (or ubik) object. That is the load-bearing shape: the codec depends on no consumer's types, so the dependency can never invert and hekate imports nothing from valis.
- Call interface:
hekate/src/did-key(proposed).raw-key->did-key/did-key->raw-key: the X25519 transport key ↔ itsdid:key.raw-ed25519-key->did-key/did-ed25519-key->raw-key: the Ed25519 signing key ↔ itsdid:key.
- Call interface:
hekate/src/token-wire(proposed).encode-token-canonical-bytes (issuer-did audience-did caps expiry nonce bound-generation): the deterministic canonical byte block a signer covers;capsis a list of(designation-string . rights-bitmask).encode-token-wire (canonical-bytes signature)→ thetoken:base58btc string;decode-token-wire (wire-string)anddecode-token-fields (canonical‖signature)→ atoken-fieldsstruct with accessors for issuer, audience, caps, expiry, nonce, bound generation, and signature. One decoder, returning a plain struct, never a CLOS method.
- Call interface:
hekate/src/name-wire(proposed).name-canonical-bytes (designation rights-bitmask expiry nonce bound-generation): the bearer-name canonical block.encode-name-wire (…)→ thevalis:base58btc string;decode-name-wire (name-string)→ abearer-namestruct with accessors for designation, rights bitmask, expiry, nonce, bound generation, and signature.
- The byte-identity contract (the safety invariant). The persisted form is durable: tokens and bearer names (and the DIDs inside them) sit in Postgres and are signed over their canonical bytes. So the contract is that the canonical bytes and the wire strings this codec produces are byte-identical to what it decodes, and stable across versions: a name already minted must re-encode to the same octets, or its signature stops verifying. The codec is therefore frozen against golden vectors captured from the pre-migration output; a re-encode that drifts a single byte is a contract violation, not a cosmetic one, because it silently invalidates every persisted grant. Byte-identity is to this seam what fail-closed is to the network seams.
- Contract version.
+hekate-marshalling-contract-version+=1(proposed; pinned when the consumers cut over). The shape (field-taking functions over raw values, a single struct- returning decoder, and byte-identity against the golden vectors) is locked from the outset.
Seam E: consumer ↔ c3po multiformats leaf
Net-new: the generic codec layer beneath hekate. c3po homes a standalone multiformats
leaf (base-N and self-describing-prefix mechanics with no protocol-module, socket, or
iolib dependency) that hekate builds its marshalling on. It is the Plan 9 precedent of a
generic base-N encoder sitting below, and separate from, the auth-marshalling library.
- Direction / ownership. c3po owns the leaf; hekate (and any module needing base-N)
consumes it. The leaf is importable without the c3po umbrella: a consumer depends on
c3po/src/multiformatsalone and never drags a protocol module oriolibinto its image. - Call interface (proposed).
base58-encode/base58-decode(base58btc, Bitcoin alphabet);encode-unsigned-varint/decode-unsigned-varint(LEB128-style, the decoder returning(values integer consumed-length));multicodec-prefixed/strip-multicodec- prefix(frame a payload behind a varint codec code, and invert it). - The byte-identity contract (the safety invariant). base58btc encoding, the leading-zero →
leading-
1rule, the varint layout, and the multicodec framing are byte-identical to the historical valis did:key mechanics they were lifted from, so adid:keyminted before the leaf existed decodes unchanged. As with Seam D, the leaf is frozen against known-answer vectors, and the no-umbrella-leak property (the leaf loads with no protocol module in the image) is itself part of the contract. - Contract version.
+multiformats-codec-contract-version+=1(proposed; pinned when a consumer first pins it). The alphabet, the varint scheme, and the framing do not move.
Seam F: valis outbound seam ↔ sister connectors
Net-new. valis originates every connection to the world through a single capability-gated verb, and a running node installs a check beneath the socket libraries that refuses a connection which did not come through it. Three sister call paths still open their own connections and are enumerated as migration debt in valis's own registry. This seam is the interface each of them adopts so the debt can be discharged.
Read outbound-dial.org for why the seam exists and what it guarantees. Everything a sister needs in order to implement its own obligation is in this section; nothing here describes a sister's internals.
The shape, which is the same for all three
Resolution and connection are one act, and the caller that holds the authority performs it. A sister must not turn a name into an address on its own, and must not connect on its own. It receives what valis already dialled, and it uses it.
The reason is not tidiness. A component that resolves in one place while something else connects in another hides the query and publishes the destination, which is worse than offering no protection because it looks like protection. Separately, a library carrying its own DNS client reads its own copy of the nameserver configuration and opens its own query socket, and is measured as resolving nothing at all inside the network namespace a valis node serves from, while working perfectly on a development host.
Two consequences a sister should design for rather than discover:
- A caller-supplied descriptor or stream comes with an ownership rule. Whoever closes it must be named at the interface, in writing, and there must be exactly one of them. An inherited descriptor closed twice and an inherited descriptor closed by nobody are both defects, and neither is visible at the call site.
- A caller-supplied connector must carry the deadlines the sister depends on. A connector that cannot bound a connect wait, and cannot bound a read on the resulting stream, silently removes whatever budget the sister enforced. Where a sister documents a timeout, the connector obligation is to keep it true.
Governance for this seam
- Contract version.
+outbound-connector-contract-version+=1(proposed; pinned when the first sister adopts it). Each adopting sister declares the constant in the package that exports the surface below. - Admitted set. valis admits
{1}. A declared version outside the admitted set is a refusal, never a reinterpretation. - Fail closed on an absent pinned symbol or a version skew. valis resolves the
pinned symbol at call time; a symbol that is not
fboundp, or a declared version outside the admitted set, refuses. It never falls back to letting the sister dial for itself, because that fallback is exactly the behaviour this seam exists to remove, and a fallback taken silently is indistinguishable from the seam working. - Evolution is additive by new version. A changed obligation is version 2 under a new constant; version 1 keeps its meaning.
F.1 seven: an endpoint built over a caller-supplied connected descriptor
The obligation that matters most, because it is reached from a live valis management path. Today the valis side asks seven for a connection by host and port, and seven resolves the name through the socket library's built-in DNS client and connects. A management endpoint configured by name would therefore fail exactly the way the outbound notification path did, on a deployed node and nowhere else.
- Direction / ownership. valis holds the dial capability and performs the dial. seven owns the 9P endpoint over whatever it is handed. seven never resolves and never connects on valis's behalf.
- Call interface.
endpoint-from-fd (fd &key dup max-frame control)→ a socket endpoint over the already-connected descriptorfd. This constructor exists in seven today; the obligation is to pin it as part of the admitted surface at version 1, with its descriptor-ownership rule stated rather than implied, so a caller can rely on it instead of reading the implementation. - Descriptor ownership, pinned.
:dup tleaves the caller's descriptor valid and the endpoint owns only its own duplicate.:dup niltransfers ownership to the endpoint, and the caller must not close the descriptor afterwards. valis dials and hands the descriptor over; exactly one side closes it, and which side is decided by that keyword rather than by convention. - Outside the admitted surface. The name-taking constructor
connect-socket-endpoint (host port &key max-frame)is not part of this surface. It resolves and connects on its own, which is the behaviour being retired. It may continue to exist for seven's own purposes; a valis-originated connection does not use it. - Fail closed. An absent
endpoint-from-fd, or a declared version outside the admitted set, refuses. valis does not fall back to the name-taking form.
F.2 mercer: renewal metadata through the pluggable request binding
- Direction / ownership. The certificate authority's renewal-information lookup is an ordinary HTTP GET. mercer performs it through the transport library's pluggable request binding, which is the same binding every other certificate path already goes through, rather than by reaching an HTTP client directly.
- Call interface. The transport library exposes a request function as a
rebindable variable that merely defaults to a concrete HTTP client. valis binds
that variable, for the extent of an order, to a function whose socket comes
from the outbound seam. The obligation is that
mercer/src/acme::ari-suggested-renewalissues its request through that binding. No new symbol is required on either side; the change is which function performs the request. - Why the current form cannot be reached any other way. That function looks the request verb up by name at run time and calls it, so no sweep over source text can see the call. It also bypasses the transport library's own request binding, so binding that variable does not reach it.
- Fail closed. Until it is migrated, this path is refused by valis's runtime check rather than permitted, and that is deliberate. See the recorded ruling below, which a reader should not skip.
Recorded ruling: the refusal here is real, and it is silent
valis's outbound exemption registry names
mercer/src/acme::ari-suggested-renewal as migration debt, and that entry is
knowingly unmatchable. The check reads the site a connection was opened from,
and the connection is opened inside the HTTP client, so the walk names the client
and not the code that called it.
The entry stays as it is. Widening it to name the HTTP client would permit every caller that client has, which is a wildcard under another name.
But "it refuses" must not be read as "someone will notice", because nobody
will. The caller, mercer/src/acme::next-renewal-instant, wraps the lookup in
ignore-errors inside an or, and falls back to a one-third-of-lifetime renewal
trigger computed from the certificate's own notBefore and notAfter. So the
refusal is swallowed at the call site. The observable effect is that the renewal
advice lookup goes silently inert and renewal quietly falls back to computing
its own schedule.
Renewal still happens, so this is not an outage. What is lost is the certificate authority's own guidance about when to renew, with nothing reporting the loss.
A finding owed to mercer, recorded here because it was found here and is not
valis's to fix. That ignore-errors is a blanket handler that cannot
discriminate between a refusal, a transport failure, a malformed answer and a
defect in the lookup itself, and it turns all four into the same fallback. It is
the pattern CONDITION-HANDLING.org exists to
prevent: define a condition, bind a handler that can tell one cause from another,
and let a fallback be a decision rather than a side effect. Migrating F.2 without
addressing it would leave the seam working and the silence intact.
F.3 runciter: the encrypted upstream connection takes a caller-supplied connector
- Direction / ownership. runciter's DNS-over-TLS wire owns the query, the budget and the response binding. It does not own the act of reaching the upstream. The connection arrives as a caller-supplied connector, and valis supplies one whose socket comes from the outbound seam.
- Call interface.
live-dot-connecttoday takes an endpoint and dials it, answering(values stream close-fn). The obligation is that the connector becomes a parameter of the wire rather than a fixed implementation inside it: the wire accepts a function of the endpoint answering the same(values stream close-fn), and callsclose-fnon every exit, success or failure, exactly as it does now. The existing implementation stays as the default for callers that supply nothing. - Deadlines are part of the obligation, not a detail. runciter's connector bounds two things: the connect wait, and a receive deadline that also bounds the sealing handshake layered over the stream. A supplied connector must enforce both. This is a real constraint rather than a formality: runciter chose its current socket provider because another one was measured as enforcing neither, setting the descriptor non-blocking unconditionally so a receive deadline does not bound a read, and returning normally from a connect that never connected. A connector that quietly drops those deadlines converts a bounded failure into a hang.
- Fail closed. An absent connector parameter, or a declared version outside the admitted set, refuses. runciter maps a refused or failed upstream connection to its existing transport error and defers, never a cleartext retry and never a fabricated answer.
Documentation correction carried in this revision
Seams A and B previously named the wrong socket library for runciter's outbound
DoT connection in three places, and those lines are corrected in this revision.
The path uses usocket, deliberately and for the deadline reasons above. A wrong
claim about which library dials is exactly the kind of thing that costs an
afternoon during an incident, which is why it is corrected rather than left as a
harmless imprecision.
| Seam | Surface | Contract-version constant | Version | Status |
|---|---|---|---|---|
| A runciter↔c3po | Resolver DoT codec | +dns-resolver-codec-contract-version+ |
1 | pinned (net-new) |
| A runciter↔c3po | Serving :53 codec exercise | +dns-adapter-contract-version+ |
1 | pinned (additive over existing seam) |
| A runciter↔c3po | AXFR/NOTIFY framing + digest | +dns-transfer-codec-contract-version+ |
1 | pinned (net-new; arity confirmed) |
| B c3po↔mercer | DoT authenticated transport | +mercer-secure-transport-contract-version+ |
1 | pinned (net-new; highest-risk) |
| B c3po↔mercer | TSIG sign/verify over digest | +tsig-contract-version+ |
1 | pinned (net-new; arity confirmed) |
| B valis↔mercer | Server-TLS termination (:443 edge) | +server-tls-contract-version+ |
2 | pinned (the valis edge holds a load-time equality guard) |
| C valis↔ACME manager | Zone-write mutation (dns-01) | +zone-write-seam-contract-version+ |
1 | proposed (v1.8). ⚠ The ACME manager was built without pinning it, so no constant of this name exists; the mutation goes through the :names write gate directly |
| D consumer↔hekate | Identity + capability wire codecs | +hekate-marshalling-contract-version+ |
1 | proposed (pure library; byte-identity invariant) |
| E consumer↔c3po | multiformats leaf (base-N + framing) | +multiformats-codec-contract-version+ |
1 | proposed (pure library; byte-identity invariant) |
| F valis↔sisters | Outbound connector (dial by valis) | +outbound-connector-contract-version+ |
1 | proposed (net-new; pinned when the first sister adopts it) |
fulcrum has no v1.6 seam surface (its eBPF steering already delivers the :53 fd).
Cross-references
- CONSTELLATION.org: the architecture of record; who owns each layer this contract binds together. That ownership map is the authority this contract derives from.