Mesh Contracts

Status: DRAFT v0.1 — promoted from benzene-go's docs/design/mesh.md. The .NET implementation is the primary implementation of this document and covers the full contract: Benzene.Mesh.Wire (descriptor, reserved topic, trace feed) and Benzene.Mesh.Collector (the §4–§6 collector), together passing all three conformance fixture files via test/Benzene.Conformance.Test. The Go port (its mesh/meshd packages) is a fully conforming implementation — this contract was originally extracted from it — and the two have hosted each other's services in live cross-language fleets, in both directions. The pre-existing Benzene.Mesh.* visibility packages (aggregator/UI/Tempo, developed independently against the roadmap) are collector-side idiom this contract doesn't constrain; §9 maps them, and bridging the aggregator's artifact pipeline to Benzene.Mesh.Collector is the natural integration follow-up.

Benzene Mesh is the application-level mesh: every service self-describes (its topics, versions, and payload schemas, derived from its handler registry), reports health, and emits one semantic trace event per invocation — so a collector can render a live, cross-cloud fleet view (catalog, health, stats, who-calls-whom) that is derived from running code, never declared. This document specifies everything that crosses a process boundary to make that work identically across language ports.

At Core level, mesh is optional, and so is each of its feeds; the normative degradation rule of §6 applies to every section here. A service claiming the Cloud Service Profile MUST provision the service-side feeds (its R6) — for such a service, §6 governs runtime degradation, not whether the feeds exist.

1. The reserved mesh topic

A mesh-enabled service MUST intercept the reserved topic id mesh (plus any app-chosen aliases) the same way health-check interception works (core-concepts.md §5): interception is by topic id alone, ignoring version; any other topic passes through unchanged. The response is status Ok with the ServiceDescriptor (§2) as payload.

Provisioning this endpoint is a deployment decision: a service that must not expose it (e.g. pending a security review) simply does not install the interception, and every other mesh feed keeps working (§6).

2. ServiceDescriptor

The service's self-description, derived at startup from its handler registry — never hand-maintained. Also the body of a mesh:register message (§4).

{
  "service": "orders",
  "serviceVersion": "1.4.2",
  "instanceId": "orders-7f9c",
  "runtime": "go",
  "binding": "http",
  "placement": { "cloud": "aws", "region": "eu-west-1" },
  "topics": [
    {
      "id": "order:create",
      "version": "v2",
      "requestSchema":  { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] },
      "responseSchema": { "type": "object", "properties": { "id":   { "type": "string" } }, "required": ["id"] }
    }
  ],
  "descriptorHash": "sha256:…",
  "degraded": ["registry"],
  "profile": { "name": "cloud-service", "missing": ["R6"] }
}

2.1 Schema derivation

requestSchema/responseSchema describe the marshaled JSON form of the registered request/response types, expressed in a subset of the JSON Schema 2020-12 vocabulary. A port derives them once at startup, from whatever type information its registration API captures. The mapping (left column names the language-neutral construct; each port applies it to its own type system):

Construct Schema
string {"type":"string"}
boolean {"type":"boolean"}
integer kinds {"type":"integer"}
floating kinds {"type":"number"}
timestamp type (marshals RFC 3339) {"type":"string","format":"date-time"}
byte array (marshals base64) {"type":"string"}
text-marshaling custom type {"type":"string"}
raw/unknown JSON, dynamic values, custom serializers {} (unconstrained)
nullable/optional of T T's schema with "null" added to its type
list/array of T {"type":"array","items":<T>}
string-keyed map of T {"type":"object","additionalProperties":<T>}
object/record {"type":"object","properties":{…},"required":[…]}

Object rules:

Two ports registering equivalent canonical types MUST produce identical topics entries — this is pinned by conformance/mesh-descriptor-cases.json.

2.2 descriptorHash

"sha256:" + lowercase-hex(sha256(canonicalJSON(descriptor))), where the hashed descriptor has instanceId, degraded, profile, and descriptorHash itself blanked. The hash covers the contract (identity, placement, topics, schemas):

Canonical JSON: object members in a fixed documented order — declaration order for the fixed descriptor shape, lexicographic for schema maps — with no insignificant whitespace. Because runtime participates, the hash is per-port by design: it detects this service's redeploys, and is never compared across ports.

3. TraceEvent

One pipeline invocation as the mesh sees it — semantic (topic + Benzene status), not transport-shaped.

{
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "parentSpanId": "0af7651916cd43dd",
  "service": "orders",
  "instanceId": "orders-7f9c",
  "topic": "order:create",
  "topicVersion": "v2",
  "status": "ValidationError",
  "durationMs": 12.4,
  "startedAt": "2026-07-16T09:14:03.120Z",
  "correlationId": "abc-123"
}

4. Collector topics

A collector is an ordinary Benzene service serving these topics over any envelope-capable transport (transport-bindings.md):

