Operator Handbook - Day-2 Operations¶
The deployment runbook is the document of record for installing, backing up, upgrading, and recovering a Telha instance. This handbook covers the recurring operator work on a running system: keys, retention, stewarding, cost caps, release gating, and the failure modes you will actually meet. It cross-links the runbook rather than duplicating it.
General CLI rule: most telha subcommands open the data dir directly (RocksDB exclusive lock) - stop the server first, or use a one-shot container on the same volume (runbook §3.2). The exceptions are pure token minting (api-key worker-token|mcp-token|clarify-token|admin-token|connector-token) and backup verify|restore, which never touch a live data dir.
Key inventory and rotation¶
The full inventory table lives in runbook §5. Rotation flows:
API keys (tenant credentials, x-api-key)¶
Hashes live in {data_dir}/api_keys.json; the server reads the file at boot.
telha api-key create --tenant <uuid> --org <uuid> --data-dir <dir>
# optionally bind a clarification-answering principal:
# --person <PERSON uuid> --role viewer|editor|steward
Rotate: mint the new key (server stopped), distribute it, remove the old entry from api_keys.json, restart. Keys without --person cannot answer clarifications at all.
gRPC token key (grpc.token_key)¶
The 32-byte HMAC key behind every internal audience (app, worker, mcp, clarify, admin, connector). Generate with openssl rand -hex 32 and make sure the 64-hex value contains at least one letter: the env config layer parses an all-digit value as an integer and rejects it (runbook §2.3 gotcha). Rotation is coordinated: update TELHA_GRPC__TOKEN_KEY on core and on every token-minting service (app layer, clarify-bot, worker token-mint commands) and restart both sides. Tokens themselves are self-expiring (TTL 300 s) and re-minted on an interval, so there is nothing else to revoke.
Service tokens (minted, not stored)¶
telha api-key worker-token --worker-id docling-1 # audience "worker"
telha api-key mcp-token --tenant <uuid> --org <uuid> # audience "mcp"
telha api-key clarify-token --worker-id clarify-bot # audience "clarify" (poll)
telha api-key clarify-token --tenant <uuid> --org <uuid> # audience "clarify" (answer)
telha api-key admin-token --tenant <uuid> --org <uuid> # audience "admin"
telha api-key connector-token --connector sharepoint --tenant <uuid> --org <uuid>
All require grpc.token_key in the resolved config and expire in 300 s. Audiences are disjoint by construction: a worker token cannot answer clarifications, a connector token leases only its tenant's sync: jobs and deletes only its own namespace, and API keys never open /admin - the admin surface is operator-scoped by token audience, not by key privilege.
Field-crypto KEK and DEK rotation (app layer)¶
Envelope encryption for classified fields lives entirely in the app layer (app-layer/packages/enterprise); core stores opaque {"$bytes": ...} envelopes and is oblivious.
- KEK:
FIELD_CRYPTO_KEKenv (32 bytes, base64 or hex) on the app-layer service only - never on the core host. Wrapped DEKs live in the app-layer PostgreSQL (which is why two-surface backups matter, runbook §6). - DEK rotation runs through the enterprise rotation queue (BullMQ, one job per tenant):
rotate(tenant)mints a new active DEK and marks the prior one retiring (decrypt-only); the sweep then re-encrypts FORWARD by writing new record versions under the new key id. Historical versions keep their old ciphertext and stay readable until an explicit destruction event. The sweep result reportsremainingOldKeyRefs; it should be 0 - non-zero means a page raced a concurrent writer, so re-run the sweep before considering old-key destruction.
LLM and embedding keys¶
Env vars named by llm.api_key_env (default TELHA_LLM_API_KEY) and embedding.api_key_env (default TELHA_EMBEDDING_API_KEY), or llm.secrets_file (0600 enforced on unix; warning-only on Windows). Update the env and restart; generation credentials are checked at boot and fail fast when llm.provider is set without one.
Backups and the restore drill¶
Schedule, stamp anatomy, partial-stamp semantics, and the quarterly restore drill are in runbook §6. The operator's checklist:
[backup] enabled = truewithdirset; the first tick fires at boot; stamps are verified before anything is pruned.- Watch for
partial: truestamps - noDATABASE_URLat backup time means wrapped DEKs / audit / role maps were NOT captured. - Run the §6.2 restore drill quarterly, not during an incident. A present erasure ledger makes restore refuse by design (until PR-081 lands ledger replay); do not delete the ledger to force it.
- After a restore, rebuild skipped open vector buckets:
telha vector rebuild --tenant <uuid> --org <uuid> --model <name>.
Retention, legal holds, dry runs¶
Nothing ever expires without an explicit policy: the default for every selector is keep_forever, so an enabled ticker on a policy-less tenant is a no-op. Enable the in-process ticker with [retention] enabled = true (requires retention.archive_dir or backup.dir to default under; interval_hours default 24, max_entities_per_run default 10000 - runs are bounded and resume next interval).
Doctrine, deliberately not configurable: hold beats policy, erasure beats hold, archive is verified before any delete, every delete is ledgered, whole-entity granularity.
# policies are per selector: exactly one of --label / --source-kind
telha retention policy set --tenant <uuid> --org <uuid> \
--label CONTRACT_DOCUMENT --action archive_then_delete \
--archive-days 365 --delete-days 730 --data-dir <dir>
# actions: keep_forever | archive_after | delete_after | archive_then_delete
telha retention policy ls --tenant <uuid> --org <uuid> --data-dir <dir>
telha retention policy rm --tenant <uuid> --org <uuid> --label CONTRACT_DOCUMENT --data-dir <dir>
# ALWAYS preview first: the normative dry-run; never writes.
# Per selector: eligible entities, hold-blocked (with hold refs),
# estimated archive bytes, delete count, remaining backlog.
telha retention preview --tenant <uuid> --org <uuid> --data-dir <dir>
# one bounded pass: archive -> verify (hard gate) -> hold-rechecked
# deletes -> orphan sweep; prints the JSON report + ledger path
telha retention run --tenant <uuid> --org <uuid> --data-dir <dir>
# legal holds suspend archive AND delete for matching entities
telha retention hold set --tenant <uuid> --org <uuid> \
--label CONTRACT_DOCUMENT --reason "Smith v. ACME discovery" --data-dir <dir>
# scope: --label | --source-kind | --id <logical uuid> | none = whole tenant
telha retention hold ls --tenant <uuid> --org <uuid> --data-dir <dir>
telha retention hold lift <hold-uuid> --tenant <uuid> --org <uuid> --data-dir <dir>
# lifting warns: matching entities become actionable on the next run
# archive slice artifacts
telha retention archives ls --data-dir <dir>
telha retention archives inspect --from <stamp-dir>
telha retention archives sideload --from <stamp-dir> --data-dir <FRESH empty dir>
# side-load is inspection-only; never merge back into the live tenant
Every delete lands in the erasure ledger ({data_dir}/erasure-ledger.jsonl), which then gates backup restores.
The /admin/retention/* REST mirror¶
A live server exposes the same surface without a stop, behind the "admin"-audience bearer token (telha api-key admin-token --tenant --org; the operator names the tenant being administered at mint time).
ADMIN=$(telha api-key admin-token --tenant <uuid> --org <uuid> --config <toml>)
H="Authorization: Bearer $ADMIN"
BASE=http://127.0.0.1:7625
curl -fsS $BASE/admin/retention/policies -H "$H" # list
curl -fsS -X POST $BASE/admin/retention/policies -H "$H" \
-H "Content-Type: application/json" \
-d '{"label":"CONTRACT_DOCUMENT","action":"archive_then_delete","archiveDays":365,"deleteDays":730,"actor":"jp"}'
curl -fsS -X POST $BASE/admin/retention/policies/remove -H "$H" \
-d '{"label":"CONTRACT_DOCUMENT"}'
curl -fsS $BASE/admin/retention/holds -H "$H" # list
curl -fsS -X POST $BASE/admin/retention/holds -H "$H" \
-d '{"label":"CONTRACT_DOCUMENT","reason":"Smith v. ACME","actor":"jp"}'
curl -fsS -X POST $BASE/admin/retention/holds/<hold-uuid>/lift -H "$H"
curl -fsS $BASE/admin/retention/preview -H "$H" # the dry-run
Bodies are camelCase (sourceKind, logicalId, archiveDays, deleteDays). Errors use the standard envelope with code RETENTION_INVALID. There is no run endpoint on the REST mirror; the pass itself runs via CLI or the ticker.
Clarification steward duties¶
Clarifications that need a human referee surface on the CLI (opens the data dir directly - stop the server, or work a restored copy):
# the review queue: open/partially-confirmed at high/critical severity,
# conflicted, expired, dead-lettered; most urgent first
telha clarify queue --tenant <uuid> --org <uuid> --data-dir <dir>
# everything, optionally by state:
# open|attested|partially_confirmed|answered|conflicted|expired|dead_lettered
telha clarify ls --tenant <uuid> --org <uuid> --state conflicted --data-dir <dir>
# one clarification: candidates, responses, quorum, events, plus the
# delivery job trail (attempt N -> ranked candidate N; final = steward)
telha clarify show <id> --tenant <uuid> --org <uuid> --data-dir <dir>
# record an answer as a declared person (channel recorded as "cli")
telha clarify answer <id> --tenant <uuid> --org <uuid> \
--responder <PERSON uuid> --index 0 --data-dir <dir>
# free-form instead of a candidate: --valid-from <µs|RFC3339> [--valid-to ...] [--text "..."]
# steward override on a conflicted clarification (explicit, never implicit)
telha clarify review <id> --tenant <uuid> --org <uuid> \
--responder <steward PERSON uuid> --index 1 --data-dir <dir>
# GDPR: tombstone a person's attribution (asked entries, responder ids,
# message refs, channel metadata) across all clarifications in scope;
# confirmation evidence and corrected facts survive
telha clarify erase-attribution --person <uuid> --tenant <uuid> --org <uuid> --data-dir <dir>
The CLI is the local-operator trust boundary: --responder is a declaration, recorded as channel "cli". Remote surfaces (REST, chat) have the responder-assertion rule instead. Delivery-side duties (who got asked, why someone was skipped) are in the clarify-bot runbook.
LLM cost caps and the reservation model¶
Per-tenant token caps live in core config (operator-only; never tenant self-service):
[llm.tenant_caps]
daily_tokens = 2000000 # input+output per daily window; 0 = unlimited
monthly_tokens = 40000000
How enforcement works (worth understanding before sizing caps): every /v1/generate request reserves its worst case up front (estimated prompt tokens + maxTokens) against the window, then settles to the provider-reported actual usage after the stream ends - including failed streams (honest accounting). A request that cannot reserve gets 429 TENANT_BUDGET_EXCEEDED with the window reset time in µs. Consequence: many concurrent requests with large maxTokens can exhaust a cap on reservations alone; keep llm.max_output_tokens (per-request ceiling, default 4096) sane. The model allowlist (llm.models_allowed plus llm.model_default) bounds which models spend at all.
Eval gate as a release check¶
The eval/ crate (binary telha-eval) sweeps a pinned dataset through a candidate binary against a deterministic mock provider and gates the report; the exit-code contract is CI-ready (0 pass / 1 fail).
cd eval
cargo run --release -- run --dataset datasets/v1 \
--spawn ../core-engine/target/release/telha.exe --report-out eval-report.json
# (or --url http://host:7625 --api-key ... against a running server)
cargo run --release -- gate --report eval-report.json \
--baseline last-release-report.json # arms the recall-regression gate
# gate: PASS (exit 0) / gate: FAIL (exit 1)
Floors are ratchet-only (--floors overrides below the defaults are refused). Run the gate against every release candidate; the build doc's Phase 3c exit criteria make it the release gate. On this Windows dev box use eval/run-eval-build.cmd (the MSYS2 toolchain wrapper).
Troubleshooting¶
The runbook's §9 table is the first stop. The classics, plus operator-level notes:
| Symptom | Cause / fix |
|---|---|
telha.exe exits instantly, exit 127 / STATUS_DLL_NOT_FOUND | MinGW DLLs missing. Use the Windows zip (ships the DLL closure) or put C:\msys64\mingw64\bin on PATH. |
gRPC/MCP silently disabled, or invalid configuration: grpc.token_key | Key not 64 hex, or all digits: the figment env layer parses an all-digit TELHA_GRPC__TOKEN_KEY as an integer and rejects it. Regenerate until it contains a letter. |
| MCP listener not up on 7627 | MCP needs BOTH mcp.enabled = true (the default) AND grpc.token_key set; sessions authenticate with mcp-audience tokens signed by that key. |
/v1/generate returns 501 | llm.provider unset, or the credential env was missing at boot (the endpoint disables itself and the boot log says so). |
429 TENANT_BUDGET_EXCEEDED earlier than expected | Reservation model: concurrent requests reserve prompt + maxTokens up front. Lower maxTokens/llm.max_output_tokens or raise the cap. |
CLI: failed to open data dir ... lock | The server holds the exclusive lock. Stop it for data-dir commands (or one-shot container per runbook §3.2). |
Ingest job stuck pending | No worker leasing that format. Check the worker process, TELHA_GRPC_TARGET, and that its token audience matches the kind (worker for ingest:*, connector for sync:*). |
| Connector seed refused: "chain step is already pending or leased" | One live chain per source, by design. telha connector status <source> and let it drain or retry the dead step (telha jobs retry). |
| Restore refuses: erasure ledger present | By design until PR-081 ledger replay. Restore-with-replay is not shipped; do not delete the ledger. |
See also¶
- Deployment runbook - install, backup, upgrade, recover
- Quickstart
- Connectors · Clarify bot
- User guide: Administration