HOVEL // chapter 29
Chapter 29 Part 06 / Reference

TLS & PKI API

This guide is for authors of CLI, local web, Elixir, and other HTTP/JSON clients that control Hovel workspace PKI through hoveld. The machine-readable source of truth is the OpenAPI 3.1 contract; the transport and compatibility policy is documented in the Daemon RPC Contract.

This is a local daemon RPC, not a public REST service. Every operation is an HTTP POST under /hovel.daemon.v1.DaemonService/. The current daemon has no public authentication scheme or HTTPS listener. Do not expose it through a reverse proxy and do not rewrite the methods as an invented /api/v1 resource API.

Boundary at a Glance

CLI / local web / Elixir / generated REST client
                         |
                         | HTTP POST + JSON
                         v
       owner-only Unix socket       loopback TCP
       inventory + privileged calls status + public capabilities
                         |
                         v
                  hoveld application API
             inventory | journals | approvals
                         |
             +-----------+----------------+
             |                            |
      daemon-owned SQLite          module provider process
      and master-key custody       framed JSON-RPC over stdio
             |                            |
             +----- never opened by client+
External clients select and inspect resources. The daemon owns durable state, key custody, audit records, and secret delivery to a consuming provider process.

The Go client in core/internal/adapters/daemonrpc is an internal implementation, not an importable public SDK. The checked-in Go, Python, and Rust SDKs are for module processes speaking framed JSON-RPC over stdio; they are not daemon HTTP clients. There is no checked-in Elixir daemon SDK. External clients should generate from OpenAPI or implement the small unary HTTP contract directly.

A browser is not a direct privileged daemon client. Browsers cannot open the owner-only Unix socket, and the current daemon does not implement CORS or preflight OPTIONS handling for cross-origin loopback calls. A local web or Elixir-controlled UI therefore needs a trusted same-origin backend or native sidecar that owns the Unix-socket connection, authenticates its own clients, enforces CSRF and authorization policy, and exposes only the operations the UI needs. Do not publish or reverse-proxy the daemon listener itself.

Choose the Correct Transport

Transport Current authority PKI behavior Client rule
Unix socket Confidential, owner-only local boundary; the daemon sets mode 0600 Read methods, mutations, signing-lease control, Mesh mutations, and eligible secret responses Use this transport for every state change or credential-bearing operation
Loopback TCP over HTTP Non-sensitive reads only; only loopback hosts are accepted PKIStatus, backend/profile capability discovery, and other explicitly public projections; PKI inventory, assignments, trust, revocation, credential bookkeeping, and privileged calls return 403 permission-denied Use only for public capability discovery; reconnect through the Unix socket for inventory or mutation
HTTPS or non-loopback TCP Not implemented The endpoint parser rejects it Build a separate authenticated adapter rather than weakening this boundary

“Confidential” here means an owner-only local Unix socket, not TLS on the daemon listener. Supplying actorId or an approval boolean does not turn TCP into a privileged transport.

Wire rules

  • Use the exact RPC path and lower-camel PKI field names shown in OpenAPI.
  • Send one JSON value with Content-Type: application/json. Unknown request fields and trailing JSON are rejected.
  • Keep the JSON body at or below 64 MiB (67,108,864 bytes). The daemon applies that inclusive limit while reading rather than rejecting from Content-Length alone; a decode that crosses it returns plain-text 413.
  • Send {} for an empty request. Do not omit the body.
  • Go []byte fields such as certificate DER, CRL DER, and bundle binary data are base64 JSON strings.
  • Timestamps are RFC 3339 date-time strings.
  • IDs are canonical strings of at most 256 bytes. Domain IDs accept letters, digits, and -_.:/; retain returned IDs rather than reconstructing them.
  • Generations and revisions are positive signed-64-bit-compatible integers, up to 9223372036854775807.
  • Requests are strict, but response readers should ignore additive fields they do not yet understand.
