HOVEL // chapter 18
Chapter 18 Part 04 / Module Development

Developing Mesh Providers

This guide is for developers implementing a Mesh provider, adding optional listening-post control, or routing another Hovel module through a Mesh. The SDK defines contracts and dispatch; your provider owns its node protocol, routing, task execution, listener runtime, and durable state.

For the platform model, start with Mesh. For certificate custody and assignment concepts, read TLS and Workspace PKI; for issue, stage, activate, rotate, and recovery procedures, use TLS Operations. This page covers only the Mesh integration seam. The complete runtime, protected-file, encoding, and stamping implementation guide is Developing Credential Providers, while Daemon RPC and the OpenAPI document are the external API references.

Start with the boundary

ComponentOwnsDoes not own
Mesh provider moduleTopology, routes, provider protocol, tasks, streams, beacons, triggers, optional listener control, and any TLS handshakes it explicitly implements.Operator planning, confirmation, dangerous-module policy, workspace PKI custody, or daemon audit storage.
Mesh ListenerA provider-reported listening post or rendezvous resource. It may be embedded in the provider deployment or run separately.The provider adapter process. A listener must not rely on one short-lived RPC subprocess remaining alive.
Workspace PKIAuthorities, immutable certificate generations, trust generations, assignments, approvals, and audited resolution.Starting TLS, choosing provider protocols, or hot-reloading a provider-owned listener.
Mesh BridgeA daemon-owned, bearer-authenticated loopback TCP or UDP endpoint backed by one Mesh session flow.TLS encryption or termination, remote peer authentication, trust injection, or the provider's route.
Consumer moduleIts normal survey, exploit, or payload behavior and any end-to-end TLS it implements.Mesh internals when it uses an SDK-authenticated local bridge connection.

Choose only the surfaces you need

Provider shapeImplementTypical use
Single routed flowmesh.describe, mesh.open_streamExpose one provider-owned path as a normal Hovel session.
Observable node treeAdd mesh.topology and mesh.beaconsShow relays, implants, links, routes, and liveness.
Tasking providerAdd mesh.taskSurvey, command, upload, execute, upload-and-execute, or provider-native load.
Listening-post providerAdd mesh.listeners and, when controlled by Hovel, start/stop hooksReport and manage rendezvous resources independently of the provider adapter.
Deep implant/stager MeshCombine the optional surfaces you actually supportTree routing, tasking, triggers, beacons, listeners, streams, and native loaders.

Do not implement no-op methods to satisfy a large interface. In Go, implement MeshDescriber and only the optional provider interfaces you need. In Python and Rust, override only the Mesh hooks you support; defaults fail explicitly. Describe task kinds, listener types, topology, triggers, and credential slots in their structured fields. Treat the optional top-level capabilities list as provider-defined vocabulary, not as a switch that makes an unimplemented hook available.

Describe capabilities without side effects

mesh.describe is catalog metadata. It must be cheap, deterministic, offline, and side-effect free. Put dynamic node state in topology, beacon, or listener calls instead. A provider supporting routed streams, destination tasking, and an HTTPS listening post might return:

