valis / Running a node

/id/ctl Key Rotation, design scoping note (for review)

This note scopes the work needed to make the /id/ctl key / delkey grammar do something. The custody model is a sovereignty-root decision, so the choice belonged with the lead repo's owner, and it has been made: the decision is recorded below, and the options it was chosen against are kept beneath it because the reasoning still repays reading. The scoping stands; whether the code has landed is a question for TODO.org, not for this note.

Author: glenneth. Raised while wiring ubik's owner surface (ubik's TODO carries a deferred "key-rotation runbook" that is blocked on this valis-side stub).

Decision (Fade, 2026-07-05)

Chosen: Option A, the additional-keys keyring (DID stays fixed). It gives the owner-facing viewer a real device-enrolment / co-signer runbook without touching the sovereignty gate, which is the right first step. valis grows the keyring custody model, its persistence, and the ctl-node node-write wiring that applies key / delkey, with the write gated on an owner-proven session reaching its own /id/ctl (question 4). The additional-keys trust model and the on-disk persistence (question 2) are settled as part of that work.

Option B, true owner-key rotation with continuity, is not dropped; it is necessary, and it is deferred to its own phase. Rotating the owner master DID is exactly the sovereignty fork the gate guards, and it is owed a design arc of its own: a continuity / attestation format, a mercer verifier, the did:key-vs- indirection question (3) settled, and migration of every capability, revocation, and durable head keyed to the DID. That is litigated on its own, not bolted onto A.

Option C is declined: read-only-by-design would needlessly foreclose the device enrolment that A provides safely.

The rest of this note is the original scoping that led to the decision.

Why this note exists

ubik (the owner-facing viewer) renders /id read-only. It cannot offer a guided key-rotation runbook because the substrate side is a deliberate stub: the /id/ctl write path parses the factotum grammar and then discards it, and the custody store is immutable and single-key by construction. ubik can already send key / delkey; there is simply nothing on the backend for those verbs to act on.

This is not a bug. It is a stub left on purpose because the owner key is the substrate's identity, and mutating it is the one operation the sovereignty gate is built to prevent from happening silently. So before ubik grows a rotation UI, valis needs a custody model that says what rotation even means here.

The ground the design was scoped against

The three facts below are the substrate this design has to build on, and each is recorded here because the design only makes sense against them. They describe the tree as it stood when the note was written, in the same tense: what has changed since belongs in TODO.org, not in a heading here that promises to be current and cannot keep the promise.

The /id/ctl write path was parse-only

ctl-node node-write extracted the leading verb and threw it away:

;; src/namespace/id.lisp
(when (zerop offset)
  (let* ((text (…octets->string data…))
         (verb (%parse-ctl-verb text)))
    (declare (ignore verb))))          ; <- nothing is recorded
(length data)

The accompanying comment was explicit: "Nothing is recorded yet — the custody store is read-only after load-or-create-keyfile; the grammar can grow later." Only a write at offset 0 was parsed, and unknown verbs were ignored with no eval and no signal, because a control file must never become an eval surface.

node-read rendered the descriptor list via custody-key-list, which returned a single derived line:

;; src/identity/custody.lisp
(defun custody-key-list (store)
  (list (format nil "proto=noise key=~A" (custody-owner-did store))))

The custody store was immutable and single-key

custody-store was documented as "Immutable after construction: callers re-load from a keyfile rather than mutate the held key material." It held exactly one owner Ed25519 master key, from which the X25519 transport key and the did:key DID were derived once at construction. There was:

  • no keyring / set of keys, one master only;
  • no add / remove / replace API on the store;
  • no write-back: the on-disk keyfile is a single raw 32-byte secret scalar, and %write-secret-bytes refuses to clobber (:if-exists :error).

The sovereignty gate this must not break

This one is not a starting condition but a standing invariant, and it outlives any of the work below. fabric.lisp fails closed when a durable store head exists but the owner keyfile is absent, precisely to stop load-or-create-keyfile's mint branch from forging a new owner DID over an existing substrate (the "silent sovereignty fork"). Any rotation design has to reason about this gate: the owner DID is the public name of the substrate, and a design that changes it is, by definition, the fork the gate exists to catch.

