Platform Integration
Audience: platform developers — kis.ai engineers building or extending a service that consumes the config and discovery services. You have access to the kis.ai monorepo and the lib-chassis Go libraries.
You also need the HTTP API reference — the chassis libraries call exactly those endpoints under the hood, and when something misbehaves you’ll want to confirm against the wire protocol. This doc covers everything the libraries do on top of the API.
1. Why the libraries?
Section titled “1. Why the libraries?”The REST + SSE API is the source of truth and works from any
language. The Go libraries (git.kis.ai/kis.ai/lib/chassis/config
and git.kis.ai/kis.ai/lib/chassis/discovery) sit on top of that
API and give kis.ai services:
| Capability | What you’d write by hand | What the library does |
|---|---|---|
| Typed access | json.Unmarshal into your own structs per call | smap.OrderedMap returned from one of three getters; deep-key path access (cfg.Get("features.fast_path").Bool(false)); optional sugar helpers (ConfigBool, ClusterStringSlice, …). |
| Change callbacks | Hand-rolled SSE consumer + JSON decode loop + dispatch | cc.OnChange("my-watch", func(ctx, evt) error { ... }). One handler per name; chassis fans out per-event. |
| Cache + resume | Track Last-Event-Id, persist it, replay on reconnect | bbolt write-through cache; cursor persisted across process restart; SSE consumer reconnects with exponential backoff. |
| Local fallback | If remote is down, your code 500s | Reads fall through in-memory cache → LocalConfig fallback → empty. Synchronous reads NEVER block on the network. |
| Layered resolution (local mode) | Re-implement the same merge algorithm you’d hit server-side | Local-mode client supports the same cluster: + tenants: layered schema as the config service; same resolution rules. Dev tests don’t need a running config.svc instance. |
| Discovery client | Same SSE + REST work for /services/* + /stream/discovery | discovery.Client with Register, Heartbeat, Deregister, Instances, WatchService. |
If you’re writing kis.ai service code, you almost certainly want the libraries. The only reason to call the REST API directly from Go is in tooling / one-off admin scripts.
2. The three getters
Section titled “2. The three getters”The config client exposes three distinct reads matching three distinct scopes of configuration:
| Method | Returns | Scope |
|---|---|---|
GetConfig(ctx) | Top-level bootstrap keys MINUS cluster: and tenants: blocks. | Service-own — knobs that belong to this binary (log level, listener port, feature flags, OTel collector, etc.) |
GetClusterConfig(ctx) | The cluster: block, resolved for this service’s dc:cluster:service:version path. | Cluster-wide — shared infra coordinates (kafka brokers, Jaeger endpoint, shared caches, etc.) |
GetTenantConfig(ctx) | The resolved *Tenant for the CPET in ctx. | Per-tenant — multi-tenancy overrides for the current request’s tenant. |
All three serve from the same client. The transport (remote SSE vs local file/dir) is determined by bootstrap YAML, not by the call.
3. Bootstrap shapes
Section titled “3. Bootstrap shapes”The chassis client supports four modes; one bootstrap YAML drives all of them:
service: myservice # required; chassis identityversion: 1.0.0sid: ${HOSTNAME}
host: "" # listener config (chassis-owned)port: "8443"zerotrust: true
log: level: info
features: # service-own keys → returned by GetConfig() new_dashboard: true
# --- one of these four mode selectors ---
# (a) Remote mode: SSEs against the config.svc binaryconfig_url: https://config.internal:7123config_apikey: ${env:MYSERVICE_CONFIG_APIKEY}
# (b) Local-file mode: chassis loads cluster + tenants from a single YAML# config: /etc/myservice/cluster.yaml
# (c) Local-dir mode: chassis walks a directory and merges *.yaml/*.yml# config: /etc/myservice/config/
# (d) Bootstrap-inline mode: cluster + tenants live in this bootstrap# cluster:# kafka_brokers: ["kafka-0:9092", "kafka-1:9092"]# tenants:# - { key: acme:forge:prod:acme-prod, name: acme-prod, active: true,# overrides: { log_level: debug } }Precedence: config_url > config: > inline cluster: /
tenants:. Setting both config_url and config: logs a warning
and ignores config:.
4. The Client lifecycle
Section titled “4. The Client lifecycle”When your service boots through svcboot.NewServerCommand, the
chassis does this for you:
- Loads bootstrap YAML.
- Constructs the
config.ClientwithRemoteURL(fromconfig_url:) orLocalPath(fromconfig:) — whichever is set — plusLocalConfig(always the bootstrap OrderedMap, used byGetConfig()and as a fallback for the other getters). - Registers the client in the lib-zero DI container under
<service>-config-v2. Initis lazy — never blocks on the network, never returns “remote unreachable” errors. The SSE consumer runs in the background with exponential backoff (1 s → 30 s cap). Reads serve from cache + local fallback immediately.
In your service:
import ( "git.kis.ai/kis.ai/lib/chassis/config" "git.kis.ai/kis.ai/lib/zero/container")
cc, _ := container.ResolveTyped[config.Client]("myservice-config-v2")5. Code samples
Section titled “5. Code samples”5.1 Service-own knobs
Section titled “5.1 Service-own knobs”func loadFeatureFlags(ctx context.Context, cc config.Client) bool { // Direct path access on the returned map: cfg, _ := cc.GetConfig(ctx) return cfg.Get("features.new_dashboard").Bool(false)}
// Or via the sugar helper:func loadFeatureFlagsHelper(ctx context.Context, cc config.Client) bool { return config.ConfigBool(ctx, cc, "features.new_dashboard")}The Config* helper family (in
lib-chassis/config/helpers_config.go) covers common types:
ConfigString, ConfigBool, ConfigInt, ConfigMap,
ConfigStringSlice, ConfigMapSlice, ConfigInt64, ConfigHas.
All zero-value on miss — use for known-present keys.
5.2 Cluster-wide settings
Section titled “5.2 Cluster-wide settings”func kafkaBrokers(ctx context.Context, cc config.Client) []string { return config.ClusterStringSlice(ctx, cc, "kafka_brokers")}The Cluster* helper family mirrors Config* but reads from
GetClusterConfig() — i.e., the resolved cluster: block.
5.3 Per-tenant config on the request path
Section titled “5.3 Per-tenant config on the request path”The chassis TenantMiddleware extracts CPET (customer, product,
environment, tenant) from headers / JWT claims / mTLS SAN and puts
them into ctx. Then:
func handle(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cc, _ := container.ResolveTyped[config.Client]("myservice-config-v2")
t, kerr := cc.GetTenantConfig(ctx) if kerr != nil { // ErrCodeTenantNotFound or ErrCodeTenantNotActive — return // 403 to the caller. http.Error(w, kerr.Error(), http.StatusForbidden) return } // t.RawConfig is the merged overrides — your service-specific // per-tenant knobs. rateLimit := t.RequestLimit // ratelimit.Limiter _ = rateLimit}5.4 Reacting to changes
Section titled “5.4 Reacting to changes”cc.OnChange("myservice-watch", func(ctx context.Context, evt svcclient.ChangeEvent) error { switch evt.Key { case svcclient.ChangeKeyCluster: fresh, _ := cc.GetClusterConfig(ctx) applyClusterConfig(fresh) default: // Tenant-level: evt.Key is the CPET key t, _ := cc.GetTenantConfigByKey(ctx, evt.Key) applyTenantConfig(t) } return nil })Callbacks fire from a per-handler goroutine so a slow handler doesn’t block others. They MUST be idempotent — false positives (callbacks where the value didn’t actually diverge) are allowed by the contract.
6. Resilience contract
Section titled “6. Resilience contract”The chassis client is built for never-hard-fail semantics:
| Scenario | Behavior |
|---|---|
config_url set, config.svc binary down at boot | Init returns nil. Reads serve from bbolt warm-cache if any; else fall through to bootstrap cluster:/tenants: fallback; else empty/NotFound. SSE consumer retries with exponential backoff, reconnects when remote returns, refreshes cache + fires change callbacks. |
| Remote disappears mid-flight | Existing cache stays valid. New reads keep serving from cache. SSE reconnect handles the catch-up. |
| Cursor persisted across process restart | Yes — written to LocalCachePath (bbolt) on every snapshot. Restart picks up where you left off. |
Remote sends full_resync | In-memory cache cleared; subsequent snapshot events repopulate; reads during the gap fall through to LocalConfig. |
| Bad bootstrap YAML / disk error | Init returns an error — the only hard-fail mode. |
LoadAllTenants returns | Immediately, with nil. Background fetches populate the cache as they complete. |
Your code should be written assuming the result is “best-known- good,” not “authoritative live state.” For values that MUST exist before serving traffic, either:
- Declare them inline in the bootstrap YAML so the
LocalConfigfallback has them, OR - Block
/readyreturning 200 until you’ve seen the values via a change callback.
7. The Discovery client
Section titled “7. The Discovery client”Mirror story for discovery.Client:
import ( "git.kis.ai/kis.ai/lib/chassis/discovery" "git.kis.ai/kis.ai/lib/zero/container")
sd, _ := container.ResolveTyped[discovery.Client]("myservice-discovery")
// Register this instancelease, err := sd.Register(ctx, discovery.LeaseRequest{ Service: "myservice", DC: "us-west", Cluster: "prod-cluster-01", Addr: "10.0.1.5:8443", TTL: 15 * time.Second,})defer sd.Deregister(ctx, lease.ID)
// Heartbeat in a background goroutine (chassis offers a helper to// do this for you; see lib-chassis/discovery/keepalive.go).
// Look up other servicesinstances, _ := sd.Instances(ctx, "forge-meta")
// Watchsd.WatchService(ctx, "forge-meta", func(evt discovery.Event) { // upsert / down / expire events})URL configuration uses a separate bootstrap key —
servicediscovery_url: — so the discovery client and config client
can point at different endpoints in the future. Today they
typically share :7123 (the same config.svc binary).
8. Building a new “block”
Section titled “8. Building a new “block””A “block” in kis.ai parlance is a kis.ai service — a binary that implements one or more product capabilities. Standard layout for a new block:
baas/myservice/├── version/root.go # Name, Version, BuildCommit, ClientName├── cmd/│ ├── root.go # svcboot.NewServerCommand wiring│ └── version.go # util.PrintVersion wrapper├── internal/│ └── server/ # your subsystems, builders, routes└── main.go # cmd.SetEmbedded(Embedded); cmd.Execute()The svcboot.ServiceConfig you pass to NewServerCommand is where
you wire your service-specific behavior:
svcboot.NewServerCommand(svcboot.ServiceConfig{ Name: version.Name, ClientName: version.ClientName, Version: version.Version, Use: "server", DefaultCfg: "/etc/myservice/config.yaml",
PreStart: func(ctx context.Context, serviceCfg smap.OrderedMap) error { // Build subsystems. Resolve config + discovery clients from // the DI container (chassis registered them already). // Stash state in a closure-captured struct that RegisterRoutes // + OnShutdown also reference. return nil },
RegisterRoutes: func(ctx context.Context, r *httprouter.Router) { // Mount your HTTP handlers on r. Chassis already mounted // /health, /ready, /version — don't re-register. },
CustomMiddleware: func(r *httprouter.Router, cfg smap.OrderedMap, name string) http.Handler { // Optional. Default chain is router → TenantMiddleware → // ContextBasedTimeoutMiddleware. Wrap with your own auth / // request-id middleware on top. return r },
OnShutdown: func(ctx context.Context) { // Drain workers, close stores, etc. },})config.svc itself is a worked example. If you want to see a
fully-realized service:
baas/config/cmd/root.go— svcboot wiringbaas/config/internal/server/{subsystems,routes,server}.go— Build / RegisterAll / Shutdown helpersbaas/config/version/root.go— identity constantsbaas/config/main.go—cmd.SetEmbedded(...)+cmd.Execute()
Conventions to follow:
- Service identity at top-level YAML keys.
service:,version:,sid:are chassis-owned. Don’t nest them under aservice:map — chassis overwrites it. - Health endpoints belong to chassis. Never register
/health,/ready,/versionin yourRegisterRoutes. - Auth bypass for chassis probes. If you provide a
CustomMiddleware, allow-list/health,/ready,/versionso probes stay unauthenticated. - SSE handlers must clear the per-request write deadline. Use
http.NewResponseController(w).SetWriteDeadline(time.Time{}). Otherwise the chassis server-wideWriteTimeoutkills long- lived streams. - bbolt opens need a finite Timeout.
Timeout: 0blocks on stale flock; use 5 s and surface the failure. - OTel via
lib-zero/telemetry.Init— call it from PreStart whenserviceCfg.Get("telemetry.collector")is non-empty.
9. Local-mode tests
Section titled “9. Local-mode tests”You don’t need a running config.svc binary for unit tests. Build a local-mode client directly:
import ( "context" "git.kis.ai/kis.ai/lib/chassis/config" "git.kis.ai/kis.ai/lib/supermap/smap")
func TestThing(t *testing.T) { cc, err := config.New(config.Options{ ServiceName: "myservice", LocalConfig: smap.Of( "log", smap.ToMSI(smap.Of("level", "debug")), "cluster", smap.ToMSI(smap.Of( "kafka_brokers", []any{"k0:9092"}, )), "tenants", []any{ smap.ToMSI(smap.Of( "key", "acme:forge:prod:acme-prod", "name", "acme-prod", "active", true, "overrides", smap.ToMSI(smap.Of( "log_level", "trace", )), )), }, ), }) if err != nil { t.Fatal(err) } if err := cc.Init(context.Background()); err != nil { t.Fatal(err) } defer cc.Close(context.Background())
// Test against cc the same way your service code uses it.}For tests that exercise file-watch behavior, pass LocalPath
pointing at t.TempDir() and write/modify files during the test.
10. Where to put a knob
Section titled “10. Where to put a knob”| Knob | Goes in | Read via |
|---|---|---|
log.level for the entire fleet of this service | Top-level | GetConfig |
log.level per tenant | tenants[].overrides.log_level | GetTenantConfig(ctx).RawConfig.Get("log_level") |
| Kafka brokers (shared across services in cluster) | cluster.kafka_brokers | GetClusterConfig |
| Feature flag for one service | Top-level or cluster.features if cluster-scoped | GetConfig / GetClusterConfig |
| Tenant API key / secret | NEVER in config — use lib-vault | — |
| Per-customer SLA tier | tenants[].overrides.sla_tier | t.RawConfig.Get("sla_tier") |
11. Further reading
Section titled “11. Further reading”- HTTP API — every endpoint, every payload. The libraries call these.
lib-chassis/config/options.go—Optionsstruct, fully documented in code.lib-chassis/config/client.go— theClientinterface.lib-chassis/config/helpers_config.go+helpers.go— the helper families.lib-chassis/discovery/client.go—discovery.Clientinterface.