Descriptor strings do not register methods. Implement the optional SDK hook first, publish the matching structured task or listener contract where one exists, and use capabilities only for additional semantics understood by your provider and its callers.
{
  "name": "lab relay mesh",
  "version": "v0.1.0",
  "capabilities": ["mesh.route", "mesh.destination", "mesh.listener"],
  "tasks": [
    {
      "kind": "survey",
      "readOnly": true,
      "targetScopes": ["node", "destination"]
    },
    {
      "kind": "upload_execute",
      "destructive": true,
      "targetScopes": ["destination"],
      "configSchema": {
        "type": "object",
        "properties": { "remotePath": { "type": "string" } }
      }
    }
  ],
  "listenerTypes": [
    {
      "kind": "https-beacon",
      "deployments": ["embedded", "separate"],
      "managementModes": ["provider", "external"],
      "protocols": ["https"],
      "configSchema": {
        "type": "object",
        "properties": {
          "bind": { "type": "string" }
        }
      }
    }
  ],
  "credentialDelivery": {
    "schemaVersion": "hovel.pki.credential-delivery/v1",
    "deliveryCapabilities": ["runtime"],
    "credentialSlots": [
      {
        "name": "listener-certificate",
        "purpose": "tls-server",
        "endpointRole": "server",
        "consumerType": "mesh-listener",
        "acceptedBundleVersions": ["hovel.pki.bundle/v1"],
        "acceptedProfiles": ["tls-server"],
        "acceptedCompatibilityTargets": ["portable-x509"],
        "acceptedProjections": ["certificate-der"],
        "acceptedMaterialForms": ["public"],
        "maximumEncodedBytes": 65536,
        "remainderPolicy": "preserve",
        "privateMaterial": "forbidden"
      },
      {
        "name": "listener-private-key",
        "purpose": "tls-server",
        "endpointRole": "server",
        "consumerType": "mesh-listener",
        "acceptedBundleVersions": ["hovel.pki.bundle/v1"],
        "acceptedProfiles": ["tls-server"],
        "acceptedCompatibilityTargets": ["portable-x509"],
        "acceptedProjections": ["private-key-pkcs8"],
        "acceptedMaterialForms": ["private-bytes"],
        "maximumEncodedBytes": 65536,
        "remainderPolicy": "preserve",
        "privateMaterial": "required"
      },
      {
        "name": "node-client-certificate",
        "purpose": "tls-client",
        "endpointRole": "client",
        "consumerType": "mesh-node",
        "acceptedBundleVersions": ["hovel.pki.bundle/v1"],
        "acceptedProfiles": ["tls-client"],
        "acceptedCompatibilityTargets": ["portable-x509"],
        "acceptedProjections": ["certificate-der"],
        "acceptedMaterialForms": ["public"],
        "maximumEncodedBytes": 65536,
        "remainderPolicy": "preserve",
        "privateMaterial": "forbidden"
      },
      {
        "name": "node-client-private-key",
        "purpose": "tls-client",
        "endpointRole": "client",
        "consumerType": "mesh-node",
        "acceptedBundleVersions": ["hovel.pki.bundle/v1"],
        "acceptedProfiles": ["tls-client"],
        "acceptedCompatibilityTargets": ["portable-x509"],
        "acceptedProjections": ["private-key-pkcs8"],
        "acceptedMaterialForms": ["private-bytes"],
        "maximumEncodedBytes": 65536,
        "remainderPolicy": "preserve",
        "privateMaterial": "required"
      }
    ]
  }
}

This fictional provider advertises exactly four runtime slots. Certificate and private-key projections are deliberately separate, and listener/server metadata is not interchangeable with node/client metadata. Publishing a slot does not implement TLS: the provider must still consume the matching values in its TLS stack. If the provider also implements credential.describe, its standalone descriptor must be identical to the nested credentialDelivery value.

Report topology, beacons, and triggers

Nodes are operator-addressable Mesh participants. Destinations are ordinary hosts or services reachable through a node or route. Do not invent a node for every destination. Use listenerId on nodes, beacons, and triggers when provenance matters.

{
  "root": "controller",
  "nodes": [
    { "id": "controller", "kind": "controller", "state": "online" },
    {
      "id": "relay-west",
      "parentId": "controller",
      "listenerId": "listener-west",
      "kind": "relay",
      "state": "online"
    }
  ],
  "links": [
    { "id": "controller-west", "source": "controller", "target": "relay-west", "state": "up" }
  ],
  "routes": [
    { "id": "route-west", "nodes": ["controller", "relay-west"] }
  ]
}

Triggers are declarations in the descriptor; beacons are time-stamped runtime observations. Neither starts work by itself or bypasses Hovel's planning and confirmation path.

Implement tasking

Validate the task kind and required target scope before contacting the target. Echo correlation and routing fields in the result when they are known. For a copy-then-run implant flow, use upload_execute; reserve load for a provider-native in-memory or protocol-specific loader.