Web clients need lossless integer handling. The contract uses JSON int64 revisions, which can exceed the exact range of a JavaScript Number. Use a lossless decoder or fail explicitly rather than silently rounding a revision and defeating a concurrency fence.

Request Context and Explicit Approvals

PKI mutations carry a context object. Its three required IDs identify who requested the call, the stable operator operation that owns it, and the individual call or workflow correlation. They are audit scope, not free-form authorization claims:

{
  "actorId": "operator-7",
  "operationId": "credential-rollout-edge",
  "correlationId": "change-1042"
}
PKIRequestContext field Currently consumed by Meaning
approveSigningLease UnlockPKIAuthority Explicitly permits an actor- and operation-scoped signing lease; it does not unlock every caller
approvePrivateKeyExport ExportPKIBundle with includePrivate: true Approves this private export request; the certificate export policy must also allow it
approveCredentialUse Credential-bearing StartMeshListener, direct survey RunMeshTask, OpenMeshStream, and OpenMeshBridge Permits assignment material to be delivered into the same provider process that performs the Mesh call; no secret is returned to the control-plane client
approveCrlPublicationReconciliation ReconcilePKICRL and ReconcilePKICRLs Permits conservative recovery of stale publication plans
approveIssuanceReconciliation No current daemon HTTP method An application-level extension point; do not treat the field itself as an implemented recovery endpoint

Omit unrelated approval fields. Sending every approval as true defeats operator review and makes audit intent ambiguous. Workspace initialization has its own top-level confirmed: true field; it is not implied by a context approval.

Stable Identity, Revisions, and Idempotency

Resource Stable identity Concurrency or recovery state
Authority authority.id activeGenerationId identifies the exact signing certificate generation
Certificate certificateId is the lineage; id is one immutable generation generation increases without changing earlier generations
Assignment assignment.id revision fences stage, activate, and unbind; active and staged certificate/trust IDs remain explicit
Trust set trustSet.id revision fences mutation; immutable trust generations carry exact anchor, intermediate, and CRL generation IDs
CRL publication publication.id for the signing plan and generation.id for the immutable CRL Publication status, phase, revision, and lease support crash recovery
Authority rollover operation.id Operation and trust-set revisions fence transitions; acknowledgement IDs and missingAssignmentIds are durable bookkeeping
Credential delivery Selector requestId, credential execution ID, or stamp ID List/inspect records expose state, hashes, and sizes without secret bytes, provider references, or protected paths

Most synchronous mutations and issuance operations accept idempotencyKey. An explicit key is scoped by actor and mutation kind. Reusing the same scoped key with the same normalized request returns the recorded public result; reusing it with a different request returns idempotency-conflict. If the field is omitted, Hovel derives a key from actor, operation, correlation, mutation kind, and the request digest. Therefore an implicit-key retry must preserve all three context IDs and the exact request.

A changed revision is a changed request. After re-inspecting a resource and deciding to act on a newer revision, allocate a new explicit idempotency key. Do not reuse the key from the stale request with a modified expectedRevision.

Implemented Method Families

Purpose Non-sensitive loopback reads Owner Unix-socket methods
Workspace and capabilities PKIStatus, ListPKIBackends, ListPKIProfiles InitializePKI
Authorities and certificates None ListPKIAuthorities, InspectPKIAuthority, ListPKICertificates, InspectPKICertificate, CreatePKIAuthority, UnlockPKIAuthority, LockPKIAuthority, IssuePKICertificate, RenewPKICertificate, RotatePKICertificate
Revocation and CRLs None InspectPKIRevocation, InspectPKIGenerationRevocation, ListPKIRevocations, InspectPKICRL, ListPKICRLs, InspectPKICRLPublication, ListPKICRLPublications, RevokePKICertificate, PublishPKICRL, ReconcilePKICRL, ReconcilePKICRLs
Assignments and trust None ListPKIAssignments, InspectPKIAssignment, ListPKITrustSets, InspectPKITrustSet, BindPKIAssignment, StagePKIAssignment, ActivatePKIAssignment, UnbindPKIAssignment, CreatePKITrustSet, StagePKITrustSet, ActivatePKITrustSet
Durable bookkeeping None ListPKIOperations, InspectPKIOperation, ListPKICredentialStamps, InspectPKICredentialStamp, ListPKICredentialExecutions, InspectPKICredentialExecution, and the six authority-rollover transitions
Bundle and Mesh use DescribeMesh ExportPKIBundle; Mesh topology, beacon, listener, and operation reads; and Mesh listener/task/stream/bridge mutations

