Skip to content
Talk to our solutions team

Platform Integration

Audience: platform developers — kis.ai engineers building a new service (a “block”) in the monorepo, or maintaining an existing service that consumes the trust plane. You have full source-code access; your service is expected to use lib-vault.VaultClient rather than write its own HTTP client.

This document is the Go-SDK layer. The underlying wire protocol is the HTTP API — you should know what’s there since it’s the eventual contract, but you should be calling Vault through lib-vault, never via direct HTTP.

For building a new block end-to-end (chassis wiring + Vault DI key + boot order), see the guide on building a new block. For deploying vault.svc, see Operations. For the agent on a real node, see Agent operations. For the design, see the consolidated Vault spec.

It’s tempting to copy the curl example from the API reference into your service and call it a day. Don’t. lib-vault.VaultClient provides — for free, and consistently across every kis.ai service:

  • Transport switching (remote vs. agent) per cluster, with no Go-code change.
  • Consistent prefix-encoded scope model (vault: / vaultcluster: / vaultkey: / plain:).
  • Caching of the hot paths (JWT mint, cert public-key retrieval).
  • Tenant context propagation from chassis TenantMiddleware straight into the request scope.
  • DI registration at a well-known key so chassis middleware (which validates inbound JWTs) finds it.
  • A migration path: when a cluster flips to the agent transport, your service inherits it without a recompile.

If you find yourself reaching for net/http to talk to Vault, stop and check whether lib-vault already does what you need — it probably does.

Your service reads from vault.svc through a single lib-vault.VaultClient provided by the chassis at DI key <service>-vaultclient. The same interface covers everything a workload needs:

CapabilityMethod
SecretsGetSecret, SaveSecret, CheckIfSecretExists, DeleteSecret
Service auth (JWT)AuthenticateAndGetJWT(svc)
Cert retrievalRetrieveServiceCertificate, RetrieveInternalCertificateForJWT*, RetrieveIssuerCertificate, RetrieveExternalCertificate
Cert generationGenerateServiceCertificate, GenerateInternalCertificateForJWT
Cert save (external)SaveExternalCertificate

Two implementations satisfy the interface:

ImplementationTransportSelected by
RemoteVaultClientHTTPS to vault.svcvault.client.transport: remote (default)
LocalAgentVaultClientUnix socket /run/vault/vault.sock to vault-agentvault.client.transport: agent

The chassis picks one at boot based on cluster config; your Go code doesn’t change. Migrating from one to the other is a config flip, per-consumer, and independently reversible.

When your service boots through svcboot.NewServerCommand, the chassis does this for you:

  1. Loads bootstrap YAML.
  2. Calls lib-vault.InitializeVaultClient(ctx, dc, cluster, vaultURL, vaultCfg).
  3. Based on vault.client.transport, constructs either RemoteVaultClient or LocalAgentVaultClient.
  4. Registers the result in the lib-zero container at key <service-name>-vaultclient.

You just resolve it:

import (
"git.kis.ai/kis.ai/lib/vault"
"git.kis.ai/kis.ai/lib/zero/container"
)
vc, _ := container.ResolveTyped[vault.VaultClient]("myservice-vaultclient")

The DI key is stable across transports. RemoteVaultClient’s constructor uses lazy init; LocalAgentVaultClient’s validates the socket path at construction but defers the actual dial until the first call.

3. Cluster config — selecting the transport

Section titled “3. Cluster config — selecting the transport”
vault:
client:
# Default if unset.
transport: remote # "remote" | "agent"
# transport=remote:
# URL is taken from vault.url (chassis-owned) at the cluster level.
# transport=agent:
agent_socket: /run/vault/vault.sock # override; default shown.
# dial_timeout: 5s # initial dial timeout.
# Cache TTL hints. RemoteVaultClient has these built-in (jwt: 58m,
# cert: 6h). LocalAgentVaultClient forwards them as hints to the
# agent — the agent decides actual TTL based on the issued lease.
cache:
jwtttl: 58m
certttl: 6h

During the migration:

  • Default to remote for clusters where vault-agent isn’t deployed everywhere yet.
  • Flip a single consumer to agent to validate the path; soak for 24h; if metrics + audit look right, flip the next consumer.
  • Roll back is a flip in the other direction. No code changes either way.

Secrets are scoped — a “DB password” for tenant acme:forge:prod:acme-prod is different from one for globex:forge:prod:globex-prod. lib-vault encodes the scope into the lookup name:

PrefixScopeSource of scope values
vault:NAMEdc + cluster + customer + product + env + tenant (CPET)request context (set by chassis TenantMiddleware)
vaultcluster:NAMEdc + cluster onlyclient init
vaultkey:NAMEglobal; no scope
plain:VALUE or no prefixNONE — the input IS the secret value
// Tenant-scoped (most common):
pw, _ := vc.GetSecret(ctx, "vault:db_password")
// Cluster-scoped (shared across tenants in this dc/cluster):
shared, _ := vc.GetSecret(ctx, "vaultcluster:kafka_token")
// Global (across the entire trust domain):
global, _ := vc.GetSecret(ctx, "vaultkey:platform_signing_key")
// Plain text (returns "literalvalue" with a warning logged):
v, _ := vc.GetSecret(ctx, "plain:literalvalue")
v, _ = vc.GetSecret(ctx, "literalvalue") // same — no prefix = plain