{
  "runId": "run-lab-1",
  "taskId": "task-deploy-1",
  "kind": "upload_execute",
  "nodeId": "relay-west",
  "listenerId": "listener-west",
  "destinationHost": "192.0.2.25",
  "destinationPort": 443,
  "protocol": "tcp",
  "config": { "remotePath": "provider-defined-path" },
  "inputData": "base64-encoded-artifact",
  "inputEncoding": "base64"
}

Provider contract versus current daemon dispatch: implement and advertise every task kind your provider truly supports, including command, upload, execute, upload_execute, load, and provider-defined kinds. The current direct RunMeshTask daemon route dispatches only survey. It rejects every other kind before invoking the provider because that route is not yet bound to a persisted throw plan, recorded confirmation, and dangerous-module allowance. Do not weaken the SDK descriptor to match this temporary orchestration limit, and do not add a provider-side bypass.

Provider-native command, survey, and delivery tasks belong here. If an existing module can already operate through a TCP or UDP endpoint, prefer the bridge workflow below instead of duplicating that module inside the provider.

Implement routed streams

mesh.open_stream returns a normal SessionRef. The provider implements session read, write, and close against its own route. The daemon can then expose the session through ordinary session APIs or a local Mesh Bridge.

  • For TCP-like flows, preserve byte-stream semantics.
  • For UDP-like flows, advertise datagram and preserve exactly one datagram per non-empty session read or write.
  • For ICMP, GRE, raw IP, or another non-socket protocol, keep it provider-defined until a concrete consumer requires a generic packet adapter.
  • Return an error for unsupported protocols; do not silently reinterpret them as TCP.

Add listening-post lifecycle only when needed

Listener listing is read-only. Start and stop are idempotent mutations using a caller-selected stable ID. IDs are unique only inside one provider Mesh; daemon-wide identity is (moduleId, listenerId).

POST /hovel.daemon.v1.DaemonService/StartMeshListener
Content-Type: application/json

{
  "moduleId": "lab-relay-mesh@v0.1.0",
  "request": {
    "listenerId": "listener-west",
    "name": "west rendezvous",
    "kind": "https-beacon",
    "deployment": "separate",
    "management": "provider",
    "config": {
      "bind": "127.0.0.1:8443"
    }
  },
  "credentials": [
    {
      "requestId": "credential-start-west-certificate-01",
      "assignmentId": "assignment-listener-west",
      "slotName": "listener-certificate",
      "capability": "runtime",
      "material": { "projection": "certificate-der", "form": "public" }
    },
    {
      "requestId": "credential-start-west-private-key-01",
      "assignmentId": "assignment-listener-west",
      "slotName": "listener-private-key",
      "capability": "runtime",
      "material": { "projection": "private-key-pkcs8", "form": "private-bytes" }
    }
  ],
  "credentialContext": {
    "actorId": "operator-alice",
    "operationId": "credential-operation-start-listener-west-01",
    "correlationId": "mesh-listener-start-west-01",
    "approveCredentialUse": true
  }
}
{
  "operationId": "mesh-op-42",
  "listener": {
    "id": "listener-west",
    "kind": "https-beacon",
    "state": "active",
    "deployment": "separate",
    "management": "provider",
    "addresses": ["https://127.0.0.1:8443"]
  }
}

The result deliberately has no config. Treat start configuration as write-only, keep it out of listener read models, errors, and logs, and never store credentials directly in daemon bookkeeping. The top-level selections are non-secret; Hovel resolves the assignment's active generation only after explicit approval. A provider-managed listener must survive the short-lived module RPC invocation; use a separate service, supervisor, or another durable provider-owned runtime, and make any handoff from the adapter process to that runtime explicit and protected.

Wire assignment-bound TLS into consuming calls

The daemon API does not accept certificate bytes. A front end sends one or more non-secret assignment/slot selections beside the Mesh request. Hovel derives the consumers permitted by that operation, inspects the exact installed provider and descriptor digest, authorizes use, resolves the active assignment generation, and sends each resolved projection through credential.runtime immediately before the Mesh method.