The default daemon composition currently registers the builtin-x509 backend. Treat ListPKIBackends as the runtime authority for algorithms and capabilities; do not hard-code a hardware, cloud, or imported-key provider that is not returned there.

Copyable HTTP Examples

The examples use curl only to make the wire visible. They do not imply any Hovel CLI flags. Set the socket and logical base URL once:

SOCKET=/absolute/path/to/hoveld.sock
RPC=http://hoveld/hovel.daemon.v1.DaemonService

Read an assignment and retain its revision

curl --silent --show-error \
  --unix-socket "$SOCKET" \
  --header 'Content-Type: application/json' \
  --data '{"id":"assignment-rpc"}' \
  "$RPC/InspectPKIAssignment"

A conforming response can be decoded as:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "assignment": {
    "id": "assignment-rpc",
    "purpose": "mtls-server",
    "consumerType": "mesh-listener",
    "consumerId": "mesh-provider/listener-rpc",
    "profileId": "mtls-server",
    "activeGenerationId": "generation-rpc-listener",
    "trustSetId": "trust-rpc",
    "activeTrustGenerationId": "trust-rpc-generation-1",
    "state": "active",
    "revision": 3,
    "updatedAt": "2026-07-12T18:00:00Z"
  }
}

Use the returned revision as the next mutation fence. Do not infer it from the number of list entries or from wall-clock time.

Activate a staged assignment generation

curl --silent --show-error \
  --unix-socket "$SOCKET" \
  --header 'Content-Type: application/json' \
  --data-binary @- \
  "$RPC/ActivatePKIAssignment" <<'JSON'
{
  "context": {
    "actorId": "operator-7",
    "operationId": "credential-rollout-edge",
    "correlationId": "change-1042"
  },
  "request": {
    "idempotencyKey": "change-1042:activate:assignment-rpc",
    "assignmentId": "assignment-rpc",
    "expectedRevision": 2
  }
}
JSON

The success type is PKIAssignmentInspection, the same resolved shape used by InspectPKIAssignment. Preserve the exact request above for an ambiguous retry.

Select assignment credentials on a Mesh mutation

curl --silent --show-error \
  --unix-socket "$SOCKET" \
  --header 'Content-Type: application/json' \
  --data-binary @- \
  "$RPC/StartMeshListener" <<'JSON'
{
  "moduleId": "mesh-provider@v0.1.0",
  "request": {
    "listenerId": "listener-edge",
    "name": "Edge listener",
    "config": {
      "bindHost": "127.0.0.1",
      "bindPort": 8443
    }
  },
  "credentials": [
    {
      "requestId": "credential-listener-edge-certificate-1",
      "assignmentId": "assignment-rpc",
      "slotName": "control-plane-mtls-certificate",
      "capability": "runtime",
      "material": {
        "projection": "certificate-der",
        "form": "public"
      }
    },
    {
      "requestId": "credential-listener-edge-private-key-1",
      "assignmentId": "assignment-rpc",
      "slotName": "control-plane-mtls-private-key",
      "capability": "runtime",
      "material": {
        "projection": "private-key-pkcs8",
        "form": "private-bytes"
      }
    }
  ],
  "credentialContext": {
    "actorId": "operator-7",
    "operationId": "mesh-listener-edge-start",
    "correlationId": "change-1042",
    "approveCredentialUse": true
  }
}
JSON
HTTP/1.1 200 OK
Content-Type: application/json