Topic Body Success payload
mesh:register ServiceDescriptor (§2) {"accepted":1}
mesh:heartbeat Heartbeat (§5) {"accepted":1}
mesh:traces {"events":[TraceEvent…]} {"accepted":<count>}

Sender behavior (normative for ports): trace export MUST be asynchronous, non-blocking, and lossy under backpressure — a full buffer drops events, a failed send drops the batch, and no mesh feed may ever fail, slow, or block the invocation it observed.

Collector behavior is pinned by conformance/mesh-collector-cases.json. Query read models (mesh:query:*) as implemented by the Go collector are deliberately not part of this contract yet: they are one collector's read models, and join the spec if a second collector or third-party view needs them pinned. The collector fixtures exercise them only as the observable surface for asserting ingest/derivation behavior.

5. Heartbeat

The health-check aggregate response (wire-contracts.md §5) reused byte-for-byte, wrapped with identity:

{
  "service": "orders",
  "instanceId": "orders-7f9c",
  "descriptorHash": "sha256:…",
  "sentAt": "2026-07-16T09:14:03Z",
  "health": { "isHealthy": true, "healthChecks": { "db": { "status": "ok", "type": "postgres" } } }
}

A heartbeat whose descriptorHash differs from the registered descriptor's hash means the instance runs a contract the collector hasn't learned. The collector MUST surface the mismatch (the Go collector reports per-instance hashMatches) rather than silently keeping stale topics.

6. Degradation (normative)

Every mesh feed — the descriptor endpoint, registration, heartbeats, traces — is independent and optional, on both sides:

7. Conformance

Three fixture files in conformance/ pin this document; their formats and the canonical mesh handlers are documented there. A port that implements mesh MUST pass mesh-descriptor-cases.json and mesh-trace-cases.json; a port that additionally implements a collector MUST pass mesh-collector-cases.json. A port that implements neither is unaffected at Core level — mesh is an optional module there, and the Core spec creates no obligation to implement it, only the obligation to implement it compatibly. Supporting the Cloud Service Profile, however, requires the service-side feeds, so a port that wants its services to claim the profile implements §§1–3 and §5 and passes the two service-side fixture files.

8. Conformance language note

Per the repository's one design rule, everything in §§1–6 is a Benzene concept — wire shapes and cross-process behavior. How a port derives its descriptor (attribute scanning vs explicit registration), how a collector stores state, and what a view renders are idioms and stay out of this document.

9. Relationship to the existing .NET mesh packages (informative)

The Benzene.Mesh.* packages implement a mesh visibility pipeline that predates this contract: a human-maintained mesh.json registry, an aggregator that polls each service's OpenAPI /spec and /health endpoints (or receives opportunistic MeshServiceReport self-reports), raw-spec hashing for contract drift (MeshHashing, HMAC-SHA256), Tempo/Prometheus-derived topology.json edges, and a static Mesh UI. The two designs solve the same problem from opposite ends, and several of the .NET roadmap's own open gaps are exactly what this contract provides:

Benzene.Mesh.* today This contract Convergence
mesh.json registry, human-edited catalog derived from mesh:register + heartbeats registry remains a pull-mode bootstrap for unmeshed services; meshed services need no entry
MeshServiceReport (name, reportedAt, opaque OpenAPI SpecJson, health, error) ServiceDescriptor (§2, topics + derived schemas) + Heartbeat (§5) the self-report is register+heartbeat in one; the descriptor replaces the opaque spec for wire purposes — the OpenAPI artifact can remain as an enrichment
MeshHashing (HMAC-SHA256 of raw spec text) descriptorHash (§2.2, SHA-256 of canonical descriptor JSON) .NET adopts §2.2 on the wire; MeshHashing stays internal to its OpenAPI-artifact drift feature
TopologyEdge from Tempo/Prometheus (client/server + rates/latencies) consumer edges derived from TraceEvent parentage (§3–4) the native trace feed yields edges with no external tracing stack; Tempo remains an optional additional TopologyEdgeSource
MeshSelfReportMiddleware (opportunistic, throttled, never blocks) trace middleware + heartbeat, same never-affect-the-service rule (§6) the ethos is already shared; the shapes converge
aggregator + manifest.json/services/*.json + Mesh UI a collector (§4) with its own read models and view the aggregator becomes a conformant collector by also accepting the three ingest topics alongside its pull sources
known staleness gap (no Stale status) heartbeats give last-seen; missing feeds are rendered as reduced (§6) solved by adopting §5
health: HealthCheckResponse the same wire-contracts §5 shape, reused verbatim already shared — no change

Nothing in the existing packages needs to be discarded: pull-based aggregation, the OpenAPI artifacts, Tempo topology, and the UI are collector-side idioms this contract deliberately does not constrain. Conformance for the .NET port means adding the wire layer: descriptor derivation (§2) with the reserved topic (§1), the trace feed (§3), and — for the aggregator — the ingest topics (§4).