ResourceAssignment consumerCanonical consumer IDWhen the daemon derives it
Provider control planemesh-providerlab-relay-meshEvery credential-aware listener-start, task, stream, or bridge-open call.
Listener / listening postmesh-listenerlab-relay-mesh/listener-westThe request explicitly carries listenerId: listener-west.
Nodemesh-nodelab-relay-mesh/relay-westThe request explicitly carries nodeId: relay-west.
Standalone listening postlistening-postOperator-selected stable ID.Not derived by the current Mesh resolver; this is a broader assignment extension point.
Implant or stagerimplant or stagerOperator-selected stable ID.Not derived by current Mesh calls. Use mesh-node only when that resource is actually exposed as the selected Mesh Node.

These examples have no slash inside either component. If a provider, listener, or node component contains a slash, Hovel uses the bounded mesh-scoped:<sha256> form: lowercase SHA-256 over the canonical UTF-8 provider name, one NUL byte, and the canonical UTF-8 listener or node ID. Retain the typed constructor's result rather than reconstructing the assignment consumer ID in a client.

Use the canonical provider name in assignment consumer IDs, without an @version suffix. The daemon still binds delivery to the exact installed module, such as lab-relay-mesh@v0.1.0, its version, provider ID, and descriptor SHA-256. A route does not implicitly authorize every node it contains; include the intended nodeId explicitly.

Same-process runtime delivery
  1. inspect exact installed module
  2. start one provider process
  3. reconcile credential.describe and mesh.describe
  4. credential.runtime for certificate slot
  5. credential.runtime for private-key slot
  6. mesh.listener.start, mesh.task, or mesh.open_stream
  7. clear daemon copies and persist secret-free receipts

In provider code, keep pending material keyed by the correlated operation and slot, require the complete pair before constructing a TLS configuration, and clear temporary copies after the consuming hook takes ownership. Return only a CredentialDeliveryReceipt with the matching request ID and optional non-secret reference or digest. Never put certificate-key bytes, protected paths, or bearer references in logs, errors, listener results, task outputs, or session metadata.

The current external Mesh selector supports only runtime delivery of certificate-der/public, public-key-spki/public, and private-key-pkcs8/private-bytes. It does not deliver a bundle, chain, trust set, CRL, signer reference, protected file, provider encoding, or stamp target. A TLS-client or mTLS assignment can pin trust lifecycle state, but its trust bytes are not projected through this API today; the provider must obtain peer trust through an explicit provider-owned deployment path until that extension is implemented.

Credential-bearing RunMeshTask

POST /hovel.daemon.v1.DaemonService/RunMeshTask
Content-Type: application/json

{
  "moduleId": "lab-relay-mesh@v0.1.0",
  "request": {
    "runId": "run-lab-1",
    "taskId": "task-survey-west-1",
    "kind": "survey",
    "nodeId": "relay-west"
  },
  "credentials": [
    {
      "requestId": "credential-task-west-certificate-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-certificate",
      "capability": "runtime",
      "material": { "projection": "certificate-der", "form": "public" }
    },
    {
      "requestId": "credential-task-west-private-key-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-private-key",
      "capability": "runtime",
      "material": { "projection": "private-key-pkcs8", "form": "private-bytes" }
    }
  ],
  "credentialContext": {
    "actorId": "operator-alice",
    "operationId": "credential-operation-task-west-01",
    "correlationId": "mesh-task-west-01",
    "approveCredentialUse": true
  }
}

Credential-bearing OpenMeshStream

POST /hovel.daemon.v1.DaemonService/OpenMeshStream
Content-Type: application/json

{
  "moduleId": "lab-relay-mesh@v0.1.0",
  "request": {
    "runId": "run-lab-1",
    "nodeId": "relay-west",
    "destinationHost": "192.0.2.25",
    "destinationPort": 443,
    "protocol": "tcp"
  },
  "credentials": [
    {
      "requestId": "credential-stream-west-certificate-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-certificate",
      "capability": "runtime",
      "material": { "projection": "certificate-der", "form": "public" }
    },
    {
      "requestId": "credential-stream-west-private-key-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-private-key",
      "capability": "runtime",
      "material": { "projection": "private-key-pkcs8", "form": "private-bytes" }
    }
  ],
  "credentialContext": {
    "actorId": "operator-alice",
    "operationId": "credential-operation-stream-west-01",
    "correlationId": "mesh-stream-west-01",
    "approveCredentialUse": true
  }
}