{
  "operationId": "mesh-operation-listener-edge-start",
  "listener": {
    "id": "listener-edge",
    "name": "Edge listener",
    "state": "active",
    "updatedAt": "2026-07-12T18:05:00Z"
  }
}

credentials and credentialContext are a pair: omit both when no assignment material is needed, and send both otherwise. The current external selector path implements only capability: runtime with these projection/form pairs:

Projection Form
certificate-derpublic
public-key-spkipublic
private-key-pkcs8private-bytes

Each requestId is an at-most-once credential-delivery identity, not a replay key for the enclosing Mesh call. An array cannot repeat a request ID or the same assignment-and-slot pair. Reusing an ID prevents a second provider delivery, but a terminal delivery cannot be replayed into a fresh provider process and Hovel does not persist the enclosing Mesh result. After an ambiguous timeout, inspect Mesh operations and credential-execution records before deciding whether it is safe to start a new call; a deliberate new attempt requires fresh request IDs. Provider descriptors must therefore expose separate slots for a TLS identity's certificate-der/public and private-key-pkcs8/private-bytes selections; never select both projections through one slot. Provider configuration is write-only and credentials are resolved immediately before the call; neither is echoed in the listener response or operation ledger. File delivery, provider encoding, stamping, bundle projection, and signer-reference contracts exist elsewhere as provider extension points, but are not accepted by this Mesh HTTP selector.

Private bundle export is never cacheable

POST /hovel.daemon.v1.DaemonService/ExportPKIBundle HTTP/1.1
Host: hoveld
Content-Type: application/json

{
  "context": {
    "actorId": "operator-7",
    "operationId": "credential-export-edge",
    "correlationId": "change-1042-export",
    "approvePrivateKeyExport": true
  },
  "generationId": "generation-rpc-listener",
  "purpose": "mtls-server",
  "includePrivate": true
}

A private response is possible only through the Unix socket, for an active generation whose purpose matches and whose export policy is explicit-private-export. The route sends Cache-Control: no-store on success and failure, including when includePrivate is false. A denied response has this typed shape:

HTTP/1.1 403 Forbidden
Cache-Control: no-store
Content-Type: application/json

{
  "version": "v1",
  "code": "private-key-export-denied",
  "message": "pki: private key export is not allowed"
}
Do not let exported bundles enter normal client state. Disable HTTP caches, browser service-worker caches, persistence, telemetry, crash reports, and debug logging. Keep the body in the smallest possible memory scope and clear mutable buffers after the consuming operation.

Authority Rollover

Rollover is one durable daemon operation, not a sequence that a front end should infer from authority states. The operation records the exact previous and replacement authority generation IDs, the overlap and final trust generations, required assignments, acknowledgements, phase, and revision.

stage overlap trust
        |
        v
StartPKIAuthorityRollover
awaiting-overlap-acknowledgements
        |
        | Acknowledge... for every required assignment
        v
ActivatePKIAuthorityRollover  [operation + trust revisions]
awaiting-leaf-rotation
        |
        | issue, stage, and activate replacement leaves
        | stage final trust
        v
BeginPKIAuthorityRolloverFinalTrust  [operation + trust revisions]
awaiting-final-acknowledgements
        |
        | Acknowledge... for every required assignment
        v
CompletePKIAuthorityRollover  [operation + trust revisions]
completed
Root rollovers place both authority generations in overlap anchors; subordinate rollovers place both in intermediates and retain a path to an anchor. Final trust removes the previous generation.

A copyable start request using explicit consumer tracking is:

