Run, inspect, and repair
Operations
Use this page after the service is running. It is for operators evolving a workspace, running maintenance, or diagnosing worker behavior—not for normal application reads and writes. Start with Getting started and the HTTP API guide for those paths. The Glossary defines operational terms such as job, run, backfill, projection, and erasure.
Runtime settings¶
The main settings groups are:
| Area | Important settings |
|---|---|
| Database | DATABASE_URL, pool sizes, migration settings |
| LLM | provider API keys, LLM_FAKE, concurrency, context/prompt/output limits |
| Catalog | COLLECTIONS_DIR, DERIVATIONS_DIR, TRIGGERS_DIR, VIEWS_DIR, ARTIFACTS_DIR, PACKAGES_DIR and the *_FILE paths under conf/ |
| Search | SEARCH_BACKEND, profile overrides, Turbopuffer credentials/layout/consistency, candidate and concurrency limits |
| Derivation | batch sizes, text/content limits, maximum depth, artifact/run limits |
| Contradiction detection | derivations/contradiction.yaml and collections/relations.yaml — see Contradiction detection |
| Artifact uses & feedback | ARTIFACT_USE_RETENTION_DAYS, ARTIFACT_USE_PURGE_BATCH, MAX_FEEDBACK_COMMENT_CHARS, MAX_FEEDBACK_EVIDENCE_CHARS — see Artifact uses & feedback |
| Safety | workspace auth, API_CORS_ORIGINS, MCP Origin validation, workspace locks, erasure and projection settings |
Use .env.example and src/memseek/config.py as the authoritative environment-name reference for the current release. Secrets belong in process configuration, not catalog YAML.
Remote MCP endpoint¶
The API process serves authenticated Streamable HTTP at /mcp; it needs no
separate MCP daemon. A reverse proxy must preserve the bearer and MCP routing
headers, allow 180-second calls, and avoid buffering request-scoped SSE. The
current protocol is stateless, so replicas do not need sticky sessions. See
MCP for the TLS, proxy, Origin, Claude
Code, and Codex configuration.
Browser workspace explorer¶
The full-screen workspace explorer is a read-only browser client for the same authenticated API. It reads catalog contracts, entity timelines and record dereferences, derivation runs, named views, and artifact renders; it never writes records, advances cursors, or queues work.
The browser passes the workspace bearer key directly to the API, so the API
must explicitly allow the explorer's origin. Origins are exact: wildcards are
rejected. Write one origin, several separated by commas, or a JSON array —
https://console.example.com and ["https://console.example.com"] are the
same list.
On the Docker stack, the api service reads API_CORS_ORIGINS from .env:
# .env, for the explorer served by the local marketing dev server
API_CORS_ORIGINS=http://localhost:4321
docker compose up -d api # recreates the API with the new list
Running Uvicorn yourself, it is an ordinary export:
# From the repository root, for the local marketing dev server.
# The database must already be running and migrated.
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/memseek_test
export LLM_FAKE=1
export API_CORS_ORIGINS='["http://localhost:4321"]'
uv run uvicorn memseek.api:app --host 127.0.0.1 --port 8000
# Add every production console origin explicitly, for example:
export API_CORS_ORIGINS='["https://console.example.com","https://memseek.ai"]'
Confirm it took effect — an allowed origin is echoed back, and any other origin gets no header at all, which is what makes the browser refuse the response:
curl -si -H 'Origin: http://localhost:4321' \
-H "Authorization: Bearer $MEMSEEK_API_KEY" \
http://127.0.0.1:8000/tools | grep -i access-control-allow-origin
# access-control-allow-origin: http://localhost:4321
The CORS list is read at API startup: restart the API (docker compose up -d api,
or stop and restart Uvicorn) after changing it. The explorer keeps a supplied key only in browser memory, and its terminal
handoff links include the API URL but never the key.
Real providers¶
Set LLM_FAKE=0, provide the OpenAI-compatible key/base URL, and choose model IDs in conf/models.yaml or the uploaded package:
Endpoints live in conf/models.yaml, so the only thing a deployment supplies
is each endpoint's credential, under the variable name that provider declared in
api_key_env:
# conf/models.yaml
providers:
openai:
adapter: openai_compat
base_url: https://api.openai.com/v1
api_key_env: OPENAI_API_KEY
token_limit_field: max_completion_tokens
json_capability: json_schema
json_schema_strict: false
Alias targets then read openai:model-id. The token-limit field can be
max_completion_tokens or max_tokens for compatible servers. Native
json_schema output is the default. Configure json_object or none on that
provider for an endpoint without the capability; a provider error never causes
an automatic downgrade. Declaring a second provider is how you point embeddings
at a different service — see Embeddings. The worker, not the API
process, makes model calls.
Keep LLM_FAKE=1 for repeatable tests and local schema work. A real provider can produce different prose and scores; test the contracts and citations rather than exact model wording.
Inspect the loaded graph¶
Authenticated read surfaces expose the normalized catalog without requiring access to source YAML:
curl -sS http://127.0.0.1:8000/catalog -H "$MEMSEEK_AUTH"
curl -sS http://127.0.0.1:8000/collections -H "$MEMSEEK_AUTH"
curl -sS http://127.0.0.1:8000/processors -H "$MEMSEEK_AUTH"
curl -sS http://127.0.0.1:8000/triggers -H "$MEMSEEK_AUTH"
curl -sS http://127.0.0.1:8000/artifacts -H "$MEMSEEK_AUTH"
curl -sS http://127.0.0.1:8000/rank/schema -H "$MEMSEEK_AUTH"
One registered artifact use reads back the same way, for support and debugging:
That returns identities, hashes, the resolved learning target, and expiry — and deliberately never a render, a prompt, a model response, or an external trace. See Artifact uses & feedback.
/collections, /processors, and /triggers include semantic hashes and normalized bindings. This is useful for deployment audits and client tooling.
Jobs and freshness¶
curl -sS 'http://127.0.0.1:8000/document?entity=user-42' -H "$MEMSEEK_AUTH"
curl -sS 'http://127.0.0.1:8000/runs?entity=user-42&processor=profile&operation=derive' \
-H "$MEMSEEK_AUTH"
curl -sS "http://127.0.0.1:8000/jobs/$JOB_ID" -H "$MEMSEEK_AUTH"
Document freshness reports derivation watermark, dirty input, unready barriers, last successful run, and queued/running/dead job information. Runs record model resolution, visible/cited inputs, output IDs, and status.
Erasure and projection repair¶
Erasure expands a bounded provenance closure, holds off active derivation jobs, deletes the records, queues external index deletion, refreshes keyed predecessors, and writes a hash-only _system/erasure audit record:
curl -sS -X POST http://127.0.0.1:8000/erase \
-H "$MEMSEEK_AUTH" -H 'Content-Type: application/json' \
-d '{"entity":"user-42"}'
External search indexes are disposable copies. Rebuild one without touching stored records:
uv run memseek reindex --workspace local --since-seq 100
uv run memseek reindex --workspace local --reset --yes
The same rebuild is a route, for a caller that holds a workspace key and no shell:
curl -sS -X POST http://127.0.0.1:8000/reindex \
-H "$MEMSEEK_AUTH" -H 'Content-Type: application/json' \
-d '{"since_seq":100}'
curl -sS -X POST http://127.0.0.1:8000/reindex \
-H "$MEMSEEK_AUTH" -H 'Content-Type: application/json' \
-d '{"reset":true,"confirm":true}'
The worker processes index deletion and update jobs after the database transaction commits, so the index can never get ahead of the records.
Evolving a live catalog¶
Six operator commands cover definition change. All of them work through ordinary database transactions under the workspace lock, and none of them changes record content. Changing definitions has the full guide; this is the operational summary.
# What would publishing this catalog do to the workspace? (exit 1 if blocked)
uv run memseek catalog-check --workspace local --dir ./catalog --package acme@1.4.0
# Apply one processor to every record that already exists (no budget needed).
uv run memseek backfill --workspace local \
--collection customer_events --version 1 --processor sentiment_v2
# ...or cap it, when you want a cost ceiling or a canary slice.
uv run memseek backfill --workspace local \
--collection customer_events --version 1 --processor sentiment_v2 --max-rows 5000
# Change the embedding model: stage, then promote once coverage is complete.
uv run memseek reembed --workspace local --space default-v2
uv run memseek reembed --workspace local --space default-v2 --cutover
# Repoint a changes cursor after a deliberate source-scope change.
uv run memseek rebind-cursor --workspace local \
--derivation profile --entity contact:avery-chen --policy reset
# Which inactive definitions does nothing reference any more?
uv run memseek catalog-prune --workspace local
# One-time: move records written before the record-contract identity forward.
uv run memseek migrate-collection-hashes --dry-run
uv run memseek migrate-collection-hashes --workspace local
Each prints one JSON object. catalog-check and migrate-collection-hashes exit
non-zero when the workspace is blocked or incomplete, so they compose in a
deployment pipeline.
These commands connect to the database directly and are scoped by --workspace,
which is what makes them operator tools. Every one of them except reembed and
migrate-collection-hashes is also a workspace-scoped route, so a tenant can run
its own evolution without shell access to the deployment:
| Command | Route | Client |
|---|---|---|
catalog-check |
POST /catalog?dry_run=true |
catalog.check() |
backfill |
POST /backfill |
backfill.start() |
rebind-cursor |
POST /derivations/{name}/rebind |
rebind_cursor() |
catalog-prune |
GET /catalog/prune |
catalog.prune() |
reindex |
POST /reindex |
reindex() |
A migration, start to finish runs the whole sequence through the client, with no command line at any step.
Operating a backfill¶
A backfill is a job lane, so the worker must be running to drain it. Omitting
max_rows is the normal case — the backfill then reaches every eligible record
and needs no chunking or supervision. Two bounds apply:
| Bound | Default | Limits |
|---|---|---|
max_rows on the request |
unlimited | records this backfill will ever scan |
BACKFILL_BATCH |
200 | records per batch — and one batch is all a worker pass does |
max_rows bounds records scanned, which is the quantity that costs money — a
record that is scanned and then terminally fails still spent its provider call.
scanned and annotated on the handle differ by exactly the terminal failures, so
a widening gap between them means the processor is failing on real content; check
GET /runs for those records.
Provider calls are sub-batched by ENRICH_LLM_BATCH (default 16) for llm
processors and 64 for embeddings, so a 50,000-record backfill of an LLM processor
is roughly 3,000 completion calls. BACKFILL_BATCH does not change that.
BACKFILL_BATCH is the interleaving granularity, and it is what makes an
unbudgeted whole-corpus backfill safe: one pass runs exactly one batch, then
services every other lane before returning. Ingest enrichment runs before the
backfill lane, so improving history never delays admitting new records. A pass that
did backfill work is marked busy, so the next pass starts without a poll delay —
worker.pass logs backfill_batches and backfilled_annotations to make the rate
visible. Raise BACKFILL_BATCH for throughput on a quiet deployment; lower it to
tighten latency for the other lanes.
Reaching max_rows finishes the backfill as done with scanned == max_rows;
request the same target again to take another slice. Because selection is by
absence of the annotation, the next slice resumes automatically. If you are
scripting that loop, drop max_rows instead.
A completed backfill reports cursor_seq: 0. That is deliberate evidence: row
selection skips records another lane holds locked, so an exhausted sweep rewinds
and sweeps again from the first record, and done is only set once a sweep from
the start finds nothing eligible.
ADDITIVE_VERIFY_MAX_ROWS (default 50,000) is unrelated to backfills — it bounds
the row check a publish runs before accepting an additive schema change.
Scheduled tombstone retention¶
Packages can declare tombstone retention
for delayed physical deletion. This creates internal retention_purge work
only; there is no retention HTTP endpoint. The worker selects current keyed
retractions old enough by server created_at, then runs the same
erasure and projection repair described above. Check the package YAML, worker
logs, and _system/erasure audit rows when operating one of these policies.
Expiring artifact uses¶
An artifact use is operational metadata, not durable history, and it expires
after ARTIFACT_USE_RETENTION_DAYS (default 90). Unlike tombstone retention
this is a deployment setting rather than a package policy, so it creates no job
and needs no cron declaration: each worker pass deletes one bounded
ARTIFACT_USE_PURGE_BATCH page (default 500) of expired rows across every
workspace and logs artifact_uses.expired_purged when it removed any. A purged
page marks the pass busy, so a backlog drains without waiting on the poll
interval.
No stored record is removed. The learning signals that feedback produced and any artifact snapshots bound to those uses are ordinary records and follow their own retention and erasure rules — which is why a signal outlives the handle that created it. Lowering the setting is safe: handles registered under a longer window become expired immediately and are purged on the next pass.
An expired handle refuses new feedback with 410 artifact_use_expired and
reports "expired": true on GET /artifact-uses/{id} until it is purged, so
the two states are distinguishable while operating.
Troubleshooting¶
| Symptom | Check |
|---|---|
401 unauthorized |
Export the one-time workspace key as MEMSEEK_API_KEY. |
422 definition |
Read the machine-readable code, file, and dotted path; no partial catalog was installed. |
409 catalog_incompatible |
Read compatibility.blockers in the response: each names the rows it protects and the action that fixes it. Run catalog-check first next time. |
409 backfill_exists |
A live backfill already targets that collection version and processor. Inspect it with GET /backfill, or cancel it. |
409 incomplete_space on cutover |
Records still have no vector staged in the target space. Finish reembed, then cut over. |
Backfill stuck in queued |
The worker is not running, or its lane is behind. Check worker.pass logs for backfilled_annotations. |
Backfill state failed |
Read last_error on GET /backfill/{id}; the target became impossible (for example a processor was removed). |
| Rows never become ready | Confirm worker/database settings and required processor credentials. |
| Derivation does not run | Inspect readiness, metric scores, watermark, cooldown, and worker job status. |
| Provider rejects request | Check alias target, configured JSON capability, supported generation params, token-limit field, and provider environment. |
| External index is stale | Inspect projection jobs; the database remains the source of truth, and the index can be rebuilt. |
410 artifact_use_expired |
The handle outlived ARTIFACT_USE_RETENTION_DAYS. Raise it only if your real feedback window is longer; already-purged handles cannot be recovered. |
Feedback returns 422 learning_signals_unavailable |
The workspace package does not include a learning_signals collection. Add it and republish. |
| A learning signal has no learning target | The render's target block read no active head, or the artifact declares no learning:. Check GET /artifact-uses/{id} and the artifact definition. |
| Candidate derivation sees no evidence | Confirm the signal's entity (artifact:<name>) and type (the signal kind) match the derivation's source scope. |
Verification gate¶
The project gate synchronizes the locked environment, checks formatting and lint, type-checks, builds the package, runs the reference parity check, and runs the test suite against the isolated database. make e2e runs the deterministic ingest/search acceptance flow.