protocol: tcp describes the provider request; it does not force the provider to perform TLS. The provider must deliberately use the selected certificate and key in its TLS client stack. A direct stream may use another provider-defined protocol, including a non-socket protocol, when the provider advertises and implements it.

Credential-bearing OpenMeshBridge

POST /hovel.daemon.v1.DaemonService/OpenMeshBridge
Content-Type: application/json

{
  "moduleId": "lab-relay-mesh@v0.1.0",
  "localNetwork": "tcp",
  "request": {
    "runId": "run-lab-1",
    "nodeId": "relay-west",
    "destinationHost": "192.0.2.25",
    "destinationPort": 443,
    "protocol": "tcp"
  },
  "localHost": "127.0.0.1",
  "localPort": 0,
  "credentials": [
    {
      "requestId": "credential-bridge-west-certificate-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-certificate",
      "capability": "runtime",
      "material": { "projection": "certificate-der", "form": "public" }
    },
    {
      "requestId": "credential-bridge-west-private-key-01",
      "assignmentId": "assignment-node-relay-west",
      "slotName": "node-client-private-key",
      "capability": "runtime",
      "material": { "projection": "private-key-pkcs8", "form": "private-bytes" }
    }
  ],
  "credentialContext": {
    "actorId": "operator-alice",
    "operationId": "credential-operation-bridge-west-01",
    "correlationId": "mesh-bridge-west-01",
    "approveCredentialUse": true
  }
}

This calls the credential-aware stream opener before creating the live bridge. The certificate and key go to the provider process, not to the daemon's local socket adapter and not to the eventual local client. Every request ID in one array must be unique, and an assignment/slot pair may appear only once. These IDs claim credential delivery at most once; they do not make bridge creation replayable. A terminal delivery cannot be reused in a new provider process, so a deliberate replacement bridge must use fresh request IDs and a new credential operation ID.

Rotate without silently changing a live consumer

  1. Renew or rotate to create a new immutable certificate generation. Rotation creates a new key; renewal reuses and revalidates the source key.
  2. Stage that generation on the assignment with its current expectedRevision. The old active generation continues to resolve for Mesh calls.
  3. Prepare or test the consumer cutover through its explicit deployment path. The current Mesh selector cannot request the staged generation.
  4. Activate with the assignment's new revision only when cutover is ready. The next credential-bearing Mesh invocation resolves the newly active generation.
  5. Reload, restart, or reopen the provider-owned listener or session as required, verify PKI and Mesh operation records, then retire old material.

Assignment activation does not currently wait for a generic consumer acknowledgement and does not hot-reload an existing listener or stream. Document provider-specific overlap and rollback behavior, and use the operator runbook for revision fences, trust changes, revocation, and recovery.

Using a Mesh with another module

The clean integration is authenticated endpoint substitution: the front end asks the daemon for a bridge, then a small SDK-aware boundary authenticates the returned loopback endpoint before handing a normal connection to consumer protocol code. The consumer does not need to understand Mesh topology or the provider's route.

  1. Inspect the Mesh and choose a node or route.
  2. Call OpenMeshBridge with the remote destination and provider-defined protocol, plus an independent localNetwork of tcp or udp.
  3. Keep the returned capability in memory and use an SDK bridge helper to authenticate the local TCP connection or UDP peer.
  4. Give the resulting connection to the consumer protocol code, then run the module through the normal persisted-plan and confirmation path.
  5. Close the bridge and inspect ListMeshOperations.
POST /hovel.daemon.v1.DaemonService/OpenMeshBridge
Content-Type: application/json