curl --silent --show-error \
  --unix-socket "$SOCKET" \
  --header 'Content-Type: application/json' \
  --data-binary @- \
  "$RPC/StartPKIAuthorityRollover" <<'JSON'
{
  "context": {
    "actorId": "operator-7",
    "operationId": "rollover-edge-root-2026",
    "correlationId": "change-1042"
  },
  "request": {
    "idempotencyKey": "change-1042:rollover:start",
    "operationId": "rollover-edge-root-2026",
    "previousAuthorityId": "authority-edge-root-old",
    "replacementAuthorityId": "authority-edge-root-new",
    "trustSetId": "trust-edge",
    "overlapTrustGenerationId": "trustgen-edge-overlap",
    "consumerTracking": "explicit",
    "requiredAssignmentIds": [
      "assignment-edge-listener"
    ]
  }
}
JSON
HTTP/1.1 200 OK
Content-Type: application/json

{
  "operation": {
    "id": "rollover-edge-root-2026",
    "kind": "authority-rollover",
    "status": "waiting",
    "revision": 1,
    "authorityRollover": {
      "previousAuthorityId": "authority-edge-root-old",
      "previousAuthorityGenerationId": "certgen-edge-root-old",
      "replacementAuthorityId": "authority-edge-root-new",
      "replacementAuthorityGenerationId": "certgen-edge-root-new",
      "trustSetId": "trust-edge",
      "overlapTrustGenerationId": "trustgen-edge-overlap",
      "consumerTracking": "explicit",
      "requiredAssignmentIds": [
        "assignment-edge-listener"
      ],
      "phase": "awaiting-overlap-acknowledgements"
    },
    "createdAt": "2026-07-12T18:10:00Z",
    "updatedAt": "2026-07-12T18:10:00Z"
  },
  "acknowledgements": [],
  "missingAssignmentIds": [
    "assignment-edge-listener"
  ]
}

Persist the returned authority generation IDs and operation revision. Before every transition, call InspectPKIOperation and InspectPKITrustSet; submit both current revisions where the request schema requires them. Use missingAssignmentIds rather than counting acknowledgements yourself. A rollover reserves its trust set and required assignments, so unrelated mutations can return the typed resource-reserved precondition. Cancellation is implemented only before overlap activation.

CRL Publication Recovery

PublishPKICRL persists a plan before invoking a signer. Keep the returned publication ID distinct from the resulting immutable CRL generation ID. Use ListPKICRLPublications or InspectPKICRLPublication to observe the plan, and ListPKICRLs or InspectPKICRL to observe completed CRL generations.

A crash may leave a publication pending in phase planned, signing, or signed. Wait until both the requested stale age and the recorded ownership lease have elapsed, then reconcile through the confidential transport:

curl --silent --show-error \
  --unix-socket "$SOCKET" \
  --header 'Content-Type: application/json' \
  --data-binary @- \
  "$RPC/ReconcilePKICRL" <<'JSON'
{
  "context": {
    "actorId": "operator-7",
    "operationId": "pki-recovery",
    "correlationId": "reconcile-crl-2026-0712",
    "approveCrlPublicationReconciliation": true
  },
  "request": {
    "publicationId": "crl-publication-018f6e2f",
    "staleAfterSeconds": 300
  }
}
JSON
  • staleAfterSeconds is between 60 and 86400. Batch recovery additionally requires limit between 1 and 100.
  • A durable signed checkpoint is independently validated and completed without calling the signer again.
  • An ambiguous planned or signing plan is failed rather than risking a second signing invocation.
  • A non-stale or still-leased plan returns crl-publication-in-progress; back off and inspect it again.
  • Reconciliation repairs publication bookkeeping. It does not make an expired CRL fresh; publish a new CRL and stage a new trust generation when freshness has elapsed.

Typed Errors

