Etki — Operations Runbook (Phase 4)¶
Deployment (Docker)¶
docker compose up -d --build # app (8000) + Postgres (5433)
curl -s localhost:8000/health # {"status":"ok"}
open http://localhost:8000/ # live assistant UI
docker compose down # stop (data persists in the pgdata volume)
- The container ships no JVM → the code graph uses the
astadapter (config/connectors.docker.yaml). The index is built automatically on first request. - Fill in
.envfirst (cp .env.example .env):POSTGRES_PASSWORD,ETKI_SESSION_SECRET(long random),ETKI_ADMIN_USER/ETKI_ADMIN_PASSWORD(first PMO user). compose won't start without these. - Env vars (all
ETKI_-prefixed):DB_URL,CONNECTORS_PATH,SESSION_SECRET,ADMIN_USER/ADMIN_PASSWORD,MAX_UPLOAD_MB,DEMO_MODE,IN_SCOPE_THRESHOLD,GRAY_THRESHOLD,DEFAULT_LANGUAGE(default UI language tr|en|de),FORCE_CODE_ENGINE(ast|joern|graphify),LLM_PROVIDER(anthropic),ANTHROPIC_API_KEY/ANTHROPIC_MODEL,COOKIE_SECURE(settrueon any TLS deployment — Secure session cookie + HSTS). - Health probes:
/health(liveness) and/ready(is the DB + engine ready; the compose healthcheck polls this). The Postgres port is not published to the host. workers=1is MANDATORY. The per-project triage engines (and their living baselines) are process-local; running uvicorn/gunicorn with more than one worker forks divergent baseline copies (a CR approved in worker A is invisible in worker B). The app fails hard at startup ifWEB_CONCURRENCY/UVICORN_WORKERS> 1. Scale by giving the single worker more CPU, not by adding workers. (Since baselines are now rehydrated from the DB at startup, making the engine stateless and lifting this limit is a known future work item.)
Database schema & migrations (Alembic)¶
The schema is now managed by Alembic (for production schema evolution; create_all is only a test/sqlite convenience):
ETKI_DB_URL=postgresql+psycopg://... uv run alembic upgrade head # create/update the schema
uv run alembic revision --autogenerate -m "description" # new migration when models change
ETKI_ADMIN_* env (when there are no users) or
with uv run python -m etki.persistence create-user <name> pmo. After that, day-to-day user
administration lives in the UI: Settings → Users (pmo-only) — create/delete users, change
roles, edit per-user project grants, reset passwords. Guard rails: you cannot delete your own
account, and the last pmo user can be neither deleted nor demoted. A password reset rotates
the session-binding token, so the target's live sessions drop immediately.
Code index (Joern) refresh — scheduled¶
The live Joern index (CPG) for production-grade impact analysis is produced on the host machine / CI; it can be mounted into the container as .etki/index.json.
uv run python -m etki.indexing # all projects (or: ... indexing <project_id>)
The CLI runs the exact same path as the UI's reindex (context.index_project):
doc_root composite documents, multi-repo merged graph, and a DB-baseline reconcile —
approved CRs survive a re-index. A document that fails to parse is skipped with a
warning; it never takes the whole project's indexing down.
Recommended cron (nightly full re-index, more often for high-churn modules):
0 2 * * * cd /opt/etki && uv run python -m etki.indexing >> /var/log/etki-index.log 2>&1
If the index is stale it shows in the decision's freshness stamp (
index_freshness) and as a yellow/red badge on the project screen; incremental re-indexing is future work.
In-app alternative (single-container/pilot deployments, both OFF by default):
ETKI_REINDEX_INTERVAL_HOURS=24 # background full re-index of every project + context rebuild
ETKI_POOL_REFRESH_MINUTES=15 # lightweight effort-pool recompute from the work-item
# provider (no code/document re-index; engines see the
# new consumed-by-category totals in place)
ETKI_INTAKE_POLL_MINUTES=5 # poll every project's request-intake provider and triage
# new requests into PENDING cases (OFF by default)
ETKI_INTAKE_BATCH_LIMIT=20 # max requests pulled per project per tick
ETKI_PUBLIC_BASE_URL=https://etki.example.com # only used to embed a case link in write-backs
Request intake / write-back. With a request_intake connector configured
(UI: Dosyalar → Talep Kanalı, or connectors.request_intake in
projects.yaml), Etki polls the tracker and triages each new request into a
PENDING case. Cron alternative to the in-app loop:
uv run python -m etki.intake # one poll of every project (cron-friendly)
The decision is written back to the source per the project's intake_response_mode
(on_decision = after the PMO decides, the default and the copilot invariant;
on_triage = an interim recommendation comment; both). Write-back is
best-effort: a channel failure is recorded in the case's audit chain
(RESPONSE_POSTED ok=false) and degrades the adapter's health badge, but never
blocks an approval. Dedup is a deterministic case id (REQ-{project}-{adapter}-{key})
plus a poll cursor in .etki/intake-cursors.json (losing it is harmless — the
id dedup re-absorbs re-seen requests).
Cron remains the recommended production path (observable, survives restarts mid-cycle);
the in-app loops exist so a plain docker compose up deployment can stay fresh without
host-level scheduling. Note: work-item similar-job evidence is always fetched live at
triage time regardless — these intervals only affect the code/document index and the
effort-pool totals.
graphify engine (optional, multi-language, no JVM)¶
For non-Python repos (JS/TS, Go, Java, …) the graphify engine (tree-sitter based, PyPI package graphifyy) produces the same normalized code index without a JVM — so it also works inside the container:
uv sync --extra graphify # installs the graphify CLI (graphifyy)
ETKI_FORCE_CODE_ENGINE=graphify uv run python -m etki.indexing
Config: code_repo: { adapter: graphify, options: { src_root: ..., export_dir: ..., refresh: true } }. The graph is written to export_dir (default <src_root>/../graphify-out) so cloned customer repos stay pristine; the build is deterministic (graphify update --no-cluster, no LLM key needed). Known limits: per-file LOC is derived from line counts and control-structure counts are unavailable (cyclomatic complexity falls back to the function count). Symbol-level API capture (package_apis, the dependency-effort surface) needs graphify-mcp installed (pip install "graphify-mcp[treesitter] @ git+https://github.com/yasinyaman/graphify-mcp" until it reaches PyPI); without it the surface stays empty and dependency-effort estimates carry the wider unknown-surface band.
Cross-encoder reranker (optional assist, opt-in)¶
A TEI-compatible /rerank endpoint (ETKI_RERANK_BASE_URL, raw logits) feeds the
v4b matching lane — measured +12 pts on EtkiBench with no LLM. Where the official
TEI image is unavailable (arm64/GB10 boxes), scripts/rerank_server.py serves the
same contract:
pip install fastapi uvicorn sentence-transformers
python scripts/rerank_server.py --model BAAI/bge-reranker-v2-m3 --port 8021
# app side: ETKI_RERANK_BASE_URL=http://<host>:8021
Keep it opt-in: on the frozen golden set the lane currently costs adversarial
cases (see the EtkiBench scoreboard); leave ETKI_RERANK_BASE_URL unset unless
you accept that trade-off. CI never sets it (the gate stays deterministic).
Decision wiki (file-based decision memory)¶
Every triage decision is automatically projected into a per-project markdown wiki
(.etki/wiki-{project}/, path template via ETKI_WIKI_DIR; empty string disables).
The database stays the single source of truth — the wiki is a regenerable
projection, so it never needs its own backup:
uv run python -m etki.wiki search "SSO entegrasyonu" --project demo # find precedents
uv run python -m etki.wiki show DEC-20260709-req-demo-1a2b3c4d --project demo
uv run python -m etki.wiki rebuild # regenerate ALL projects from the DB
Do not hand-edit wiki files (rebuild overwrites them). Deleting a project keeps its
wiki (readable projection of the preserved audit history). If the decision files contain
personal data, the directory falls under the same residency/access rules as the DB —
see KVKK.md. Search prefers rg (ripgrep) when installed and falls back to a pure-Python
scan.
The wiki also carries the derived HITL memory (regenerated on every PMO decision and
by rebuild): a PMO override promotes the case to precedents/PRE-*.md (boundary-case
memory), and conflicting resolved decisions on the same contract clause are collected in
disputed.md — read it before ruling on that clause again. Counters for both appear on
the project's Raporlar screen. Concepts and retrieval details: see
Decision memory.
Backup & restore (Postgres)¶
docker compose exec db pg_dump -U postgres etki > backup_$(date +%F).sql # date computed on the host
cat backup.sql | docker compose exec -T db psql -U postgres etki # restore
audit_events) and versioned baseline (baseline_versions) are backed up — so every decision stays reconstructable for a contractual dispute.
Capability negotiation & graceful degradation¶
Each adapter declares its capabilities (supports_webhooks/realtime/effort_tracking/incremental_diff). The system degrades accordingly:
- no webhooks → periodic polling; no incremental diff → full re-index.
- For a composite document source, a capability is supported only if all children support it (most conservative).
Adapter health (degraded-adapter visibility)¶
A work-items or documents adapter that fails to build (bad options, a plugin
that failed to load, unreachable config) never takes the project down: the context
falls back to an empty fake and records an AdapterHealth entry. Degraded adapters
surface as red badges on the project's Özet screen and the Dosyalar work-items
card (tooltip = the recorded error), and the Raporlar effort-pool figures carry a
"pool may be stale" note when the provider failed at refresh time. There is no
auto-heal: fix the config/plugin, then re-save the work-items settings or trigger
a reindex — the context rebuild clears the badge. A project deliberately
configured with the fake adapter is not "degraded"; the badge only appears when
a real adapter fell back.
Pluggability (vendor swap)¶
Moving to a new organization = a config change (connectors.*.yaml); core code does not change:
work_items: { adapter: jira|gitlab|file, ... }
code_repo: { adapter: joern|ast|graphify, ... }
documents: { adapter: filesystem|composite, ... }
tests/integration/test_composite.py — the documents filesystem → composite swap yields the same decision.
Plugins (install, lockfile, policy)¶
Third-party adapters ship as plugin packages against etki-api (entry-point
group etki.adapters). Operational rules:
- Policy is an env-only admin lock:
ETKI_PLUGIN_POLICY=verified_only(default — installs blocked, non-editable unverified distributions don't even load) |allow_git|allow_local. Deliberately NOT a UI/Settings value;.etki/llm.jsoncannot touch it. - Install (operator CLI, capability confirmation from the static manifest —
plugin code is never executed for the prompt):
Branches are rejected; tags resolve to the full commit SHA and the SHA is what installs and locks. Wheel installs verify SHA-256 BEFORE anything runs.
ETKI_PLUGIN_POLICY=allow_git uv run python -m etki.plugin install git+https://…@v1.2.0 ETKI_PLUGIN_POLICY=allow_local uv run python -m etki.plugin install ./acme.whl --sha256 <hash> etki-plugins.lock(TOML, repo-committable) records source/commit/hash/ capabilities per plugin.python -m etki.plugin syncreproduces the exact state on a new machine (local wheels re-hash-verified);remove <dist>uninstalls + drops the entry;list [--json]is the KVKK inventory feed.- Verified marketplace (signed index): the official index lives at
https://yasinyaman.github.io/etki-plugins/index.json(source repo yasinyaman/etki-plugins; acceptance process in itsPROCESS.md). Under the defaultverified_onlypolicy the ONLY install path is an index-verified one:uv run python -m etki.plugin search acme uv run python -m etki.plugin install etki-plugin-acme--index <url|dir>is optional: both commands resolve the same trust root as the UI —ETKI_PLUGIN_INDEX_URLif set, else the official index — and print the source they used. Pass it explicitly to target a mirror directory or a non-default index. A weekly scheduled workflow (marketplace-live-verify.yml) mirrors the live index with REAL signature verification and does a verified install — the end-to-end canary for the whole chain. A REMOTE index requires a valid sigstore signature (keyless, GitHub OIDC; the verification tooling ships behind the optional extraetki[plugins]— the base/air-gapped install never pulls it). The signer identity is pinned to the index repo's release workflow, overridable for forks/self-hosted marketplaces viaETKI_PLUGIN_INDEX_IDENTITY/ETKI_PLUGIN_INDEX_ISSUER(env-only, like the policy). The artifact's SHA-256 must match the index BEFORE any install subprocess; success writesverified = trueto the lockfile — which is also what lets the plugin load at runtime underverified_only. - Air-gapped flow: mirror on the ONLINE side —
python -m etki.plugin mirror <index-url> <dir>(the signature is verified at mirror time, every artifact hash-checked, recorded inmirror-manifest.json) — carry the directory inside, theninstall <name> --index <dir>offline. Inside the mirror SHA-256 stays mandatory and the signature is optional (a missing sigstore toolchain degrades to a warning; a FAILED verification is always fatal). - UI — Settings → Plugins (
/ayarlar/eklentiler, pmo-only): every installed plugin with status (active/failed/incompatible/blocked/disabled+ error text), install source (from the lockfile),api_compatand a verified badge; the policy is displayed read-only. The single UI mutation is enable/disable (persisted to.etki/plugins.json; the toggle rebuilds the registry/context) — install/remove/policy have no HTTP endpoint by design. - UI — marketplace browse (same screen, lazy-loaded card): a projection
of the signed index — search, per-plugin summary/ports/capability
declaration, the highest version compatible with the installed etki-api,
installed/update badges, and a copyable operator-CLI install command. The
index source is
ETKI_PLUGIN_INDEX_URL(env-only, like the policy — the UI-writable settings file can never redirect the trust root; default = the official index URL; a mirror directory also works for air-gapped setups). Remote fetches verify the sigstore signature; without theetki[plugins]extra the card degrades to a friendly error and the rest of the screen keeps working. Responses are cached in-process for 15 min; Refresh re-fetches. - UI install (optional, default OFF): setting
ETKI_PLUGIN_UI_INSTALL=true(env-only) puts an Install/Update button on the marketplace card for pmo users. It uses ONLY the verified path — the source stays env-pinned, the capability declaration is confirmed before the POST, signature + SHA-256 + lockfileverified=trueare exactly the CLI chain — and git/wheel targets still have no UI route. Each install is recorded in the process log as aplugin_installevent (user, plugin, sha256). Bare-metal/venv only: inside a container the image is immutable — keep installing at build time. - UI — plugin detail & adapter defaults (
/ayarlar/eklentiler/{name}, pmo-only): status/manifest info plus a default-options form per adapter, rendered from the plugin'soptions_model(e.g. the Linearapi_key). Values are stored in.etki/plugin-options.json(0600, atomic — the llm.json posture): secret-named fields are never echoed back and an empty submit keeps the stored value;env:VARreferences are stored unresolved. At build time the defaults merge under each project's connector options — the project value always wins — so a key is entered once and reused across projects. - Containers install plugins at IMAGE BUILD TIME from the lockfile — see
Dockerfile.plugins. Runtimesyncinside a container does not survive a restart (immutable images); it is for bare-metal/venv deployments. - Private git hosts: authenticate via a git credential helper — never embed tokens in the URL (it would be recorded in the lockfile).
- The public
etki-pluginsindex repo is live (since 2026-07-16): signed index on GitHub Pages, wheels underartifacts/, conformance reports underreports/, acceptance process in itsPROCESS.md. Git-tag and wheel installs remain the channels for plugins not (yet) in the index.
Pilot (shadow mode) & calibration¶
uv run python -m etki.pilot # the system recommends, compared against the PMO reference
ETKI_IN_SCOPE_THRESHOLD); the calibration suggestion is applied with human approval.
Multilingual & per-project LLM profile¶
- Global LLM provider (UI-managed): Settings → AI Assistant (pmo-only) selects off / Anthropic Claude API / OpenAI-compatible (Ollama, vLLM), with model, endpoint, timeout and a connection-test button. Values are persisted to
.etki/llm.json(chmod 600, git-ignored, only whitelisted LLM keys) and take precedence over env vars — "off" in the UI wins even whenETKI_LLM_BASE_URLis set. Changes apply on the next request (engine contexts rebuild; no restart). Keys are never echoed back to the form; an empty field keeps the stored secret, and a checkbox wipes stored keys. In production, prefer env vars for secrets and leave the UI fields empty. - UI language (TR/EN/DE): changed from the top-right via
POST /dil(written to the session); on first visitAccept-Language→ETKI_DEFAULT_LANGUAGE. UI text only; does not change data flow. - Per-project LLM profile: each project gets an output language + a selectable domain/skill profile (
config/domains/*.md) + free-text instructions (Files & Settings screen →POST /projeler/{id}/ayarlar/llm-profili). The index does not change; only the context cache is cleared. An optional pivot translation is enabled per project (translate input → working language → reason → translate back; extra LLM call). A new domain profile = add aconfig/domains/<id>.mdfile.
Security¶
Authentication is a real login (pbkdf2 + signed session cookie); the old self-asserted X-Role header was removed. Roles (RBAC v3): pmo approves and administers, engineer runs triage/analysis, viewer is read-only (all mutating endpoints reject it with 403). Project access is isolated (RBAC v2): a user only sees projects granted in the user_projects table (managed in Settings → Users, or create-user <name> <role> --projects p1,p2); case/evidence endpoints derive the project from the case itself (no IDOR); the top-bar portfolio count is shown to pmo only. The pmo role bypasses grants while ETKI_PMO_GLOBAL=true (single-customer default) — set it to false for multi-customer pilots.
Session hardening: login is rate-limited in-process (5 failures per IP+username within 15 min → 15 min lock; single-worker enforcement makes the in-memory counter authoritative; a restart resets it). The post-login redirect only accepts site-relative paths (open-redirect guard). CSRF is covered twice: same_site=lax cookies plus a middleware that rejects mutating requests browsers stamp Sec-Fetch-Site: cross-site. Sessions are token-bound to the password hash: a password reset or user deletion invalidates live sessions on their next request, and role changes apply immediately (the role is re-read from the DB per request). "Remember me" is enforced server-side — 30 days checked, 8 hours unchecked. For production hardening (reverse proxy + OAuth/SSO + internal network) and KVKK/VERBİS/DPIA, see docs/KVKK.md.
Hardening pass (2026-07 security audit):
- Markdown from LLM output / user text (pre-analysis, chat) renders with raw HTML
disabled — <script>/<img onerror=…> in a saved analysis is escaped, not
executed (stored-XSS fix).
- Responses carry X-Content-Type-Options: nosniff and X-Frame-Options: DENY;
with ETKI_COOKIE_SECURE=true (any TLS deployment) the session cookie gains
Secure and HSTS is sent. Keep it false only for local HTTP dev.
- Uploads are size-capped (ETKI_MAX_UPLOAD_MB, default 20) and docx/xlsx archives
declaring an unsafe expansion are rejected before parsing (zip-bomb guard:
512 MB expanded / 200× ratio ceilings; PDFs get page and extracted-text caps).
- User/config-supplied LLM, embedding and rerank endpoints are refused when they
point at the cloud instance-metadata range (169.254.0.0/16 — SSRF guard,
etki/net_guard.py). Localhost/RFC1918 stay allowed on purpose: local
Ollama/vLLM is the primary use case.
- .etki/llm.json is created 0600 atomically (no window where an API key sits
world-readable).
Productization (notes)¶
- Multi-project: each contract gets its own baseline + index; separated by
contract_id/project_id. - Multi-customer: data separation (schema/DB per customer) + per-customer connector config.
- Licensing: core + adapter packages; vendor adapters can ship as plugins.