Skip to main content

YAML reference

mindD takes a single YAML file via --config. The flag is required; the binary has no default path. (The container image supplies /etc/mindd/config.yaml through its CMD.) The example shipped at configs/example.yaml is annotated; this page is the per-field reference.

Environment variables override fields via the MINDD_ prefix with double-underscore as section separator. The name is lowercased and __ becomes ., then merged over the YAML:

MINDD_SERVER__GRPC__TCP="0.0.0.0:9000"

Top-level shape

server: {...}
observability: {...}
auth: {...}
policy: {...}
tenant_isolation: false # optional; see below
encryption: {...} # optional; see below
backends: [...]
namespaces: [...]

There is no top-level logging: or telemetry:; logging lives under observability.logging.

tenant_isolation

tenant_isolation: true # default false

Scopes every block's storage to the caller's capability tenant, so two tenants sharing a namespace name get physically separate data. Off by default (single-tenant behavior; existing data unaffected). Covers all six blocks (kv, episodic, lease, graph, artifact, semantic). Restart required to change.

Enabling it changes where data is addressed, so set it before the first write. See Tenant isolation and Security.

encryption

encryption:
keys: # ordered; the first key is active
- id: primary-2026-08 # stable label, hashed into the envelope
secret_env: MINDD_ENC_PRIMARY
- id: retired-2026-02
secret_env: MINDD_ENC_RETIRED
allow_plaintext_reads: false # default false; migration only
KeyMeaning
keys[].idStable key label. Hashed into each envelope so ciphertext names its own key. Changing an id orphans everything sealed under it. Required, and must be unique.
keys[].secret_envName of an env var holding 32 bytes as hex (64 chars) or base64. Required; secrets must not appear in YAML.
allow_plaintext_readsReturn values that aren't well-formed envelopes as-is, to migrate a namespace that already holds plaintext. See the warning in Encryption at rest.

Declaring keys does nothing on its own; a namespace opts in with encrypt: true. Supported on kv and episodic only; setting it on any other block is a startup error, as is encrypt: true with no keys configured, or allow_plaintext_reads: true with no keys. Restart required to change (keys are not hot-reloadable).

server

server:
grpc:
tcp: "127.0.0.1:7777" # default when unset or empty
uds: "/tmp/mindd.sock" # optional; no default
tls: # optional; UDS stays plaintext either way
cert_file: /etc/.../server.crt
key_file: /etc/.../server.key
client_ca_file: /etc/.../client-ca.crt # set -> mTLS
require_client_cert: true
http:
addr: "127.0.0.1:8080" # grpc-gateway; unset/empty = disabled
shutdown_timeout: 10s # default 10s
KeyDefault
server.grpc.tcp127.0.0.1:7777
server.grpc.udsempty (no UDS listener)
server.grpc.tlsabsent (plaintext)
server.http.addrempty (gateway disabled)
server.shutdown_timeout10s
The TCP listener cannot be turned off

An empty or absent server.grpc.tcp is rewritten to 127.0.0.1:7777 before validation, so a UDS-only deployment is not expressible in this release. Bind it to loopback if you only want same-host traffic.

TLS engages only when both cert_file and key_file are set. A tls: block containing only client_ca_file is silently ignored and the listener stays plaintext. MinVersion is forced to TLS 1.3 and ALPN is h2. With client_ca_file plus require_client_cert: true the server uses RequireAndVerifyClientCert; with require_client_cert: false it uses VerifyClientCertIfGiven.

Setting server.http.addr with no gRPC listener is a startup error. The gateway dials the local gRPC listener with insecure credentials, so enabling server.grpc.tls breaks the gateway's own dial. The gateway mirrors kv, episodic, semantic, artifact and lease only; graph and admin have no HTTP route. See HTTP / JSON gateway.

observability