Classified application failures use the versioned RPCError envelope. Branch on version, code, and, for rollover failures, rolloverReason. The message and optional rolloverDetail are diagnostics for people, not a protocol.

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "version": "v1",
  "code": "rollover-precondition",
  "message": "pki: authority rollover precondition failed: assignments-not-rotated: assignment drifted after acknowledgement",
  "rolloverReason": "assignments-not-rotated",
  "rolloverDetail": "assignment drifted after acknowledgement"
}
HTTP Stable code Client action
404not-foundRefresh inventory; do not invent or parse an ID from the message
403permission-deniedReconnect through the Unix socket; repeating on TCP cannot succeed
403private-key-export-deniedStop automatic retry and require an explicit operator decision
403authority-signing-lease-ownedDo not replace or revoke another actor or operation's live lease; wait for expiry or coordinate with that owner
423authority-signing-lockedAcquire an explicitly approved signing lease for the same actor and operation, then retry the unchanged idempotent request
409revision-conflictInspect, re-evaluate, and use a new idempotency key if the request changes
409idempotency-conflictTreat as a client bug or changed intent; never mutate the request under the same key
409issuance-in-progress, crl-publication-in-progress, or mutation-existsBack off, inspect bookkeeping, and retry the exact request with the same key
409acknowledgement-existsInspect the operation and use the recorded acknowledgement
409rollover-preconditionBranch on the stable reason and re-inspect the operation, assignments, authorities, and trust set

Invalid JSON returns plain-text 400, a body read that exceeds 64 MiB returns plain-text 413, a non-POST request returns plain-text 405, and an unclassified server failure currently returns plain-text 500. The size cap is enforced while decoding, not by preflighting Content-Length; malformed input rejected before the decoder crosses the cap can therefore still produce 400. Although internal is a reserved error code in the schema, the current handler does not promise a typed envelope for unclassified failures. Treat any non-conforming error body as opaque.

Never parse error prose. Do not match substrings in message, plain-text bodies, or logs. If a response is not a valid v1 envelope, retain only its HTTP status and an opaque diagnostic for display.

Minimal Client Algorithm

call(method, request, call_class):
  1. Choose transport:
       public status/capability method                        -> Unix socket or loopback TCP
       inventory, bookkeeping, mutation, or secret-bearing   -> owner-only Unix socket
  2. Validate the request against OpenAPI; emit exact field names and one JSON value.
  3. For a journaled PKI mutation, allocate an idempotency key before the first send
     and retain method + actor + exact request + key + relevant resource revisions.
     For credential-bearing Mesh, allocate unique request IDs, but treat them as
     at-most-once delivery claims rather than replayable mutation keys.
  4. POST /hovel.daemon.v1.DaemonService/{method}.
  5. On 2xx, decode the documented response, retain returned IDs/revisions,
     and ignore unknown additive response fields.
  6. On a valid v1 RPCError, branch on code and rolloverReason, never message.
  7. On timeout or opaque 5xx:
       idempotent read        -> retry with bounded backoff on its authorized transport;
       journaled PKI mutation -> inspect durable bookkeeping, then retry only the
                                exact request with the same idempotency key;
       Mesh mutation          -> inspect Mesh operations and credential executions;
                                do not replay automatically because the result and
                                provider-process state are not journaled.
  8. If a PKI inspection changes the intended request, obtain operator confirmation
     as appropriate and allocate a new idempotency key. If a new Mesh attempt is
     explicitly judged safe, use fresh credential request IDs for its new process.

Storage and Extension Boundaries

Never open daemon custody files from a client. Do not read or write <workspace>/workspace.db, its SQLite sidecars, or <workspace>/secrets/pki-master-keys.json. Those files are daemon-owned implementation details. Direct access bypasses request approvals, revision fences, idempotency journals, audits, integrity checks, and the daemon's exclusive database lifecycle.

Use list and inspect methods even for local tools. In particular, use operation inspection for rollover, publication inspection for CRL recovery, and credential stamp/execution inspection for provider bookkeeping. These are the supported recovery surfaces; a SQLite query is not.

Provider registries, additional credential-delivery capabilities, and a future authenticated public adapter are extension points, not current external-interface promises. Generate clients from the current OpenAPI, discover backends at runtime, and add behavior only when a method and schema appear in that contract.