{
  "moduleId": "lab-relay-mesh@v0.1.0",
  "localNetwork": "tcp",
  "request": {
    "runId": "run-lab-1",
    "nodeId": "relay-west",
    "listenerId": "listener-west",
    "destinationHost": "192.0.2.25",
    "destinationPort": 443,
    "protocol": "tcp"
  }
}
{
  "operationId": "mesh-op-43",
  "sessionId": "run-lab-1-session-1",
  "localHost": "127.0.0.1",
  "localPort": 43117,
  "localNetwork": "tcp",
  "capability": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
  "localAddress": "127.0.0.1:43117"
}

The shown capability is an example value. A real response carries a fresh, sensitive 256-bit bearer capability. Send the capability plus LF as the first TCP bytes, or as the first complete UDP datagram; Hovel consumes the preface instead of forwarding it. Go provides DialMeshBridge, Python provides connect_mesh_bridge, and Rust provides the generic connect_mesh_bridge helper plus connect_mesh_bridge_tcp and connect_mesh_bridge_udp for callers that handle only one local adapter. Keep the capability out of logs, results, caches, and durable configuration.

request.protocol is delivered to the provider unchanged and may name TCP, UDP, ICMP, GRE, raw IP, or a custom flow. It does not select the daemon socket. localNetwork selects only the loopback TCP stream or UDP datagram adapter and is returned in the response and Mesh operation bookkeeping.

A lost bridge-open response is not replayable. The bearer capability is returned once and is not retained in ListMeshOperations, so it cannot be recovered or guessed. The original bridge may nevertheless be active. List active bridge operations using the known module and run filters, match the route, destination, session, and local endpoint, and close the orphan with CloseMeshBridge by operation or session ID. Only after the old bridge is closed or confirmed absent should an operator-approved replacement call be made. When credentials are selected, that new call must use fresh credential request IDs plus a new credential operation and correlation ID; it returns a new capability.
Bridge close is retryable. CloseMeshBridge closes the local TCP or UDP endpoint first, then asks the provider to close its session. If provider cleanup times out or fails, the operation is recorded as failed but the daemon retains the bridge selector as a cleanup handle. Retry the same operation ID or session ID; do not open a replacement merely because the first close returned an error. A successful retry removes the handle and transitions the bookkeeping record to closed. The closed local endpoint never reopens during a retry.

The daemon bridge moves bytes or datagrams; the Mesh provider owns the remote route; the consumer protocol remains ordinary after the helper authenticates. Open one bridge per independent TCP connection or UDP peer association. A raw third-party tool that cannot send the capability preface needs a small local adapter rather than being pointed directly at the returned port.

A bridge does not add TLS, terminate TLS, validate certificates, inject trust, or turn UDP into DTLS or QUIC. Its bearer preface authenticates the local peer but does not encrypt traffic. End-to-end TLS bytes from a local client pass through unchanged; provider-terminated TLS is the provider's responsibility.

Use RunMeshTask instead when the provider itself owns the operation (for example, provider-native survey, command, upload-and-execute, or load). Today only survey is directly dispatchable through the daemon RPC; execution-capable kinds require the planned-task orchestration described above. Use a bridge when reusing a protocol client or module that already knows how to talk to a host and port. These approaches can coexist in one provider.

SDK method map

Wire methodGoPythonRust
mesh.describeMeshDescriber.DescribeMeshdescribe_meshModule::describe_mesh
mesh.topologyMeshTopologyProvider.MeshTopologymesh_topologyModule::mesh_topology
mesh.beaconsMeshBeaconProvider.ListMeshBeaconslist_mesh_beaconsModule::list_mesh_beacons
mesh.listenersMeshListenerProvider.ListMeshListenerslist_mesh_listenersModule::list_mesh_listeners
mesh.listener.start/stopMeshListenerLifecycleProviderstart_mesh_listener/stop_mesh_listenerModule::start_mesh_listener/stop_mesh_listener
mesh.taskMeshTaskProvider.RunMeshTaskrun_mesh_taskModule::run_mesh_task
mesh.open_streamMeshStreamProvider.OpenMeshStreamopen_mesh_streamModule::open_mesh_stream
credential.describeCredentialDescriber.DescribeCredentialDeliverydescribe_credential_deliveryModule::describe_credential_delivery
credential.runtimeCredentialRuntimeProvider.LoadRuntimeCredentialload_runtime_credentialModule::load_runtime_credential

