Documents that go stale on purpose
A knowledge base that never re-reads its sources is a snapshot with ambitions. This one gives every source its own freshness clock, tells you when one has gone off, asks before it fetches anything, does the fetching in a window you chose, and keeps the old text when the new text arrives.
- 9 endpoints
- Per-source refresh TTL
- Nightly window in your timezone
- Versioned, never replaced
The refresh cycle
Five steps, and a human decision sits in the middle of them on purpose. Automatic fetching of arbitrary URLs is how a knowledge base becomes an attack surface.
-
A source goes past its TTL
Each source carries its own refresh_ttl_seconds, defaulting to seven days and settable from five minutes to a year. Nothing runs on a global cron that ignores what the document actually is — a price list and a company handbook do not need the same cadence.
-
The system suggests, it does not act
A stale source produces a row in kb_suggestions with a reason. Listing suggestions is what generates them, so the queue is always current when you look at it. No refresh happens because a timer fired; a refresh happens because somebody approved one.
-
You approve, and it is scheduled — not run
Approval computes the next occurrence of your configured night start in your configured timezone and enqueues a KB_REFRESH_SOURCE event with that availability time. Nothing before that moment will claim it. If you need it sooner, queue it with when=now — and either way, KB mutations are rate-limited to ten per tenant per hour by default.
-
The worker fetches it, carefully
A worker claims events with FOR UPDATE SKIP LOCKED, so several workers can drain the same queue safely. The fetch sends the stored ETag and Last-Modified back as conditional headers: a 304 costs almost nothing and simply moves the last-checked timestamp. A failure records the status on the source and retries with a ten-minute backoff rather than losing the job.
-
New text supersedes old text without deleting it
HTML is reduced to text with script, style and noscript content dropped. The text is chunked at 1,200 characters with 150 characters of overlap, each chunk is embedded locally, and the write closes the previous version’s system_time range before inserting the new rows with an open one. Yesterday’s wording is still readable; only “current” moved.
Nine endpoints
Reads take an API token. Every mutation — adding a source, changing the window, approving a refresh — additionally requires an admin token, because each one can cause the server to make an outbound request on your behalf.
| Endpoint | What it does |
|---|---|
GET /v1/kb/status | Active sources, open suggestions, approved suggestions. Reports enabled:false rather than erroring when KB is off. |
GET /v1/kb/sources | Every source with its URI, type, TTL, last check, last status and stored validators. |
POST /v1/kb/sources | Add or update a source. Quota-checked on create; refresh TTL from five minutes to a year. |
POST /v1/kb/sources/{id}/toggle | Deactivate a source without deleting it. Inactive sources are skipped by the worker and marked as such. |
POST /v1/kb/sources/{id}/queue | Queue a refresh now, or at the next night window in the tenant’s timezone. |
GET /v1/kb/settings | The tenant’s refresh window, timezone, nightly job cap and enabled flag. |
POST /v1/kb/settings | Change them. Night start and end are wall-clock times in the timezone you set. |
GET /v1/kb/suggestions | Open suggestions — and generating them is a side effect of asking, so stale sources surface on read. |
POST /v1/kb/suggestions/{id}/approve · snooze · dismiss | Approve schedules the refresh for the next window. Snooze defers by one to 365 days. Dismiss closes it. |
Fetching a URL somebody else chose is the dangerous part
The moment a product will fetch a URL on request, it becomes a proxy into whatever network it is running in — cloud metadata endpoints, internal admin panels, a database on a private subnet. The usual defence validates the hostname and then hands the URL to an HTTP client that resolves it again, which is a race an attacker can win.
All of it lives in one readable file, backend/app/kb/ssrf.py,
rather than being spread across middleware. Security you cannot read
in one sitting is security you cannot audit.
What gets stored, and how it is found again
A source is a row in kb_sources holding its URI, type,
TTL, last check time, last status and the ETag and Last-Modified
values used for conditional requests. Its content lives in
kb_documents, one row per chunk, each with a
content_hash, a chunk index, a vector embedding and a
search_vector generated by Postgres.
Documents carry a system_time range exactly like facts
do. Current chunks are the ones whose range is still open, and a
partial index makes that the cheap query. Refreshing a source closes
the old range and opens a new one — so “what did this page say last
quarter” remains a question with an answer.
Retrieval over KB chunks is vector search restricted to current chunks of active sources, and each extract that reaches a context pack carries its source name into the manifest. Citations are a property of the storage, not a prompt instruction.
- (Supplier handbook v4) Payment terms for
retainer clients default to net-45 unless…
// manifest entry
{ "layer": "kb",
"source": "Supplier handbook v4",
"document_id": "d_9c31" }
// only chunks with an open system_time,
// and only from active sources
Stored and refreshed is not the same as injected
ENABLE_KB_RETRIEVAL defaults to false. With
the default configuration, knowledge-base sources are fetched,
chunked, embedded, versioned and searchable through the API — and they
do not enter a context pack. Nothing is quietly pushed into your
agent's prompt because you added a URL.
That is the right default for a feature whose input is text somebody else controls. Turn it on when you have set your allowlist and you are satisfied with what the sources contain. When you do, KB extracts are fenced as evidence like everything else in the pack, and they sit at 0.66 on the trust table — above a raw conversation turn, below a verified policy.
Source quotas are per plan and enforced as a hard block: exceeding the
count returns 402 with the metric and the limit rather
than billing you for the overage.
One more thing we would rather you heard from us. The nightly job cap is stored on the settings row and returned by the API, but the current worker drains whatever is due in the window and does not yet enforce that ceiling. The bound that does bite today is the request rate limit on KB mutations, and that limiter is in-process — it does not coordinate across replicas.
Give your agents a memory you can audit
Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.