observability:
tracing:
exporter: stdout # stdout | otlp | none; default stdout
sample_ratio: 1.0 # default 1.0; <= 0 is treated as 1.0
otlp: # only when exporter=otlp
endpoint: localhost:4317 # required in otlp mode
insecure: true # plaintext for localhost
compression: gzip # only "gzip" has an effect
headers:
x-some-team: literal
headers_env:
x-api-key: MY_API_KEY_ENV # value comes from env at start; must be non-empty
metrics:
exporter: prometheus # prometheus | otlp | none; default none
prometheus: # only when exporter=prometheus
addr: ":9090" # default ":9090"
path: /metrics # default "/metrics"
otlp: # only when exporter=otlp (push; no /metrics endpoint)
endpoint: localhost:4317 # same shape as tracing.otlp
insecure: true
compression: gzip
headers_env:
x-api-key: MY_API_KEY_ENV
logging:
level: info # debug | info | warn | error; default info (hot-reloadable)
format: json # json | text; default json

Logs go to stderr. An unset metrics.exporter means none: no meter provider and no /metrics server. In otlp metrics mode there is no /metrics endpoint and prometheus.addr / prometheus.path are ignored.

auth

auth:
verifier: paseto # paseto | jwt; default paseto
paseto:
public_key_hex: "..." # singular; back-compat
public_key_hexes: # rotation list; all keys are trusted
- "<new>"
- "<old>"
jwt:
alg: HS256 # HS256 | RS256
secret_env: MINDD_JWT_SECRET # HS256 only
public_pem: /etc/.../jwt.pem # RS256 singular
public_pems: # RS256 rotation
- /etc/.../jwt-new.pem
- /etc/.../jwt-old.pem

Only one verifier is active at a time. verifier: paseto requires at least one PASETO public key; verifier: jwt requires alg, and an unrecognised alg fails later, when the verifier is built. public_key_hexes is consulted first, then public_key_hex if not already present; public_pem/public_pems are each either an inline PEM (a string starting -----BEGIN) or a file path.

mindD only verifies, so it never needs a private key. auth.paseto has a private_key_hex field in the struct that nothing reads; mindctl token issue takes the signing key from --secret-key-hex or $MINDD_PASETO_SECRET_HEX instead. Do not put a private key in this file.

Do not use the example key

configs/example.yaml, configs/compose.yaml and the quickstart all set public_key_hex to a development key whose private half is published in this repository. Run mindctl token gen-keypair. See Security.

policy

See Policy for semantics.

policy:
default: allow # allow | deny
rules:
- name: block-secrets # required, unique
effect: deny # allow | deny | rate_limit | cap
reason: "secret-* namespaces are off-limits"
match:
tenant: ["acme"] # any field optional; empty = match anything
agent: ["agent-1"]
block: ["kv"]
namespace: ["secret-*"] # glob, single trailing *
op: ["put", "delete"] # dotted or verb-only
bucket: # only used when effect=rate_limit
per_tenant: true
per_agent: false
per_namespace: false
per_op: true
rate_per_second: 5.0 # required and > 0 for rate_limit
burst: 10 # <= 0 becomes 1
- name: cap-search-topk
effect: cap # bound the magnitude of a single request
match: { op: ["semantic.search"] }
max: # only used when effect=cap; at least one bound required
top_k: 200 # semantic Search result count
limit: 0 # scan/range page size (0 = no bound)
depth: 0 # graph traversal depth
fan_out: 0 # graph traversal fan-out
rerank_candidate_k: 0 # semantic hybrid per-lane candidate depth

Note the singular key names under match: even though each takes a list.

Omitting the whole policy block (no default, no rules) installs the no-op engine, which allows everything without evaluating anything. Setting default: with an empty rules list installs the real rule engine with no rules, which behaves the same but goes through the evaluation path.

deny (and default: deny) surface as PermissionDenied; rate_limit and cap rejections surface as ResourceExhausted so clients can back off.

Policy is validated when the engine is built, not by the config loader, so a typo in effect or a missing bucket.rate_per_second fails at startup with a policy: ... error.

The whole policy block is reloaded on SIGHUP. See Hot reload.

Namespace rules and streaming RPCs on v0.1.0

On v0.1.0, match.namespace and cap rules did not apply to KV/Scan, Episodic/Range, Episodic/Tail, Artifact/Put, Artifact/Get or Artifact/List. Fixed after v0.1.0. See Security.

backends

