Serving model
July 18, 2026 · View on GitHub
This document describes the normative serving model for data-olympus. It
summarizes SPEC.md section 8; for the authoritative text, see that section.
Single replica, streamable HTTP
A write-enabled data-olympus server runs as a single replica and exposes MCP over streamable HTTP.
The write path is single-writer by design:
- Per-path advisory locks prevent two concurrent write operations from racing on the same concept file.
- Per-session git worktrees isolate in-flight proposed edits from the live index until they are committed.
- A durable push queue serializes outbound git pushes so no write is lost if the remote is briefly unavailable.
A single shared HTTP surface, rather than N independent stdio processes, gives every agent one synchronized conversation with the server. When multiple agents each run their own stdio MCP process against the same git working tree, they race each other's worktrees and lock state. Streamable HTTP eliminates that race.
The long-lived SSE stream (proxy and session tuning)
Each MCP client holds a long-lived GET /mcp Server-Sent-Events stream to
receive server-to-client messages. Two things must be configured so that stream
does not churn:
- Ingress/proxy read timeout. A proxy that closes the idle stream (nginx's
default is 60s) forces the client to reconnect on that cadence. Each reconnect
creates a new transport server-side and, in a persistent client like
opencode serve, leaks event listeners. Set a long read timeout and disable response buffering on the/mcppath (seedeploy/k8s/ingress.yamlfor the nginx annotations). - Session reaping. FastMCP does not reap the transport a client leaves behind
when it disconnects without sending
DELETE. The server runs its own reaper (session_metrics): the activity middleware keeps a session non-idle for as long as its SSE stream is open, andKB_SESSION_IDLE_TIMEOUT_SEC(default300) terminates a session only after its stream has actually closed.KB_SESSION_TOUCH_INTERVAL_SEC(default30, clamped to a third of the idle window) sets how often an in-flight stream is re-stamped.kb_health//api/v1/healthand a periodic log surface the live session count.
Core configuration reference
A few server-wide settings, beyond the feature-specific env vars documented in their own sections below (search ranking, synonyms, embeddings, co-occurrence, trigram, auth, audit rotation):
KB_HTTP_PORT: TCP port the MCP HTTP server binds (default8080).KB_CONFIDENCE_THRESHOLD: the auto-commit confidence cutoff, in[0, 1](default0.85). A proposal at or above it from a principal holdingauto_commitis committed; below it (or from a principal without that capability) it is parked as pending. An out-of-range value fails startup loudly.KB_PENDING_TIMEOUT_SEC: age after which an unresolved pending proposal is auto-expired by the pending GC loop (default86400, i.e. 24h). Each expiry emits an audit event.KB_SECRET_SCAN_EXTRA_PATTERNS: comma-separated additional regexes the secret-scanning gate (issue #71) checks alongside its built-in pattern set (see "Write serialization and integrity gates" above). Each entry is scanned as its own named pattern (custom_1,custom_2, ...); an invalid regex, or one with the classic nested-quantifier ReDoS shape, is logged and skipped rather than raised, and every accepted pattern runs with a hard 1-second match timeout. Empty by default (no extra patterns).KB_DISABLE_VERSION_CHECK: truthy (1,true,yes,on) disables the public PyPI/GitHub version check completely. Use this for air-gapped deployments. Default is off.KB_VERSION_CHECK_INTERVAL_SEC: how often the background task refreshes the cached latest-version result (default86400, i.e. 24h). The health request path never performs the outbound lookup.KB_GOVERNED_LANE_PROTECTION: governed-lane write protection (issue #112), defaulton; setoffto restore the exact pre-#112 behavior (see "Governed-lane write protection" below).KB_TOOL_DISCOVERY_MODE: MCP catalog exposure mode.searchis the default;allrestores the complete native catalog intools/list. Any other value fails startup.
MCP tool discovery
The default KB_TOOL_DISCOVERY_MODE=search keeps the initial MCP catalog small
without removing any capability. tools/list exposes these native tools:
kb_consultkb_searchkb_getkb_healthkb_gate_checkkb_record_eventkb_session_recap
It also exposes the synthetic tool_search and call_tool tools. Use
tool_search to find a native tool omitted from the initial list, then invoke
it with call_tool. A client that already knows the native name may still call
it directly. Search mode filters discovery only; it does not unregister tools.
Visibility is not an authorization boundary. A hidden write tool retains its
annotations and passes through the same principal capability middleware whether
it is called directly or through call_tool. For clients that require the
complete catalog during initialization, set KB_TOOL_DISCOVERY_MODE=all and
restart the server.
Read only replicas expose the subset of the default catalog that is actually
registered in read only mode, plus tool_search and call_tool. Discovery
cannot make a write tool available when the replica did not register it.
Read-only mirrors may scale horizontally
Set KB_READ_ONLY=true to run an instance as a read-only replica. In this mode
the server registers only the read tools (kb_search, kb_get, kb_list,
kb_outline, kb_health) and the read REST routes; the write and
enforcement-write tools and routes (propose, resolve, bootstrap,
consult, gate, record-event, and their observability mirrors) are not
registered at all and return 404. The write pipeline (worktrees, push queue,
pending) is never initialised, so no replica is a git writer.
Crucially, the git_pull_loop still runs, so each replica keeps KB_REMOTE_URL
set and refreshes its own index snapshot from the same git remote as the single
writer. Run as many replicas as you need for read throughput; the single
write-enabled instance remains the only owner of the git remote.
KB_READ_ONLY is a truthy flag: 1, true, yes, or on (case-insensitive)
enable it; unset or anything else keeps the default read-write behaviour.
See deploy/k8s/read-replica/ for a ready-to-apply Deployment (not the
StatefulSet writer) that runs N read replicas with per-pod ephemeral clone +
index scratch. Apply the base stack first, then the overlay:
kubectl apply -k deploy/k8s # namespace + secret + writer StatefulSet
kubectl apply -k deploy/k8s/read-replica # N read replicas + read Service
kubectl -n data-olympus scale deployment/data-olympus-mcp-read --replicas=5
Git pull loop
On startup, and at the interval set by KB_SYNC_INTERVAL_SEC (default 60s),
the server calls git pull on KB_MAIN_PATH. If KB_REMOTE_URL is empty,
the pull loop runs but exits cleanly with no action. The health endpoint
reports degraded: true only when the index has not been rebuilt within
KB_STALENESS_DEGRADED_SEC (default 600s).
For a local read-only demo with no remote, set KB_REMOTE_URL="". The server
stays healthy as long as the index builds successfully on startup.
Health, readiness, and liveness (three distinct signals)
The server exposes three endpoints that answer three different questions. They are deliberately split so a data-freshness problem does not eject a pod that can still serve reads:
| Endpoint | Question | 200 when | 503 when |
|---|---|---|---|
GET /api/v1/health | Is the data fresh? | index built, fresh, last build ok | degraded: staleness > KB_STALENESS_DEGRADED_SEC, no successful pull, empty index, or last build failed |
GET /readyz | Can this pod serve reads now? | process up and index loaded and last build ok | no index built yet, or the last index build failed |
GET /livez | Is the process responsive? | always (if the handler runs, the loop is alive) | never |
Key point: /readyz is independent of data staleness. A git-remote outage
makes the KB stale (so /api/v1/health returns 503 with degraded: true), but
the last-good index still answers reads perfectly, so /readyz stays 200 and the
pod stays in the Service. The Kubernetes readiness probe targets /readyz
(see deploy/k8s/statefulset.yaml); if it targeted /api/v1/health a stale
single-replica pod would be ejected from the Service and a "reads are a bit old"
condition would become a hard 503 with zero endpoints.
/api/v1/health keeps its 503-on-degraded contract because the bin/kb CLI
--no-stale flag relies on it (exit 2 when the endpoint reports degraded: true,
whether over HTTP 200 or 503). Alert on /api/v1/health degraded (and on the
last_git_fetch_status / staleness_seconds fields it carries) rather than
wiring it to a probe. The liveness probe is a tcpSocket check by default; the
HTTP /livez route is offered for operators who prefer an explicit route. See
docs/operations.md for the full alerting and recovery model.
The health payload also carries malformed_frontmatter: the count of docs whose
front-matter was present but malformed at the last index build. A non-zero value
means a doc silently lost its governance metadata (type/status/tier), so it
will not be governed or filtered correctly. It is a warning signal and does
not flip degraded (that would 503 every read for an authoring mistake);
alert on malformed_frontmatter > 0 separately.
When the background version check sees a newer published package than the
running server, health also carries latest_version and sets
update_available: true. latest_version is omitted from compact health output
until a check has found a published version; verbose health shows it as null
before then. This signal is informational only and does not affect degraded,
readyz, or write behavior.
Write serialization and integrity gates
Every write (kb_propose_memory, kb_propose_edit, kb_resolve_pending, and
onboarding bootstrap — which commits its whole file bundle through the same
serialized/validated multi-file path) goes through one serialized critical
section so concurrent writes cannot corrupt each other:
- A process-wide write serializer wraps the write →
git add→ commit → enqueue sequence, so one thread's commit can never sweep another thread's staged file. Write volume is rate-limited, so a single global mutex is cheap. - A per-path advisory lock, shared between the auto-commit path and the
pending queue, prevents two writes to the same file from racing and prevents an
auto-commit from landing on a path with a pending proposal in flight (whose
later approval would clobber it). A write blocked by the lock returns
rejected_path_lock_busy. An orphaned lock (a crash between lock acquisition and the pending entry write) is reclaimed by the pending GC loop. - A content-validation gate runs on the postimage before commit. It rejects
rejected_invalid_documentwhen the frontmatter is malformed YAML, atype/status/tierenum value is out of vocabulary, or theidis a forged/duplicate of one already used by a different path (which would break every subsequent index rebuild — one bad write, persistent degraded state). The responsereasoncarries the machine-readable errors. - Compare-and-swap (optimistic concurrency). When a caller supplies a base
marker (
base_commitnaming a specific commit,base_blob_sha, ortarget_file_hash) onkb_propose_edit(or it is carried on the pending entry intokb_resolve_pending), the server refreshes the session worktree's base ontoorigin/mainand compares the marker against the current target content. A mismatch returnsrejected_stale_baseand nothing is committed. If the base cannot be refreshed at all (the remote is unreachable, or the rebase conflicts) while an enforceable marker was supplied, the write is also rejectedrejected_stale_baserather than committed against a possibly-stale base — the marker cannot be verified, and the push-path rebase recovery is not an equivalent safety net (a compatible rebase would still publish the stale write). A barebase_commitofHEADis advisory (no per-file expectation), and when no marker is supplied the pre-0.3.0 behavior is preserved (a refresh failure is non-fatal; the push path's non-FF recovery publishes the commit). - Ordering of side effects: the commit message and all gates are evaluated
BEFORE the file is written and
git add-ed, and on any failure after the add the worktree is hard-reset, so a rejected write never leaves a staged leftover for the session's next commit to sweep in. - A secret-scanning gate (issue #71) runs on the postimage BEFORE the
content-validation gate, on every commit path (auto-commit propose, resolve
approve, including a resolved
edited_text, and onboarding bootstrap). It runs first so a postimage that is both malformed AND carries a credential-shaped value is always rejected via the redactedrejected_secret_detectedpath, never viarejected_invalid_document(which echoes the offending value verbatim in its message). The gate checks a built-in pattern set (PEM private-key blocks; GitHubghp_/gho_/ghs_/ghr_/github_pat_tokens; AWSAKIA...access key ids; Slackxox[bpars]-tokens; genericpassword=/passwd=/secret=assignments, including env-style prefixed keys likeDB_PASSWORD=, with a non-placeholder value; andscheme://user:pass@hostconnection strings) plus any operator-suppliedKB_SECRET_SCAN_EXTRA_PATTERNS. A match on an auto-commit or bootstrap path rejects the writerejected_secret_detectedbefore anything is written to disk. Only the pattern name and an approximate line number are ever surfaced in the response, the audit event, or a log line, never the matched value. A low-confidence proposal containing a secret still enters pending (removing it would defeat the operator-override workflow below), but the scan runs at propose time too: the response never echoes the raw text when flagged (only the pattern name), the pending entry is taggedsecret_scan_flagged/matching_pattern(names only) sokb pendingsurfaces the warning, and a flagged memory proposal's filename falls back to a neutral slug instead of embedding the flagged text. The gate also covers the fields AROUND the postimage: a credential-shapedtarget_path(filename) is rejected on edit, bootstrap, and resolve without ever echoing the path back (it would otherwise land in responses, audit events, commit subjects, and the git tree); a flagged editreasonis replaced with a redacted note before it reaches pending meta, push metadata, or audit events; and a flagged memory tag is stored redacted in pending meta.kb_resolve_pendingaccepts an operator-onlyoverride_secret_scanboolean (kb resolve --override-secret-scanon the CLI) to consciously commit a false positive anyway (recorded in the audit event); no auto-commit or bootstrap path exposes this override, so an agent can never self-authorize past a flagged write. An extra pattern with the classic nested-quantifier ReDoS shape is rejected at load time alongside an invalid regex, and every accepted custom pattern executes through theregexengine with a hard 1-second match timeout, so a catastrophic pattern the load-time check misses is bounded at scan time (logged and skipped) instead of hanging the single-writer write path.
Governed-lane write protection (KB_GOVERNED_LANE_PROTECTION, issue #112)
KB_GOVERNED_LANE_PROTECTION (default on; case-insensitive off/0/
false/no disables it and restores the exact pre-#112 behavior) gates a
demotion-only feature on top of the write path above: "agents can propose,
only humans can promote." No write is ever REJECTED by this feature; an
affected write is DEMOTED to the pending queue instead of auto-committing.
See SECURITY.md's "Governed-lane write protection" section for the full
threat-model rationale; this section covers the mechanics and the demotion
semantics an operator needs at serving time.
- Status clamp. Checked on the auto-commit path of
kb_propose_memory/kb_propose_edit, and on everykb_bootstrap_projectfile. If the postimage'sstatusfield is being set/changed to a value in the in-force class (active/accepted/approved--format.validate.IN_FORCE_STATUSES, single-sourced, never a local copy), the write is demoted withdemotion_reason: "status_promotion"instead of committed. Unconditional on the PRIOR status: a brand-new file, a promotion from a non-in-force status, and an unreviewed re-claim of an already in-force status all clamp the same way. - Governed-target edit demotion. Checked only on
kb_propose_edit. If the edit'starget_pathcurrently resolves to an IN-FORCE document in the live index (the full composed predicate: status class AND validity window AND not-inbox AND not-graph-excluded -- the same predicateIndex.search(in_force=True)andkb_get's computedin_forcefield use), the write is demoted withdemotion_reason: "governed_target", REGARDLESS of confidence. A target that is expired or has been superseded out of force is not currently governing anything and so is not protected by this rule. The lookup FAILS CLOSED: when the target's in-force state cannot be verified (no index wired, or an index read fails), the edit is demoted with the distinctdemotion_reason: "governed_target_unverified"rather than auto-committed on the strength of a broken lookup -- an unhealthy index can never be used to slip an edit past this rule. A target with no entry in a HEALTHY index is definitive at the tool layer (a brand-new file cannot already be in force), and an authoritative IN-WORKTREE BACKSTOP closes the residual index-lag window: inside the serialized commit section, after every hard gate passes and after the worktree base is refreshed ontoorigin/main, the target's CURRENT bytes on that refreshed base are re-judged (status+ validity window from its own frontmatter,is_inboxfrom the path). The backstop deliberately consults NOTHING from the index -- in particular not graph exclusion, whose stale edges could otherwise REMOVE protection the base's own bytes assert (a target still carrying an in-forcestatusunder a live supersedes edge is over-demoted for operator review rather than auto-committed; a target properly flipped tosupersededis not in force by its own bytes and stays unprotected). A doc pushed as in-force but not yet re-indexed is therefore still demoted (demotion_reason: "governed_target") -- the decision is made against the same content the commit would replace, so index staleness cannot bypass the rule in either direction. - Injection-pattern annotation. Every postimage evaluated by the two
rules above is also scanned for agent-directed injection patterns
("ignore previous instructions", "disregard ... policy", an imperative
paired with an http(s) URL, a long base64-looking run, "do not tell the
operator"). A match sets
injection_suspect: trueand populatesinjection_patterns(a list of"pattern_name:line"strings, mirroring the issue #71 secret-scan redaction discipline -- never the matched text) on the pending entry. This is ADVISORY ONLY: it never blocks a write and never causes a demotion by itself; it exists so the human reviewer sees the signal alongside the demoted content. - Ordering. The issue #71 secret-scanning gate and the content-
validation / duplicate-id gate are evaluated FIRST for any write that
would otherwise auto-commit. A postimage that would be rejected outright
by either of those gates IS rejected (
rejected_secret_detected/rejected_invalid_document), never silently demoted -- a demotion cannot be used as a side door around a hard rejection. - Unaffected paths. The maintenance-ledger system write
(
maintenance.maybe_update_ledger, which stamps its own docstatus: active) commits through the shared multi-file write primitive DIRECTLY, bypassing the tool-function layer these checks live in, so it is never gated by this feature. The operator resolve path (kb_resolve_pending/kb resolve) is likewise never gated: an operator-confirmed resolve IS the promotion this feature exists to require. - Per-write feedback. A demoted response carries the same
pending_idshape as a plain low-confidence park, plusdemotion_reasonand anoperator_promptthat explicitly instructs the calling model to inform the operator that the write awaits review rather than proceeding silently. - Per-session feedback. See
docs/operations.mdforkb_session_recap/kb session-recap/GET /api/v1/session-recap, thekb_consultpending_actionssurfacing, and thebin/kb-session-recap-hookSessionEnd/Stop hook wiring.
Push queue and write-path visibility
Committed writes are published to origin/main through a durable push queue: a
commit that returns "committed" has a queue entry on disk, and a background loop
drains the queue with retry. Health surfaces the queue state so a stuck write
path is diagnosable:
push_queue_sizeis the live count of queued (not-yet-pushed) entries.pending_countis the live count of pending (awaiting-approval) entries.push_queue_frozenis the count of entries that hit the retry cap and were frozen.
Both counts are computed at health-report time from the queues themselves, so they cannot drift.
Non-fast-forward push recovery
When a second overlapping session moves origin/main between a session's commit
and its push, the push is rejected non-fast-forward. The push loop classifies
this distinctly from a network failure: it fetches origin/main, rebases the
session branch onto it, and retries the push once. Most non-FF cases (the two
sessions touched different files) publish cleanly on the retry.
If the rebase conflicts (the two sessions edited the same lines
incompatibly), the commit cannot be auto-published. Instead of retrying forever,
the push loop demotes the commit to a pending entry for operator resolution:
the postimage is re-proposed as a pending edit (visible via kb_list_pending),
the queue entry is removed, and a push_conflict_demoted audit event is recorded.
The operator resolves the pending entry (approving re-applies it on the current
base, subject to the CAS/validation gates) to publish the change.
Pure contention (the retry push is itself non-FF because origin/main moved
again mid-rebase) is retried in-line for a bounded number of passes; if it never
wins the race it is demoted the same way rather than counted toward the freeze
cap, so persistent contention never becomes a silently-frozen queue item.
Startup recovery
A crash in the narrow window between git commit and the push-queue enqueue
would otherwise orphan a commit on a session worktree branch that nothing pushes.
On boot the server scans every session worktree and re-enqueues any commit
reachable from its HEAD but not from origin/main. Entries already in the
queue are left untouched, so recovery never double-enqueues. The recovery result
is logged (startup push recovery re-enqueued N orphaned commit(s) or no orphaned commits found).
Frozen entries and how to unfreeze
After max_attempts consecutive push failures a queue entry is frozen: the
retry loop stops retrying it (retrying a permanently failing push every interval
only spams the remote and hides the problem). The freeze is logged once at WARN
with the sha, worktree, and last error, and the entry is counted in
push_queue_frozen. A nonzero push_queue_frozen means writes are stuck and
need operator attention. Non-fast-forward pushes against a moved origin/main
are now recovered automatically (see Non-fast-forward push recovery above), so
a frozen entry now signals a persistent failure the rebase path could not
handle — typically an authentication, network, or remote-side rejection.
To unfreeze, an operator resolves the underlying push failure, then clears the
entry file directly on the push-queue volume (KB_PUSH_QUEUE_ROOT, default
/state/push-queue):
- Requeue (retry from scratch): delete the
"frozen": trueand reset"attempts"to0in the entry's JSON file (or delete and let startup recovery re-enqueue it), then the retry loop picks it up again on its next pass. - Drop (abandon the write): delete the entry's
<sha>.jsonfile. The commit stays on its session worktree branch but is never published.
There is no unfreeze API in this release; the file-level procedure above is the supported path.
Per-session worktree GC
Each writing session gets its own git worktree (and a kb-session/<safe_id>
branch) so in-flight edits are isolated. A background GC task, running every 5
minutes, removes worktrees idle beyond KB_WORKTREE_IDLE_SEC (default 3600s) so
KB checkouts do not accumulate one-per-session forever.
GC is conservative and coupled to the push queue:
- A worktree with commits not yet reachable from
origin/mainis deferred (never removed) until the push queue drains those commits; the next GC pass cleans it up once they land upstream. - When a worktree is removed, its
kb-session/<safe_id>branch is deleted in the same step. This matters because a returning session recreates its worktree withgit worktree add -b kb-session/<safe_id>, which would fail if the branch still existed. Deleting the branch keeps a GC'd session able to write again.
Streamable-http session lifecycle and reaping
Each session-less POST /mcp handshake makes the underlying MCP SDK create a
transport, register it in an in-memory session table, and start a task that
blocks waiting for further messages on that session. The SDK removes a session
only on an explicit client DELETE, on the session task crashing, or on server
shutdown. It also supports an idle timeout, but only when its session manager is
constructed with one, and FastMCP does not wire one through: under its default
construction the idle timeout is unset, so nothing reaps idle sessions.
Consequence: a client that handshakes and drops the connection without sending
DELETE (the common cause of repeated "Created new transport with session ID"
log lines) leaves its transport resident. Over long uptime the session table
grows without bound and leaks memory and tasks.
data-olympus closes this two ways:
- Observability:
healthreportslive_sessions, the current live transport count (ornullbefore the HTTP app has started serving). The server also logs the live count each reaper pass. Alive_sessionsvalue that only ever climbs is the signal of a leak. - Bound: a background reaper terminates sessions idle beyond
KB_SESSION_IDLE_TIMEOUT_SEC(default 1800s / 30 min). It scans everyKB_SESSION_REAP_INTERVAL_SEC(default 60s). SetKB_SESSION_IDLE_TIMEOUT_SEC=0to disable reaping and keep observability only. Termination uses the SDK's ownterminate()path, so a client that reconnects simply gets a fresh session.
Idle is measured from the last request seen for a session: the activity clock
advances only when a request carrying that session's mcp-session-id header
reaches the server. A client that keeps polling or making periodic calls is
therefore never reaped, because each call re-stamps its activity.
The consequence to be aware of is that "idle" is per-request, not
per-connection. A quiet long-lived GET SSE stream that stays open but makes no
periodic POST requests still stamps no activity, so after
KB_SESSION_IDLE_TIMEOUT_SEC its session is reaped and the stream is torn down;
the client must reconnect (it gets a fresh session on the next handshake). If
your client relies on a long-lived stream without periodic requests, either
raise KB_SESSION_IDLE_TIMEOUT_SEC above your longest expected quiet period,
set it to 0 to disable reaping (observability only), or have the client send a
periodic keep-alive request. Excluding sessions with an active open stream from
reaping (so a live stream is never torn down) is a possible follow-up; the
current behavior reaps purely on request-activity age.
Search ranking
kb_search orders hits by BM25 relevance and then applies a status-aware
rerank on top of that score, enabled by default. The rerank exists so a
retired document does not outrank the live one that replaced it: for a query that
matches both, the in-force doc is promoted and the retired one demoted.
The built-in status-to-weight map is:
- In-force (boosted):
active,accepted,approved. - Retired (penalized):
superseded,deprecated,rejected. - Not yet in force (penalized):
draft,proposed.
A negative weight boosts (moves a hit earlier), a positive weight penalizes
(moves it later). Status matching is case-insensitive, so Active in frontmatter
ranks like active. Any status not in the map (including an empty status) is
neutral and is never dropped from results.
Because this reorders results, a query that previously surfaced a superseded
doc first may now surface the active one first. This is the intended behavior
change.
Override the map at deploy time, with no code change:
-
KB_STATUS_WEIGHTS: a JSON object of{status: weight}that replaces the built-in map (it is not merged). Keys are statuses (matched case-insensitively), values are numeric deltas: negative boosts an in-force status, positive penalizes a retired one. A malformed value fails startup loudly rather than silently shipping default ranking. Unset (the default) uses the built-in map above.Example:
KB_STATUS_WEIGHTS='{"active": -1.0, "approved": -1.0, "superseded": 2.0, "draft": 1.0}'.
in_force: hard in-force filter
kb_search accepts an in_force: bool = False parameter (MCP tool and
GET /api/v1/search?in_force=true). When set, results are HARD-filtered
BEFORE ranking to docs that are currently in force: the in-force status class
(active, accepted, approved) AND the validity window
(validity.valid_from not in the future, validity.valid_until not in the
past; both boundary days inclusive). So a superseded, deprecated,
draft, proposed, expired, or upcoming doc is excluded entirely rather
than merely soft-downranked by the status rerank above.
Use it when you want only guidance that currently applies and a demoted-but-
present retired doc is not acceptable. It differs from the status rerank: the
rerank always keeps every hit and only reorders; in_force drops out-of-force
hits. The in-force predicate is defined once
(format.validate.IN_FORCE_STATUSES for the status class,
format.validate.is_in_force for the combined status-AND-window predicate)
and shared by the rerank boosts and this filter.
in_force composes with the single-status status filter: passing both
requires a doc match status AND be in-force (so status=superseded with
in_force=true yields nothing). The filter also applies to the dense
(embedding) candidate source, so a hybrid deployment never leaks an out-of-force
doc through the semantic path.
The predicate also applies a memory-inbox floor (issue #109): a document
under the memory-inbox prefix (KB_MEMORY_INBOX_PREFIX, default
memory/inbox/) is never in force regardless of claimed status, so a legacy
inbox file or forged frontmatter on an agent-written memory cannot satisfy
in_force=true no matter what status it declares. This is a plain is_inbox
column derived once at index build time, not a per-query prefix scan. Together
with the supersession-graph exclusion below, the full in-force predicate is:
status class AND validity window AND not-inbox AND not-graph-excluded.
kb_consult passes in_force=true on its internal retrieval unconditionally
(not a caller-facing parameter): the enforcement surface must never present an
unreviewed, retired, expired/upcoming, memory-inbox, or graph-excluded
document as a governing rule for a code/architectural decision. See
docs/enforcement.md.
Computed in_force on served responses. A verbose (verbose=true)
kb_get response and each verbose kb_search hit carry a computed
in_force: bool: the same single-sourced predicate evaluated against the
doc's actual status/validity/inbox-membership/graph-exclusion, independent of
whether the query itself passed in_force=true. This lets a caller retrieve
a doc via a default (unfiltered) kb_get/kb_search and still tell whether
it currently governs, without a second in_force=true round trip. It is a
serving-layer derivation only, never written to frontmatter (see SPEC.md's
"runtime envelope" note). Compact responses emit it deviation-only:
in_force: false appears ONLY when the doc is not in force, and an in-force
doc's compact shape is byte-for-byte unchanged. The deviation emission is
required because the compact status/freshness fields key off the RAW
frontmatter status: a memory-inbox doc with a forged status: active would
otherwise render as an ordinary current rule with no signal that the in-force
floor disqualified it.
Supersession-graph exclusion (issue #110 slice 2)
in_force=true ALSO excludes any document that is the TARGET of a
supersedes edge whose SOURCE document is itself in force (the full
status-class AND validity-window AND not-memory-inbox predicate above, not
merely status: superseded). This is the in-force-source guard: a draft,
expired, already-retired, or memory-inbox document can never retire another
document just by naming it in supersedes. It closes the "forgotten status flip" gap -- A (active)
supersedes B, but B's own status was never updated to superseded -- B is
excluded from in_force=true results even though its own status class would
otherwise qualify it. Like the upcoming half of the validity window, graph
exclusion is scoped to in_force=true only: a plain (default) search still
returns the excluded document.
A mutually-supersessive in-force cycle (already a kb lint ERROR at parse
time) is NOT special-cased at retrieval time: every member independently
satisfies the exclusion rule (each is the target of an in-force source's
supersedes edge), so in_force=true excludes ALL of them. A dangling edge
(source or target id with no corresponding document) excludes nothing; the
exclusion query joins edges to docs on both ends.
kb_consult runs its search with in_force=true (see "Enforcement
endpoints" below), so it never returns a graph-excluded, not-yet-in-force, or
retired document -- the same guarantee the unconditional expired-doc exclusion
already gave it, made explicit for the graph rule since that rule is scoped to
in_force=true rather than applying unconditionally.
A graph_excluded_docs health counter (in kb_health / /api/v1/health,
alongside malformed_frontmatter and malformed_validity) reports the count
of documents currently excluded by this rule. It is evaluated LIVE at
health-read time against the current date, from the same SQL definition the
retrieval-time filter uses (format.validate.graph_excluded_ids_sql), so
the counter can never drift from the filter it reports on -- including
across a date boundary where an in-force source's validity window opens or
closes between index rebuilds (retrieval evaluates the window per query, so
a counter frozen at build time would keep reporting a stale value). The
short health cache (health_ttl_sec, default 5s) bounds its staleness. It
is a WARNING signal only and does not flip degraded.
Retirement is explainable
kb_get by id always resolves regardless of in-force or graph-exclusion
status (same as it already ignores expiry) and returns:
superseded_by: the sorted, deduped UNION of the document's own frontmattersuperseded_byclaim and any reversesupersedesedge naming it. This ONE consistent computed shape covers both the honest self-declared case and the forgotten-status-flip case above, where only the superseding document's ownsupersedeslist names this one.contradicts: the document's own frontmatter list (unchanged from the frontmatter, never affects filtering or ranking anywhere in the retrieval path).contradicted_by: the computed reverse -- every other document whosecontradictsnames this one.
All three are omitted from the compact kb_get response when empty (same
deviation-only pattern as freshness). Compact kb_search hits carry a
deviation-only superseded_by (the same computed union above), omitted when
the document is not superseded; it is computed and attached to EVERY hit
regardless of in_force, so a plain search result still explains why a hit is
historically superseded. kb_search hits do NOT carry contradicts /
contradicted_by; those are kb_get-only.
Validity: expired docs leave default results
Independent of in_force, a doc past its validity.valid_until date is
excluded from EVERY default kb_search result (SPEC.md section 4.2): an
expired doc has no named successor to outrank it, so left visible it could be
the top hit and would govern. Three related kb_search parameters (MCP tool
and GET /api/v1/search):
include_expired: bool = falserestores expired docs to the result set; each carriesfreshness: "expired"in its hit.validity_stateis an audit facet:"expired","stale"(pastrecheck_by), or"expiring_within:N"(docs whosevalid_untilfalls within N days). Filtering for"expired"implies including expired docs. A malformed value is rejected (HTTP 400 on REST).- Compact hits carry a deviation-only
freshnessfield (stale/expired/upcoming), omitted when fresh or when the doc has novalidityblock. A doc with a futurevalid_fromstays in default results flaggedupcoming; onlyin_force=trueexcludes it. A stale doc (pastrecheck_by) stays in force and visible.
kb_get by id always resolves regardless of expiry, returning the full
validity object plus the computed freshness indicator. kb_consult never
returns an expired (or upcoming, proposed/retired, memory-inbox, or
graph-excluded) doc, via its unconditional in_force=true retrieval (see the
in_force and "Supersession-graph exclusion" sections above), not merely by
reusing the default search path's own expired-exclusion.
The data-olympus validity-report CLI subcommand lists expired and
soon-to-expire docs from a bundle directory.
abstain: signal-gated abstention
kb_search accepts an abstain: bool = False parameter (MCP tool and
GET /api/v1/search?abstain=true). When set, the query is first run restricted
to the discriminating columns (title, tags, applies_when). If it matches
none of them, the query is treated as out-of-scope and the search returns NO
hits with abstained: true and abstain_reason: "no_signal_match", instead of
surfacing a weak match on generic body prose. A query with a real signal
retrieves normally over all columns (recall is preserved).
The response distinguishes a deliberate abstention from an ordinary empty
result: a normal search that simply finds nothing returns abstained: false.
The gate logic is single-sourced in data_olympus.search_gate; the benchmark
ablation imports it rather than reimplementing it.
Taxonomy and writable paths
The server maps each document's path to a (tier, category) pair. The built-in
default is deployment-neutral and covers universal/, tech-stacks/<stack>/,
decisions/, workflows/, memory/ (with memory/inbox/ and
memory/accepted/), tooling/, templates/, and projects/<name>/
(with components/<component>/ for T4).
A bundle that uses a different directory layout overrides the defaults at deploy time, with no code change:
KB_TAXONOMY_PATH: path to a JSON file holding a list of[prefix, tier, category]triples that replaces the default table. Thetech-stacks/andprojects/prefixes keep their dynamicstack:<name>/project:<name>behavior if present.KB_INDEXED_PREFIXES: comma-separated list of writable top-level prefixes that replaces the default writable set. Writes outside these prefixes are rejected by the structural rule.KB_MEMORY_INBOX_PREFIX: directory new memory proposals are written under (defaultmemory/inbox/).
Synonym / acronym query expansion
Before building the FTS MATCH, the server rewrites the query term list through a
curated, bidirectional synonym map. A short-form query (k8s, rls) also reaches
documents that only use the long form (kubernetes, row level security), and
vice versa; adjacent query tokens are scanned as n-grams so multi-word keys match
from a long-form query. Expansion is bounded (32 terms) and de-duplicated, and the
terms the user actually typed are ranked first. This is curated lexical expansion,
not semantic (embedding) retrieval.
The default map ships a small set of high-value, unambiguous technical acronyms
(k8s/kubernetes, authn/authz, rls, adr, rbac, sso, pii, iac,
kb). Ambiguous or generic short tokens are intentionally left out of the
defaults to avoid recall noise; add them per deployment via KB_SYNONYMS.
Two environment variables tune it:
KB_SYNONYMS: extra or override groups inkey=variant,variant;key2=variantform. Groups are;-separated; within a group thekeyis the canonical form and the comma-separated tail are its variants. Whitespace is trimmed and empty entries are dropped. Multi-word keys and variants are allowed (e.g.row level security=rls). Example:KB_SYNONYMS="feature flag=ff,flag;service level objective=slo".KB_SYNONYMS_MODE: howKB_SYNONYMScombines with the built-in defaults.merge(default): layerKB_SYNONYMSon top of the curated defaults; a key present in both is overridden byKB_SYNONYMS.replace: use onlyKB_SYNONYMS; the curated defaults are dropped.off: disable expansion entirely (the expander becomes a passthrough, so only the terms the user typed are matched).
Optional local-embedding hybrid ranking
For real paraphrase / synonymy handling (a query that shares no tokens with the
relevant doc, e.g. car for a doc about automobile), the server can blend BM25
with LOCAL embedding similarity. This is optional and OFF by default: with
the feature off and the embeddings extra uninstalled, nothing imports the
embedding stack and the default lexical product is unchanged. There is no
external API and no network call at query time; a small ONNX model runs
in-process.
When enabled, each document is embedded at index-build time and its vector is
stored in a doc_vectors table (schema v8) written into the same tmp database
and swapped atomically with the rest of the index, so vectors rebuild
atomically. At query time a hybrid reranker embeds the query once and blends
normalised BM25 with cosine similarity over the candidate hits' stored
vectors, then re-sorts. It composes UNDER the exact-id / exact-tag short-circuit
(so an exact id or tag still ranks first) and wraps the status-aware rerank (so
an active doc still outranks the superseded one it replaced). When disabled the
reranker stack is exactly the status + id/tag behaviour described under Search
ranking.
Enable and tune it with:
KB_EMBEDDINGS_MODE:onto enable; any other value (including unset) leaves it off.KB_EMBEDDINGS_MODEL: the local model name (defaultBAAI/bge-small-en-v1.5, a 384-dimensional MiniLM-class model). Anyfastembed-supported model works.KB_EMBEDDINGS_WEIGHT: the cosine fraction of the blended score, in[0, 1](default0.35).0.0is pure lexical (BM25) ordering,1.0is pure semantic. A malformed or out-of-range value fails startup loudly.
Install the extra to make the model runner available:
uv sync --extra embeddings # or: pip install 'data-olympus[embeddings]'
The extra pulls in fastembed, which bundles onnxruntime (there is no torch
dependency). The default model ships as a ~33 MB quantised ONNX file that is
fetched once when the feature is first enabled and cached locally; after that
there is no network access. If KB_EMBEDDINGS_MODE=on but the extra or the model
is unavailable, startup fails loudly with an actionable message rather than
silently reverting to lexical-only ranking.
Cost: build adds one batched local embedding pass over the corpus (seconds for a
small KB); storage adds dim x 4 bytes per document in doc_vectors (~1.5 KB
per doc for the 384-dim default). Query-time cost is one query embedding plus a
cosine over the (bounded) candidate pool.
Corpus co-occurrence query expansion
A build-time pass mines the corpus for term pairs that co-occur far more often than chance (positive pointwise mutual information) and stores the top related terms per term. At query time the expander adds those related terms so a query reaches documents that use a different-but-associated vocabulary, without a hand-curated synonym map. This is ON by default and adds no query-time network call. It auto-disables on a corpus below the doc floor (too little signal), and the O(n^2) pair counting is bounded per document.
Tune it with:
KB_COOCCURRENCE_MODE:offdisables it (both the build-time table and the query-time expansion); any other value (including unset) leaves it on.KB_COOCCURRENCE_K: max related terms kept per term (default5).KB_COOCCURRENCE_MIN_COUNT: minimum raw co-occurrence count for a pair to qualify (default3).KB_COOCCURRENCE_MIN_PMI: minimum PMI for a pair to qualify (default0.1).KB_COOCCURRENCE_MIN_DOCS: corpus-size floor below which co-occurrence is auto-disabled (default50).KB_COOCCURRENCE_MAX_DOC_TOKENS: per-document unique-token cap on the pair counting, bounding the O(n^2) work on large docs (default400).
Trigram fuzzy-match fallback
For typos and partial identifiers, an optional trigram index backfills results when the primary FTS query returns few hits. This is OFF by default so the default deployment pays no trigram build or storage cost; when off, the trigram table is neither created nor populated. When on, a primary query returning at or below the threshold is backfilled from the trigram index, and the backfill only ever appends after the primary hits (never reorders them).
Tune it with:
KB_TRIGRAM_MODE:on(case-insensitive) enables it; any other value (including unset) leaves it off.KB_TRIGRAM_FALLBACK_THRESHOLD: primary-hit count at or below which the fallback fires (default3). A malformed or negative value falls back to the default.
Authentication and network security
The server supports optional bearer-token authentication with a per-principal capability model, enforced on both the REST routes and the MCP write tools:
KB_AUTH_TOKENregisters a single full-capabilityoperatorprincipal.KB_AUTH_PRINCIPALS(JSON) registers per-agent tokens with explicit capabilities; a principal lackingauto_commithas its proposals clamped to pending regardless of the client-asserted confidence.
When auth is configured, write, enforcement, and observability routes require a
capable principal; read routes (search/get/list/outline/health) stay
open. When it is unset (the default) every caller is fully trusted, which is only
safe on a trusted private network.
Authentication does not protect read routes, so for confidentiality operators
MUST still deploy on a trusted private network or behind an authenticating
reverse proxy (terminate TLS there). See SECURITY.md for the full threat model,
the route/capability table, payload limits, the tamper-evident audit log, and the
git sync-failure health fields.
Host allowlist behind a proxy (KB_PUBLIC_HOSTNAMES, v0.4.3+)
fastmcp >= 3.4.3 validates the Host header on streamable HTTP
(DNS-rebinding protection) and returns 421 Misdirected Request for
hostnames outside its allowlist. When data-olympus is served behind a
reverse proxy or ingress, set the public hostname explicitly via the
first-class knob (comma-separated, no JSON):
KB_PUBLIC_HOSTNAMES=kb.example.com
This is mapped onto fastmcp's Host-header allowlist when the HTTP app is
built. The older dependency knob FASTMCP_HTTP_ALLOWED_HOSTS='["kb.example.com"]'
still works and is merged with KB_PUBLIC_HOSTNAMES when both are set.
Direct localhost/pod requests are always allowed, so health probes pass while proxied traffic fails: this is the exact shape of the 2026-07-09 kn-dev outage (readiness green, every proxied request 421). To make that failure loud, the server now emits a startup WARN when Host protection is on, it binds a non-loopback address, and no public hostname is allowed. Check the server log for that WARN or for 421 lines if agents suddenly cannot reach the endpoint after an upgrade. Operational details in docs/operations.md section 3.4. (Closes issue #139 / KNA-70.)
Prometheus metrics (GET /metrics, optional metrics extra)
prometheus-client is an OPTIONAL dependency. Install it to enable the
scrape endpoint:
uv tool install 'data-olympus[metrics]' # or pip install 'data-olympus[metrics]'
With the extra installed, GET /metrics serves the standard text exposition
format. Without it, /metrics returns 501 Not Implemented (the route exists,
the capability is simply not built into the image) and every metric update
inside the core background loops is a silent no-op, so a standard deployment is
unaffected. Exposed series (prefix data_olympus_):
pending_queue_depth,push_queue_depth,push_queue_frozen(gauges)push_failures_total(counter)staleness_seconds,live_sessions(gauges)tool_calls_total{tool="..."}(counter, per MCP tool)index_build_duration_seconds(histogram) andindex_last_build_timestamp_seconds(gauge)
The gauges are refreshed from the refresh / push-retry loops each tick and also snapshotted on scrape, so a scrape reflects the live queue depths and freshness. (Closes issue #69 / KNA-71.)
Proxy headers and the rate limiter (KB_TRUSTED_PROXIES)
The rate limiter keys on the client remote address (plus principal). Behind an
ingress or reverse proxy, uvicorn by default sees the proxy's address as the
peer, so every client collapses into one remote_addr and the per-IP cap
(KB_RATE_LIMIT_PER_IP_PER_HOUR) throttles all clients as if they were one.
Set KB_TRUSTED_PROXIES to the proxy address(es) (comma-separated) to fix this:
- Unset (default) — uvicorn runs with
proxy_headersoff.X-Forwarded-Foris ignored andremote_addris the immediate peer. This is the safe default: a direct client cannot spoof its address to dodge the limiter. - Set to the proxy IP(s) — uvicorn enables
proxy_headersand restrictsforwarded_allow_ipsto those addresses, so it rewritesremote_addrfromX-Forwarded-Foronly when the immediate peer is a trusted proxy. The limiter then sees the true client IP. *— trustX-Forwarded-Forfrom any peer. Only safe when nothing untrusted can reach the port directly (e.g. the port is bound to the pod network and only the ingress can connect). Otherwise a direct client could forge the header.
The gate-check route (KB_GATE_CHECK_RATE_LIMIT_PER_HOUR)
POST /api/v1/gate/check (and the kb_gate_check MCP tool) is the enforcement
hook's freshness probe, called once per gated tool action by every agent. It
therefore must not share the write/consult quota (KB_RATE_LIMIT_PER_HOUR): with
several agents active, and all clients collapsing to one limiter bucket behind an
ingress, a fixed hourly quota self-throttles the whole fleet with 429s. So
gate-check has its own ceiling:
KB_GATE_CHECK_RATE_LIMIT_PER_HOUR=0(default) — gate-check is not rate-limited. It does only cheap classification plus a freshness lookup and no writes, so this matches the read routes, which are also unthrottled.- A positive value — an explicit per-(address, principal) backstop just for gate-check, independent of the write/consult limiter. Size it above your fleet's real per-hour tool-call volume, not near it, or you reintroduce the self-DoS.
The consult and onboarding/cleanup-plan routes remain throttled by
KB_RATE_LIMIT_PER_HOUR (they write to the ledger or are CPU-heavy).
Audit-log rotation (KB_AUDIT_MAX_BYTES)
The JSONL audit log (KB_AUDIT_LOG_PATH, default /state/audit/events.log) grows
without bound by default. Set KB_AUDIT_MAX_BYTES to a byte threshold to enable
size-based rotation: when the live file passes the threshold, the next append
first renames it to events-<UTC-timestamp>.log and starts a fresh live file.
- Chain continuity. The tamper-evident hash chain carries across the
rotation boundary: the first event of the new file links to the last hash of the
rotated file, so
GET /api/v1/audit/verify(and thekb audit/verifylogic) validates the whole history, rotated segments included, in chronological order. A break at the boundary is caught like any other. - Backward compatible. With
KB_AUDIT_MAX_BYTESunset (0), nothing rotates: the log stays a single file exactly as before, and an existing single-file log still verifies unchanged when opened by a rotation-aware build. - Reads.
kb auditreads the live file by default (cheap, most-recent-first). A--sincequery also walks rotated segments so history that has rotated out of the live file is still visible; the reverse scan is bounded so a large archive cannot turn one query into an unbounded read.
Manual mv-based rotation (documented in the operator runbook) no longer breaks
the chain when the process keeps running, because the in-memory last-hash carries
forward; but prefer KB_AUDIT_MAX_BYTES so rotation is automatic and the boundary
link is always written. See docs/operations.md for backup/verify procedures.
Git commit identity
Every write commits to the KB repo, so git needs an author/committer identity.
The shipped Docker image sets a default (data-olympus-mcp <data-olympus-mcp@localhost>)
in deploy/docker/entrypoint.sh so the artifact can commit out of the box; the
server's main() also fills unset GIT_* variables as a belt-and-suspenders for
non-Docker runs. Override the identity with:
KB_GIT_AUTHOR_NAME— commit author/committer name (defaultdata-olympus-mcp).KB_GIT_AUTHOR_EMAIL— commit author/committer email (defaultdata-olympus-mcp@localhost).
A pre-existing GIT_AUTHOR_*/GIT_COMMITTER_* value or a real git config
identity is never overwritten.
Running locally
See docs/quickstart.md for the verified local-run procedure using
scripts/run-local.sh.
Running in Kubernetes
See deploy/k8s/ for the kustomize manifests. Apply them with:
# Apply namespace and non-secret resources
kubectl apply -k deploy/k8s/
# Apply the encrypted secret separately (operators use SOPS)
sops exec-file deploy/k8s/secret.sops.yaml 'kubectl apply -f {}'
The deploy/k8s/secret.template.yaml file shows the expected secret shape.
Fill in your git remote URL and deploy key, then encrypt with SOPS before
committing. Never commit the unencrypted secret.
The default kubectl apply -k deploy/k8s/ deliberately does not create the
Ingress: it publishes all routes (including the unauthenticated-by-default write
routes) to the LAN. The Ingress is opt-in and enabled only after you set
KB_AUTH_TOKEN; see deploy/k8s/README.md for the enablement steps.
The pod is fully rootless: both the prepare-git initContainer (which stages the
deploy key and does the first-boot /kb-main clone) and the main container run as
uid 65534 with runAsNonRoot: true, all capabilities dropped, and a read-only
root filesystem, so the manifest passes the restricted Pod Security Standard.
The base image is digest-pinned in deploy/docker/Dockerfile; the git SSH host is
build-arg + runtime-env configurable (KB_SSH_KEYSCAN_HOST) for non-GitHub
remotes. Operational procedures (backup, upgrade, recovery playbooks) live in
docs/operations.md.
Running with Docker Compose
docker compose -f deploy/docker/compose.yaml up --build
See docs/quickstart.md section 5 for the bind-mount pattern to serve a
real bundle.