See the repository SDK guides for language-specific starter code: Go, Python, and Rust. The wire protocol contains request and response examples for every Mesh method. Use the credential-provider guide for complete provider implementations; the two credential rows above are the only ones required by the current external Mesh selector path.

SDK starters

These excerpts show the optional-interface pattern. They intentionally do not open a real listener, contact a target, or advertise credential delivery. Replace the returned sample state with calls into your durable provider runtime, implement only the methods you advertise, and add the separate credential interfaces or hooks before using the credential-bearing requests above.

Go: compose optional interfaces

type LabMesh struct{}

func (LabMesh) DescribeMesh(hovel.MeshDescribeRequest) (hovel.MeshDescriptor, error) {
    return hovel.MeshDescriptor{
        Name: "lab relay mesh",
        Tasks: []hovel.MeshTaskSpec{{
            Kind:         hovel.MeshTaskSurvey,
            ReadOnly:     true,
            TargetScopes: []hovel.MeshTargetScope{hovel.MeshTargetNode},
        }},
        ListenerTypes: []hovel.MeshListenerSpec{{
            Kind:            "https-beacon",
            Deployments:     []hovel.MeshListenerDeployment{hovel.MeshListenerDeploymentSeparate},
            ManagementModes: []hovel.MeshListenerManagement{hovel.MeshListenerManagementProvider},
            Protocols:       []string{"https"},
        }},
    }, nil
}

func (LabMesh) ListMeshListeners(req hovel.MeshListenerListRequest) ([]hovel.MeshListener, error) {
    // Query provider-owned durable state here. An empty list must encode as [].
    return []hovel.MeshListener{}, nil
}

func (LabMesh) StartMeshListener(req hovel.MeshListenerStartRequest) (hovel.MeshListener, error) {
    // Idempotently ensure req.ListenerID exists; never return req.Config.
    return hovel.MeshListener{
        ID: req.ListenerID, Kind: req.Kind,
        State: hovel.MeshListenerStateActive,
        Deployment: req.Deployment, Management: req.Management,
    }, nil
}

func (LabMesh) StopMeshListener(req hovel.MeshListenerStopRequest) (hovel.MeshListener, error) {
    return hovel.MeshListener{ID: req.ListenerID, State: hovel.MeshListenerStateStopped}, nil
}

Add these methods to the normal hovel.Module type shown in the Go module guide. Add RunMeshTask, MeshTopology, ListMeshBeacons, or OpenMeshStream only when the provider supports those surfaces.

Python: override the hooks you support

class LabMesh(HovelModule):
    name = "lab-relay-mesh"
    version = "v0.1.0"
    module_type = "exploit"

    def describe_mesh(self, _request: MeshDescribeRequest) -> MeshDescriptor:
        return MeshDescriptor(
            name="lab relay mesh",
            tasks=[MeshTaskSpec(
                kind=MESH_TASK_SURVEY,
                read_only=True,
                target_scopes=[MESH_TARGET_NODE],
            )],
            listener_types=[MeshListenerSpec(
                kind="https-beacon",
                deployments=[MESH_LISTENER_DEPLOYMENT_SEPARATE],
                management_modes=[MESH_LISTENER_MANAGEMENT_PROVIDER],
                protocols=["https"],
            )],
        )

    def list_mesh_listeners(self, _request: MeshListenerListRequest) -> list[MeshListener]:
        return []

    def start_mesh_listener(self, request: MeshListenerStartRequest) -> MeshListener:
        # Idempotently ensure request.listener_id exists in durable provider state.
        return MeshListener(
            id=request.listener_id,
            kind=request.kind,
            state=MESH_LISTENER_STATE_ACTIVE,
            deployment=request.deployment,
            management=request.management,
        )

    def stop_mesh_listener(self, request: MeshListenerStopRequest) -> MeshListener:
        return MeshListener(id=request.listener_id, state=MESH_LISTENER_STATE_STOPPED)