Why this matters: your service config can reference a secret without knowing the scope, e.g.:

db:
password: vault:db_password

At runtime, the same config works for every tenant because the SDK plugs in the request’s CPET. Hard-coding acme:forge:prod:acme-prod:db_password in code is a category mistake.

import (
"context"
"git.kis.ai/kis.ai/lib/vault"
"git.kis.ai/kis.ai/lib/zero/container"
)
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
vc, err := container.ResolveTyped[vault.VaultClient]("myservice-vaultclient")
if err != nil {
return nil, err
}
// ctx must carry the CPET (set by chassis TenantMiddleware).
pw, kerr := vc.GetSecret(ctx, "vault:db_password")
if kerr != nil {
return nil, kerr
}
return sql.Open("postgres", fmt.Sprintf("%s password=%s", dsn, pw))
}

5.2 Minting a service JWT for outbound calls

Section titled “5.2 Minting a service JWT for outbound calls”
func callJanus(ctx context.Context) error {
vc, _ := container.ResolveTyped[vault.VaultClient]("myservice-vaultclient")
token, kerr := vc.AuthenticateAndGetJWT(ctx, "janus")
if kerr != nil {
return kerr
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://janus/...", nil)
req.Header.Set("Authorization", "Bearer "+token)
// ...
return nil
}

In the new model, audience validation is enabled receiver-side per spec §11.2. After phase-3, receivers reject JWTs whose aud doesn’t match — pass the audience through your call helpers explicitly.

5.3 Chassis middleware uses lib-vault transparently

Section titled “5.3 Chassis middleware uses lib-vault transparently”

For services that DON’T write any vault code themselves but still go through chassis: lib-chassis-go/httputil/middleware.go calls RetrieveInternalCertificateForJWT* on every authenticated request to verify the inbound JWT signature. That call is cached (6h cert cache in lib-vault for the remote transport; agent-side cache for agent transport). Your service inherits the same migration behaviour even if you don’t directly use lib-vault.

err := vc.SaveSecret(ctx, "vault:db_password",
newPassword,
"" /* startdate */, "" /* enddate */,
false /* indefinite */, log)

Writes go to whichever transport the client is using; vault.svc enforces authorization based on the caller’s mTLS identity. The legacy from_service == "vulcan" || from_service == "curly" rule shape is being retired (spec §11.2) — new clusters use registration-entry policy.

5.5 Retrieving an internal-JWT cert (for JWT verification on the receiver side)

Section titled “5.5 Retrieving an internal-JWT cert (for JWT verification on the receiver side)”
cert, _, pubKey, kerr := vc.RetrieveInternalCertificateForJWT(
ctx, "myservice", "realm", "users", "" /* any serial */)
if kerr != nil {
return kerr
}
// Use pubKey to verify a JWT's signature.

This is the chassis-middleware path; you usually don’t hit it directly.

6. Resilience contract — what you can rely on

Section titled “6. Resilience contract — what you can rely on”

The resilience story differs by transport.

ScenarioBehaviour
vault.svc reachableDirect HTTPS calls; no SDK-level cache for secrets (the wired cache is currently disabled).
JWT issuanceCached at 58m TTL by default.
Cert retrieval (JWT verify path)Cached at 6h TTL. The hot path that chassis middleware uses.
vault.svc downCalls return errors immediately. NO retry, NO fallback in the SDK. Your caller code MUST handle this.
Mid-flight server restartCalls in flight error out. Subsequent calls reconnect via the underlying lib-client/httpclient.

What your code should look like:

// GOOD — handle the case where vault is briefly unreachable.
pw, kerr := vc.GetSecret(ctx, "vault:db_password")
if kerr != nil {
log.Errorln("vault unreachable; using cached/last-known-good", kerr)
return lastKnownGoodPassword, nil
}
return pw, nil
// BAD — assuming vault is always available.
pw, kerr := vc.GetSecret(ctx, "vault:db_password")
if kerr != nil {
panic("can't boot without DB password") // ← takes down the service on transient vault blip
}

The agent is local — failures are about the AGENT, not the network to vault.svc. Behaviour by scenario:

ScenarioBehaviour
Agent up + connected to serverCalls are localhost-fast; agent serves from cache.
Agent up but disconnected from serverCached credentials continue to be served until their natural expiry. The agent emits server.disconnect events.
Agent downAll calls fail. The chassis health probe (/ready) of your service should still pass (the agent is a dependency, not a hard prerequisite for serving cached responses you already have).
Process restart, agent still upNew calls work immediately; cached state survives in the agent.

The agent’s cache is per-workload, attested by binary path + cgroup — restarting your workload re-attests but typically gets the same identity back, so the cache hit ratio stays high.

6.3 No retry, no fallback — at any layer

Section titled “6.3 No retry, no fallback — at any layer”

Neither transport retries on its own. Neither falls back to the other. If you need retries for a specific call, wrap it in your own backoff (e.g. lib-zero/retry). Don’t expect the SDK to do it.

For pure-Go unit tests, mock vault.VaultClient directly:

type fakeVault struct {
secrets map[string]string
jwts map[string]string
failNext bool
}
func (f *fakeVault) GetSecret(ctx context.Context, name string) (string, *kiserr.KisErr) {
if f.failNext {
return "", kiserr.NewError(ctx, "test-failure", nil)
}
if v, ok := f.secrets[name]; ok {
return v, nil
}
return "", kiserr.NewError(ctx, "secret-not-found", nil)
}
// ... stub the other methods you don't exercise ...
func TestMyHandler(t *testing.T) {
fake := &fakeVault{secrets: map[string]string{"vault:db_password": "hunter2"}}
container.GetInstance().Register("myservice-vaultclient", fake, nil)
defer container.GetInstance().Unregister("myservice-vaultclient")
// ... your test ...
}

For integration tests that exercise the real wire protocol, see the guide on building a new block.

CredentialGoes inLookup
Per-tenant DB passwordvault.svc (tenant-scoped)vault:db_password
Cluster-shared Kafka SASL passwordvault.svc (cluster-scoped)vaultcluster:kafka_password
Third-party API key (SendGrid, Twilio, etc.)vault.svc (tenant or cluster)vault:sendgrid_api_key
Service identity (mTLS cert)vault.svc (issued at registration time)RetrieveServiceCertificate
JWT signing key for THIS servicevault.svc (per realm)RetrieveInternalCertificateForJWT
Static feature flagconfig.svc (NOT vault.svc)config key
Build-time embedded valuethe binaryconstant

The anti-pattern: storing third-party API keys plaintext in service config. Phase-3 of the migration plan retires every such case (the notification and meta services). New services should refuse to start if they detect a plaintext key in config — see lib-vault.ProcessKey which logs a loud warning when it returns PLAIN.

The cache hit ratio matters more than anything else:

  • AuthenticateAndGetJWT: 58m cache TTL → at most one network call per hour for most callers.
  • RetrieveInternalCertificateForJWT* (the chassis JWT-verify hot path): 6h cache TTL → at most a handful of calls per service per day.
  • GetSecret: today’s lib-vault has the cache wired but disabled. Treat secret reads as network calls until phase-3 enables the cache.

If you read the same secret hundreds of times per request, cache it in your handler.

The legacy pattern was a generic AuthenticateAndGetJWT(serviceName) where the serviceName was used as a cache key but the JWT itself had a single hardcoded audience. Phase-3 introduces audience scoping: pass the receiver’s audience explicitly when minting, and validate audience on the receiver side. Both shifts are config-driven; no SDK signature changes.

Your service’s name, version, instance_id come from service: in the bootstrap (chassis-owned, fixed at boot). Don’t fetch identity from vault.svc on the hot path — your mTLS cert IS your identity; the chassis manages it.

9.1 Plaintext API key in service config → Vault secret

Section titled “9.1 Plaintext API key in service config → Vault secret”

Before:

sendgrid:
api_key: SG.xxxxxx # ← plaintext, in config

After:

sendgrid:
api_key: vault:sendgrid_api_key # ← Vault reference

Then write the value once via the CLI:

Terminal window
kis vault put vault:sendgrid_api_key "$NEW_KEY" --indefinite

Code paths that read cfg.Get("sendgrid.api_key").String() keep working — lib-vault.ProcessKey resolves the vault: prefix transparently inside chassis config interpolation.

9.2 RemoteVaultClient → LocalAgentVaultClient

Section titled “9.2 RemoteVaultClient → LocalAgentVaultClient”

Cluster config flip:

vault:
client:
transport: agent # was: remote

No code change in the consumer. Soak for 24h with metrics + audit monitoring; roll back by flipping the field if anything looks wrong.

9.3 JS-rule based authz → registration entries

Section titled “9.3 JS-rule based authz → registration entries”

This is server-side and doesn’t affect consumers. Eventually the from_service == "vulcan" style rules retire in favour of registration entries, but the consumer’s call sites are unchanged.

  • The Vault documentation index — default ports, concepts.
  • The consolidated Vault spec.
  • The vault-agent design.
  • lib-vault/root.go — the VaultClient interface, constants for transport selection.
  • lib-vault/remote.go — the legacy HTTPS implementation.
  • lib-vault/local_agent.go — the agent-socket implementation.
  • lib-vault/utils.goProcessKey and the prefix model.