Service Registry Contract

mandatory

The runtime service-discovery contract of the Koder Cloud: how a service instance registers/renews/deregisters its endpoint, how the registry expires dead instances, how a consumer opts a service into edge routing, and the health semantics — the single source of truth that the PRODUCER (Koder DNS registry, dns#015) stores and the CONSUMER (Koder Jet, jet#027) reads. Ratified by infra-RFC-007 (Slice 3 = this contract). Replaces hand-edited endpoint maps (koder-service-ports.toml + jet sites.toml) with a registered source of truth.

Status: v1.0.0. Formalizes the contract already implemented by the producer (Koder DNS, dns#015) and the consumer (Koder Jet, jet#027); both predate this doc and agreed by convention — this spec makes that agreement explicit, versioned, and conformance-testable so the two ends cannot drift (the failure mode behind the voice.koder.dev incident in infra-RFC-007).

Roles

  • Producer = the Koder DNS service registry (infra/net/dns): stores

    instances, serves them (SRV + the REST API below), and reaps dead ones. The single source of truth for "where is service X right now?".

  • Registrant = any Koder service, which registers its own endpoint on

    (re)start (auto-register-on-deploy) and heart-beats while alive.

  • Consumer = anything that routes to / resolves a backend (the Koder Jet

    edge, jet#027; future: other clients).

R1 — ServiceInstance (canonical wire shape)

A registered instance is a JSON object with these fields (the producer's discovery.ServiceInstance):

Field JSON Type Notes
ID id string Stable instance id; defaults to <name>-<address>-<port> when omitted
Name name string Logical service name (required)
Address address string Host/IP reachable by consumers (required)
Port port int Required, non-zero
Protocol protocol string default tcp
Tags tags string[] key=value tags also accepted (see R5)
Metadata metadata mapstring,string see R5
Weight / Priority weight/priority int SRV weighting; defaults 100 / 10
Healthy healthy bool R6
TTL ttl_ns int64 (ns) lease; 0 → registry default (R3)
RegisteredAt / UpdatedAt registered_at/updated_at RFC3339 server-stamped

R2 — Registration is an idempotent upsert

POST /api/v1/services/register with a ServiceInstance body. Registering an ID that already exists refreshes its mutable fields and renews its lease (it does NOT error). This is what makes auto-register-on-deploy safe to call on every (re)start without leaking duplicate or stale entries. Response: {id, created} (created=true only on first registration). name+address+port are required.

R3 — Lease, heartbeat, expiry

  • Each instance has a TTL lease (its ttl_ns, or the registry default — currently

    90s — when unset/non-positive). A registry default of 0 means "never expire" (a static entry).

  • POST /api/v1/services/heartbeat {id} renews the lease (bumps updated_at).

    Renewing an unknown/expired id returns 410 Gone — the registrant must re-register.

  • The registry reaps any instance whose updated_at + effectiveTTL has passed

    (reaper interval currently 30s). A service that dies stops heart-beating and disappears on its own — the registry never serves a dead endpoint.

R4 — Deregistration

POST /api/v1/services/deregister {id} removes the instance immediately. 404 if unknown. Registrants SHOULD deregister on graceful shutdown (the reaper is the backstop for ungraceful exits).

R5 — Edge-routing opt-in + topology metadata

  • A service opts into edge routing by setting jet.domain (in metadata,

    or as a jet.domain=<host> tag) to its public vhost (e.g. voice.koder.dev). An instance without jet.domain is internal-only — registered + discoverable but not routed by the edge. (Analogue of Traefik's koder.jet.enable.)

  • metadata.container / metadata.service (or container= tag) carry the

    topology labels used to GENERATE koder-service-ports.toml (R8).

R6 — Health

healthy reflects the producer's health checks (infra/net/dns HealthChecker: tcp/http). Consumers MUST honour it (route only to healthy instances for new traffic); the flag is preserved end-to-end so a consumer can display/treat unhealthy instances distinctly. GET /api/v1/services?healthy_only=… is the producer's filter; SRV queries return healthy instances only.

R7 — REST API surface (authenticated, X-API-Key)

Method + path Purpose
POST /api/v1/services/register upsert an instance (R2)
POST /api/v1/services/heartbeat renew a lease (R3)
POST /api/v1/services/deregister remove an instance (R4)
GET /api/v1/services list service names; ?detail=1 → full instances
GET /api/v1/services/export render the registry as koder-service-ports.toml (R8)

(SRVATXT DNS resolution of the same registry is the producer's existing service.dns domain — out of scope for this REST contract.)

R8 — Generated artifacts

koder-service-ports.toml (meta/docs/stack/registries/) becomes generated from the registry (/services/export); the hand-maintained file is deprecated. A drift check compares the live registry against a static reference and reports missing / extra / owner_mismatch per port.

Conformance

  • Producerinfra/net/dns (dns#015): R1–R8. Tests:

    infra/net/dns/internal/discovery/registry_slice1_test.go (upsertrenewexpire/ default-TTLexportdrift) + internal/api/server_slice1_test.go (register→ heartbeat→export→deregister, 410 on expired).

  • Consumerinfra/net/jet (jet#027): R1, R5, R6. Tests:

    infra/net/jet/internal/discovery/koder_registry_test.go (opt-in via jet.domain metadata/tag; internal-only excluded; Healthy preserved; hot-reload).

Non-goals

  • Sidecar/eBPF mesh data-plane (that's Koder Mesh's own scope; this contract is the

    registry, not per-hop proxying).

  • Per-service identity / mTLS — see stack-RFC-009 (complementary, not here).