Import the model and constant names from hovel_sdk, add the normal run hook shown in the Python module guide, and test through ModuleRPC so framing and dispatch are covered—not only direct method calls.

Rust: add optional methods to Module

fn describe_mesh(&self, _req: MeshDescribeRequest) -> Result<MeshDescriptor, String> {
    Ok(MeshDescriptor {
        name: "lab relay mesh".into(),
        tasks: vec![MeshTaskSpec {
            kind: MESH_TASK_SURVEY.into(),
            read_only: true,
            target_scopes: vec![MESH_TARGET_NODE.into()],
            ..MeshTaskSpec::default()
        }],
        listener_types: vec![MeshListenerSpec {
            kind: "https-beacon".into(),
            deployments: vec![MESH_LISTENER_DEPLOYMENT_SEPARATE.into()],
            management_modes: vec![MESH_LISTENER_MANAGEMENT_PROVIDER.into()],
            protocols: vec!["https".into()],
            ..MeshListenerSpec::default()
        }],
        ..MeshDescriptor::default()
    })
}

fn start_mesh_listener(&self, req: MeshListenerStartRequest) -> Result<MeshListener, String> {
    Ok(MeshListener {
        id: req.listener_id,
        kind: req.kind,
        state: MESH_LISTENER_STATE_ACTIVE.into(),
        deployment: req.deployment,
        management: req.management,
        ..MeshListener::default()
    })
}

Place these methods in the normal Module implementation shown in the Rust module guide. The default trait methods provide explicit unsupported-method errors for surfaces you omit.

Protocol-level contract test

def test_listener_lifecycle_contract():
    with ModuleRPC(LabMesh()) as rpc:
        started = rpc.call("mesh.listener.start", {
            "listenerId": "listener-west",
            "kind": "https-beacon",
            "deployment": "separate",
            "management": "provider",
            "config": {"bind": "127.0.0.1:8443"},
        })

    assert started["id"] == "listener-west"
    assert started["state"] == "active"
    assert "config" not in started

Testing checklist

  • Descriptor calls are deterministic and perform no target or listener I/O.
  • Every advertised task kind and target scope has a success and rejection test.
  • Daemon integration tests prove that direct survey dispatch works and every execution-capable task kind is rejected before its provider hook until persisted-plan orchestration is present.
  • Unsupported optional methods fail explicitly.
  • Topology IDs are stable; routes reference existing nodes and links.
  • Listener start/stop reject blank IDs before provider side effects and return the exact requested ID.
  • Repeated listener start/stop calls are safe and converge on the same state.
  • Listener configuration and credentials never appear in results, errors, logs, or daemon operation records.
  • Credential descriptors use separate certificate and private-key slots with exact purpose, endpoint role, consumer type, profile, compatibility, projection, form, size, and private-material policies.
  • Credential tests send credential.runtime calls and the consuming Mesh method through one framed RPC process, reject wrong consumer IDs and descriptor drift, and clear provider-owned temporary material.
  • Assignment tests prove that staging leaves the old active generation in use and activation changes the next resolved invocation without claiming to hot-reload a live resource.
  • Stream tests cover read, write, close, cancellation, and datagram boundaries when applicable.
  • Bridge tests bind only loopback, prove a stalling unauthenticated TCP client cannot block a valid client, reject missing or wrong capabilities, preserve TCP bytes or UDP datagram boundaries after authentication, and close sessions on failure.
  • A failed provider-session close retains the operation/session selector; retry succeeds without reopening the local endpoint and moves bookkeeping from failed to closed.
  • Bridge documentation and tests never imply that the local adapter adds TLS, trust validation, DTLS, or QUIC.
  • Task, stream, listener, and bridge calls appear in ListMeshOperations with provider and listener provenance.
task fmt
task check
task ci

Repository development always uses Task. For out-of-tree providers, mirror the SDK's framed-RPC tests in your own build and run them against malformed input, provider failures, duplicate IDs, and cleanup paths—not only the happy path.