backends:
- name: mem-default
driver: memory # reads no options
- name: pg-main
driver: postgres
options:
dsn: "postgres://..." # OR
dsn_env: MINDD_PG_DSN
max_conns: 10 # default 10
sweeper_interval: 5m # kv only: kv_items expiry sweep (default 5m)
tail_interval: 250ms # episodic only: Tail poll cadence (default 250ms)
poll_interval: 100ms # lease only: wait_for poll cadence (default 100ms)
- name: blob-local
driver: fs
options:
base_dir: /var/lib/mindd/blobs # required
- name: blob-s3
driver: s3
options:
endpoint: s3.amazonaws.com # required
bucket: my-bucket # required
use_ssl: true # default false
region: eu-west-1
prefix: "mindd/"
access_key: "" # literal wins over the _env form
access_key_env: AWS_ACCESS_KEY_ID
secret_key: ""
secret_key_env: AWS_SECRET_ACCESS_KEY

name is required and must be unique. driver must be one of memory, postgres, fs, s3.

options is an untyped map, so unknown keys are silently ignored, and a duration written as a bare number (sweeper_interval: 300) silently falls back to the default rather than erroring.

Which drivers serve which blocks

Blockmemorypostgresfss3
kvyesyesnono
episodicyesyesnono
semanticyesyes (pgvector)nono
artifactyesnoyesyes
leaseyesyesnono
graphyesyesnono

A mismatch is a startup error, not a runtime one. The graph Postgres driver stores nodes and edges in shared graph_* tables and runs the bounded walk in Go inside a read transaction.

Postgres migrations always run at startup; there is no config key to skip them.

namespaces

namespaces:
- { block: kv, name: scratchpad, backend: mem-default }
- { block: episodic, name: events, backend: pg-main }
- { block: artifact, name: blobs, backend: blob-local }
- { block: lease, name: locks, backend: pg-main }
- { block: graph, name: knowledge, backend: mem-default }
- block: semantic
name: notes
backend: pg-main
text_search: english # optional; Postgres FTS config for hybrid's sparse lane (default: simple)
embedder:
provider: openai # fake | ollama | openai
model: text-embedding-3-small
dimensions: 1536 # required, > 0
cache_size: 4096 # optional; embed-once cache (see below)
options:
api_key_env: OPENAI_API_KEY
timeout: 30s

block must be one of kv, episodic, semantic, artifact, lease, graph. name is required, backend must reference a declared backend, and block/name pairs must be unique (kv/notes and semantic/notes can coexist).

Semantic namespaces

embedder is required. provider is fake, ollama or openai; dimensions must be positive; model is required for ollama and openai but not for fake. Provider options:

ProviderOptions
fakenone
ollamabase_url (default http://localhost:11434), timeout (default 30s)
openaiapi_key_env (required, and the named env var must be non-empty), base_url (default https://api.openai.com), timeout (default 30s)

text_search is read only for semantic namespaces on the Postgres driver; it is ignored elsewhere. It defaults to simple and must match ^[a-z][a-z0-9_]{0,62}$.

embedder.cache_size bounds a per-namespace embedding cache: identical content (same (namespace, model, content)) is embedded once and served from a bounded LRU thereafter. Omit it or set 0 for the default (4096 entries); set a negative value to disable caching for the namespace. Hit and miss rates are exported as mindd.embedder.cache.{hits,misses}; see Observability.

Cache-tier access policy (in-memory kv only)

namespaces:
- block: kv
name: tool-cache
backend: mem-default
access:
track: true # record last_accessed/access_count on Get
slide_ttl_seconds: 300 # integer seconds; each Get extends a TTL'd key
capacity: 10000 # cap live keys; over cap, evict the coldest
heat_half_life_seconds: 3600 # integer seconds; default 3600

All four values are integer seconds or counts, not duration strings. The block is honoured only by the in-memory kv driver, and only for block: kv; it is ignored on a Postgres-backed namespace and on every other block. A block where all four fields are zero is dropped entirely. See KV cache-tier access policy.

Encryption

kv and episodic namespaces may set encrypt: true to seal stored values with the encryption keyring. Namespaces sharing a backend can differ, so one can be encrypted while another stays plaintext. See Encryption at rest.

What hot-reloads

SectionHot-reloadable?
auth.verifier and keysyes
policy.*yes
observability.logging.levelyes
server.*, observability.tracing/metrics, backends, namespaces, encryption, tenant_isolationrestart required

See Hot reload for the contract.