The decision to make

What should key / delkey on /id/ctl actually do? Three shapes, in increasing blast radius. This note does not pick one.

Option A: Additional-keys keyring (DID stays fixed)

Custody keeps the same owner master key and DID, but gains a set of additional authorized keys. key proto=… key=… adds one; delkey … removes one. The added keys can authenticate (and optionally sign) alongside the master; the substrate identity never changes.

  • Pro: non-forking by construction - the DID is stable, so the sovereignty gate is untouched. Matches the deferred, additive posture the custody file already describes for at-rest hardening. Smallest, safest step, and it is enough to give ubik a real, useful runbook (enrol/retire a device or co-signer key).
  • Con: it is enrolment, not true rotation. It does not answer "the master key leaked; replace it." The threat model for "additional keys" needs pinning: are they equal owners, restricted authenticators, or per-device delegates?
  • Open: where do added keys live on disk, and what is their trust relative to the master?

Option B: True owner-key rotation (new master, new DID, with continuity)

Rotate the master itself: generate a new Ed25519 key, derive a new DID, and emit a signed rotation record (old key signs the new key, and/or the reverse) so an external verifier can prove old→new continuity. The old DID is retired.

  • Pro: answers real key compromise / scheduled rotation; this is what "key rotation" usually means.
  • Con: this is the sovereignty fork the gate guards. It needs a continuity / attestation format, a verification story for anyone who pinned the old DID, migration of everything keyed to the DID (issued caps, revocation store, the durable head), and a fail-closed story for a half-applied rotation. Large, and cross-repo (mercer verifies; seven/ubik surface it).
  • Open: is a did:key (a raw key with no indirection) even the right identifier for something that must survive rotation, or does continuity demand a layer of indirection (a stable name that points at the current key)?

Option C: Explicitly keep it read-only (document, don't build)

Decide that owner-key mutation stays out of band by design, and make /id/ctl's read-only status a documented contract rather than a "not yet". ubik then renders /id read-only on purpose, and its TODO item is closed as "won't do at this layer" instead of "blocked".

  • Pro: smallest surface; no new sovereignty-critical code.
  • Con: leaves device enrolment / co-signing unsolved if that is wanted.

Cross-repo shape (once a model is chosen)

Whatever is chosen, the seam is clean and the order is fixed:

  1. valis (server): the custody model + persistence + the ctl-node node-write wiring that applies the verb. This is the load-bearing, sovereignty-sensitive work and it lands first.
  2. mercer: only if Option B, a verifier for the rotation/continuity record.
  3. ubik (client): the guided, confirm-gated runbook UI over the now-live seam. ubik depends only on mercer + seven, never on valis internals, so it cannot and must not lead this.

Cross-cutting requirements (apply to A and B)

  • No eval surface. The control file parses a fixed grammar; it must never become an eval or injection surface. Keep the "recognized verbs only, ignore the rest, never signal" posture.
  • Atomicity / fail-closed. A rotation or enrolment that is interrupted mid-write must leave the store in a consistent, still-usable state, never a window where the owner is locked out or two masters race.
  • At-rest. New key material inherits the keyfile's 0600 posture; the file format needs a version byte if it stops being "exactly 32 raw bytes".
  • Auth to mutate. Who is allowed to write key / delkey? Presumably only an owner-proven session reaching its own /id/ctl. The write path should gate on that, not just on reaching the node.
  • Tests before code. Custody-level unit tests for the new model, and a 9P-level test that a write to /id/ctl is observable in a subsequent read.

Questions for Fade

  1. Which shape: A (additional-keys keyring, DID fixed), B (true owner rotation with continuity), or C (keep read-only by design)?
  2. If A: what is an additional key's trust relative to the master - equal owner, restricted authenticator, or per-device delegate? And where does it persist?
  3. If B: how is DID continuity meant to be proven and verified, and is did:key the right identifier or do we need an indirection layer?
  4. Either way: what is the authorization rule for writing to /id/ctl?

🄯 Brian O'Reilly <fade@deepsky.com>, 2026