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.
0. Why not direct HTTP?
Section titled “0. Why not direct HTTP?”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
TenantMiddlewarestraight 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.
1. Mental model
Section titled “1. Mental model”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:
| Capability | Method |
|---|---|
| Secrets | GetSecret, SaveSecret, CheckIfSecretExists, DeleteSecret |
| Service auth (JWT) | AuthenticateAndGetJWT(svc) |
| Cert retrieval | RetrieveServiceCertificate, RetrieveInternalCertificateForJWT*, RetrieveIssuerCertificate, RetrieveExternalCertificate |
| Cert generation | GenerateServiceCertificate, GenerateInternalCertificateForJWT |
| Cert save (external) | SaveExternalCertificate |
Two implementations satisfy the interface:
| Implementation | Transport | Selected by |
|---|---|---|
RemoteVaultClient | HTTPS to vault.svc | vault.client.transport: remote (default) |
LocalAgentVaultClient | Unix socket /run/vault/vault.sock to vault-agent | vault.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.
2. The client’s life cycle
Section titled “2. The client’s life cycle”When your service boots through svcboot.NewServerCommand, the
chassis does this for you:
- Loads bootstrap YAML.
- Calls
lib-vault.InitializeVaultClient(ctx, dc, cluster, vaultURL, vaultCfg). - Based on
vault.client.transport, constructs eitherRemoteVaultClientorLocalAgentVaultClient. - 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: 6hDuring the migration:
- Default to
remotefor clusters where vault-agent isn’t deployed everywhere yet. - Flip a single consumer to
agentto 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.
4. The prefix-encoded scope model
Section titled “4. The prefix-encoded scope model”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:
| Prefix | Scope | Source of scope values |
|---|---|---|
vault:NAME | dc + cluster + customer + product + env + tenant (CPET) | request context (set by chassis TenantMiddleware) |
vaultcluster:NAME | dc + cluster only | client init |
vaultkey:NAME | global; no scope | — |
plain:VALUE or no prefix | NONE — 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 = plainWhy this matters: your service config can reference a secret without knowing the scope, e.g.:
db: password: vault:db_passwordAt 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.
5. Code samples
Section titled “5. Code samples”5.1 Reading a tenant-scoped secret
Section titled “5.1 Reading a tenant-scoped secret”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.
5.4 Writing a secret
Section titled “5.4 Writing a secret”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.
6.1 RemoteVaultClient
Section titled “6.1 RemoteVaultClient”| Scenario | Behaviour |
|---|---|
vault.svc reachable | Direct HTTPS calls; no SDK-level cache for secrets (the wired cache is currently disabled). |
| JWT issuance | Cached at 58m TTL by default. |
| Cert retrieval (JWT verify path) | Cached at 6h TTL. The hot path that chassis middleware uses. |
vault.svc down | Calls return errors immediately. NO retry, NO fallback in the SDK. Your caller code MUST handle this. |
| Mid-flight server restart | Calls 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}6.2 LocalAgentVaultClient
Section titled “6.2 LocalAgentVaultClient”The agent is local — failures are about the AGENT, not the network to
vault.svc. Behaviour by scenario:
| Scenario | Behaviour |
|---|---|
| Agent up + connected to server | Calls are localhost-fast; agent serves from cache. |
| Agent up but disconnected from server | Cached credentials continue to be served until their natural expiry. The agent emits server.disconnect events. |
| Agent down | All 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 up | New 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.
7. Local mode for tests
Section titled “7. Local mode for tests”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.
8. Patterns
Section titled “8. Patterns”8.1 Where to put a credential
Section titled “8.1 Where to put a credential”| Credential | Goes in | Lookup |
|---|---|---|
| Per-tenant DB password | vault.svc (tenant-scoped) | vault:db_password |
| Cluster-shared Kafka SASL password | vault.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 service | vault.svc (per realm) | RetrieveInternalCertificateForJWT |
| Static feature flag | config.svc (NOT vault.svc) | config key |
| Build-time embedded value | the binary | constant |
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.
8.2 Hot-path performance
Section titled “8.2 Hot-path performance”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.
8.3 Audience scoping for outbound JWTs
Section titled “8.3 Audience scoping for outbound JWTs”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.
8.4 Don’t conflate identity with config
Section titled “8.4 Don’t conflate identity with config”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. Migration from legacy patterns
Section titled “9. Migration from legacy patterns”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 configAfter:
sendgrid: api_key: vault:sendgrid_api_key # ← Vault referenceThen write the value once via the CLI:
kis vault put vault:sendgrid_api_key "$NEW_KEY" --indefiniteCode 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: remoteNo 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.
10. Further reading
Section titled “10. Further reading”- The Vault documentation index — default ports, concepts.
- The consolidated Vault spec.
- The vault-agent design.
lib-vault/root.go— theVaultClientinterface, constants for transport selection.lib-vault/remote.go— the legacy HTTPS implementation.lib-vault/local_agent.go— the agent-socket implementation.lib-vault/utils.go—ProcessKeyand the prefix model.