Production scope & link refresh
-
Deployment-owned settings
When
ui.runtime_mode=production, a fixed set of global settings is reconciled into every corpus-scoped config — on read and on save. -
Drift cannot come back
Saving a stale per-corpus config in production reconciles before persisting, so a client PUT cannot reintroduce old model aliases or base URLs.
-
Deep links follow the deploy
Grafana/Tempo and Langfuse links stored in traces are re-pointed to the current deployment origins when traces are read.
Configuration Tracing Operations & metrics
Who should read this
- Operators running ragweld in
ui.runtime_mode=production(for example, behind the Proxmox/Caddy ingress): understand which per-corpus settings are no longer corpus-editable. - Engineers: the reconciliation lives in
server/services/config_store.py(_PRODUCTION_SCOPED_GLOBAL_PATHS,_reconcile_production_scope), and the link refresh inserver/services/traces.py.
Production-scoped config reconciliation
In development, per-corpus config wins over global config, full stop. In production, some values are deployment facts — which model alias the gateway exposes, where Grafana and Langfuse live, what the trace store path is — and a corpus must not carry its own divergent copy.
When the global config has ui.runtime_mode=production:
- On read (
ConfigStore.get(repo_id=...)): each production-scoped path in the scoped config is replaced with the global value. If anything changed, the reconciled config is persisted and the changed paths are logged as migrations. - On save (
ConfigStore.save(config, repo_id=...)): the incoming config is reconciled against the global config before persistence, so a stale client snapshot cannot reintroduce drift. - In development: nothing is reconciled; corpus overrides always win.
The production-scoped paths
Managed in _PRODUCTION_SCOPED_GLOBAL_PATHS in server/services/config_store.py:
| Area | Paths |
|---|---|
| Generation models and limits | generation.gen_model, generation.enrich_model, generation.gen_max_tokens |
| Chat | chat.max_tokens, chat.litellm.default_model, chat.multimodal.vision_model_override, chat.vllm.enabled, chat.web |
| Synthetic generation | synthetic.generator.max_tokens |
| Embedding provider | embedding.embedding_backend, embedding.embedding_type, embedding.embedding_model, embedding.embedding_dim |
| UI defaults | ui.chat_default_model, ui.runtime_mode, ui.open_browser, ui.grafana_base_url |
| Observability endpoints | tracing.langfuse_base_url, tracing.langfuse_public_base_url, tracing.faro_base_url, tracing.trace_store_path |
| Training/eval endpoints and judges | training.ragweld_agent_flyte_admin_base_url, training.ragweld_agent_flyte_console_base_url, training.ragweld_agent_flyte_callback_base_url, training.ragweld_agent_mlflow_tracking_url, training.ragweld_agent_mlflow_console_base_url, evaluation.ragas_judge_model, evaluation.promptfoo_grader_model |
Don't hand-edit these paths per corpus in production
The values are reconciled away on the next read. Change them in the global config (or the deployment environment) instead. Corpus-specific tuning that is not on the list — retrieval, fusion, chunking, recall gates — remains fully corpus-scoped.
Concrete production aliases
The Proxmox production render (deploy/proxmox/render_config.py) sets chat.litellm.default_model and ui.chat_default_model to z-ai.glm-5.3-flash, while keeping chat.multimodal.vision_model_override on openai.gpt-5.6-terra. Two things follow from this split:
- The chat default is a fast, lightweight gateway alias — it is what every conversation starts on unless a per-message override is picked.
- The vision override stays pinned to a multimodal-capable alias, because image-capable requests route through
chat.multimodal.vision_model_overriderather than the chat default.
Both are deployment-owned values on the production-scoped list above: a stale per-corpus snapshot carrying an older alias is reconciled to the current global value on the next read, and a client PUT cannot reintroduce the drift (see the save-path behavior below).
Mechanism diagram (reconciliation only; the wider config store behavior is covered in the config store guide):
flowchart LR
A["Persisted corpus config"] --> B{"Global\nui.runtime_mode == production?"}
B -->|"no"| C["Corpus overrides win\n(no reconciliation)"]
B -->|"yes"| D["Replace production-scoped paths\nwith global values"]
D --> E["Read path: changed values\npersisted + logged as migrations"]
D --> F["Save path: reconciled\nbefore persisting"] Retrieval surface: the non-chat generation alias is deployment-locked
The generation alias renders once on the RAG → Retrieval subtab, under Generation → Answer Routing (web/src/components/RAG/RetrievalSubtab.tsx) — the duplicate picker that used to sit in Universal Controls was removed. In production mode that single picker no longer treats the alias as a per-corpus knob: it is read-only, relabeled, and explained in place:
| What you see | What it means |
|---|---|
| Label reads Non-chat generation alias (instead of Generation Alias) | This alias feeds the non-chat answer pipeline (/api/answer, eval analysis, synthetic generation) — not Chat. |
| The model picker is disabled | The value is deployment-owned (generation.gen_model is on the production-scoped list above), so the UI cannot edit it per corpus. |
| Lock note under the picker | "Chat uses its own model picker. This non-chat answer pipeline is locked by the production deployment." — Chat routing follows chat.litellm.default_model / ui.chat_default_model through the Chat model picker, which stays interactive. |
The lock is exposed accessibly: the select carries aria-describedby pointing at the lock note, so screen readers announce why the control is disabled. Behavior is covered by the web/tests/e2e/gateway/production_generation_alias.spec.ts e2e test.
Mechanism diagram (this UI lock only — the wider reconciliation flow is the diagram above):
flowchart LR
A["RAG - Retrieval subtab"] --> B{"ui.runtime_mode == production?"}
B -->|"no"| C["Generation Alias picker editable\n(generation.gen_model per corpus)"]
B -->|"yes"| D["Picker disabled + relabeled\nNon-chat generation alias"]
D --> E["aria-describedby lock note\nChat picker stays interactive"]
D --> F["Value comes from the deployment\ngeneration.gen_model\nreconciled on read and save"] If you need a different non-chat answer model in production
Change generation.gen_model in the global config (or the deployment render), not in the corpus UI. The retrieval subtab picks up the reconciled value on the next read — see Configuration for the read/patch workflow.
Development mode is unchanged
When ui.runtime_mode=development, the picker is labeled Generation Alias and remains fully editable per corpus. Only production mode locks it.
Renderer output safety (deploy/proxmox/render_config.py)
The Proxmox production render writes the validated config to disk atomically, and the write path is deliberately defensive. If you run the renderer from cron, systemd units, or a different user than whoever owns the deployed config, these rules are what protect you:
- Regular file required
- If the output path already exists, it must be a regular file. Symlinks — including a symlink pointing at an unrelated file elsewhere on disk — are refused with
output must be a regular file when it already existsbefore anything is written, so a render can never silently overwrite a file it was not pointed at. - Ownership preserved
- When the renderer replaces an existing output, the new file inherits the old file's
uidandgid, and is always written with mode0600before it is swapped in viaos.replace. A config rendered as root keeps the service account's ownership of the deployed file. - Original kept on failure
- If restoring ownership fails (for example, a non-root renderer replacing a file it does not own), the render fails, the original file is left byte-for-byte intact, and the temporary file is cleaned up. A failed render never leaves you with a half-owned or mode-flipped production config.
flowchart LR
A["Rendered config JSON"] --> B{"Output exists?"}
B -->|"no"| E["Write temp file\nfchmod 0600"]
B -->|"yes"| C{"Regular file?"}
C -->|"no (symlink or special)"| D["Refuse:\noutput must be a regular file"]
C -->|"yes"| F["Write temp file\nfchown original uid:gid\nfchmod 0600"]
E --> G["os.replace\natomic swap"]
F --> G
F -.->|"fchown fails"| H["Keep original\nbyte-for-byte\nclean up temp"]
G --> I["Deployed config\n0600, original owner"] If you're not sure
Run the renderer as the same user that owns the deployed config, or expect to re-run it with sufficient privileges. If you see output must be a regular file, check for symlinks at the output path (ls -la) rather than forcing the write — the renderer refuses them on purpose.
Unit coverage for these behaviors lives in tests/unit/test_proxmox_deployment_contract.py (ownership preservation, ownership-failure rollback, and symlink rejection).
The public origin, spelled once (Faro and MCP)
render_config.py derives every browser-facing endpoint that must agree with the deployment's origin from one constant, PRODUCTION_PUBLIC_ORIGIN (https://ragweld.dtmont.com):
PRODUCTION_FARO_URLis{origin}/faro/collectinstead of repeating the host in a second constant.mcp.public_base_urlis the origin itself — not origin +/mcp, because the server appendsmcp.mount_pathand a base that already ends in the mount path would advertise/mcp/mcp/.ragweld.dtmont.comis appended tomcp.allowed_hosts(loopback entries kept) andhttps://ragweld.dtmont.comtomcp.allowed_origins; without the host entry the transport answers421to clients using the advertised URL.- The Caddy site for the advertised origin routes
/mcp*to the API (inside forward auth); the secondaryme.ragweld.comhost deliberately does not, because that host is not inmcp.allowed_hostsand the route could not work there.
Both the rendered config and the Caddy path set are pinned by tests/unit/test_proxmox_deployment_contract.py, so opening or moving a public MCP route is a visible, reviewed edit.
External link origin refresh
Traces persist across deployments, but the dashboards they deep-link to move. TraceStore keeps a mapping from link kind to the current deployment origin and rewrites matching links whenever traces are read:
| Link kind | Origin taken from |
|---|---|
grafana | ui.grafana_base_url |
tempo | ui.grafana_base_url (Tempo traces open in Grafana Explore) |
langfuse | tracing.langfuse_public_base_url |
Behavior:
- Applied when the trace store initializes (including when traces are reloaded from the persisted store) and on every latest-trace lookup.
- Only the origin (scheme + host) is rewritten; path, query, and fragment are preserved.
- Links of kind
customare never rewritten. - An empty base URL means "no rewrite" for that kind — the component reports as unconfigured rather than guessing.
If a deep link lands on the wrong host
Update ui.grafana_base_url or tracing.langfuse_public_base_url in the global config. Old traces pick up the new origin on the next read — no trace-store rebuild required.
Operator checklist
- Set
ui.runtime_mode=productionin the global config before creating corpora. - After changing a production-scoped value (for example, the model alias), confirm per-corpus configs picked it up:
GET /api/configscoped per corpus, or open the corpus's Admin panel. - Verify Grafana/Langfuse deep links from an old trace open the current deployment.
- Keep corpus-scoped tuning off the production-scoped list so operators retain per-corpus control where it matters.