cortex REST API

July 30, 2026 · View on GitHub

Canonical reference for the always-on /api/* surface introduced by epic cortex-0p8r (v0.26). All endpoints require a bearer token (Authorization: Bearer $CORTEX_API_TOKEN); 401 is returned for missing/invalid tokens regardless of bind address. Routes marked admin additionally require X-Cortex-Admin-Token: $CORTEX_API_ADMIN_TOKEN; missing or invalid admin tokens return 403.

CLI commands route here by default since v0.26 via CORTEX_USE_HTTP=true written to ~/.cortex/.env by cortex setup repair. See docs/architecture.md for the caller → DB diagram and docs/rollout.md for the manual upgrade playbook.


Endpoint matrix

63 routes total. Scope is read (mounted via axum::routing::get, hits read-side db_permits) or admin (POST + MAINTENANCE_PERMIT single-flight, audited via tracing::warn! before the service call). All responses are JSON; error bodies are {"error": "<message>"} unless a route documents a structured diagnostic body.

Syslog queries (7)

These existed before the epic; bead .1 only added /api/version. They are documented here for completeness because the CLI now routes to them by default.

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/searchreadquery params: query?, hostname?, source_ip?, severity?, app_name?, facility?, process_id?, from?, to?, limit? (u32)SearchLogsResponse { count: usize, logs: [LogEntry] }200, 400, 401, 503, 500YFTS5 search; deny_unknown_fields rejects typos.
GET/api/filterreadquery params: hostname?, source_ip?, source_kind?, tool?, project?, session_id?, container?, docker_host?, stream?, event_action?, severity?, app_name?, facility?, exclude_facility?, process_id?, from?, to?, received_from?, received_to?, limit? (u32)SearchLogsResponse { count: usize, logs: [LogEntry] }200, 400, 401, 503, 500YStructured filter-only retrieval; query and unknown fields are rejected.
GET/api/tailreadquery: hostname?, source_ip?, app_name?, severity_min?, n? (u32)SearchLogsResponse { count: usize, logs: [LogEntry] } (tail order)200, 400, 401, 503, 500Yseverity_min honoured per RFC severity ordering.
GET/api/errorsreadquery: from?, to?, group_by? (app_name only)GetErrorsResponse { summary: [ErrorSummaryEntry] }200, 400, 401, 503, 500YCounts by host (and optional secondary key).
GET/api/hostsread(none)ListHostsResponse { hosts: [HostEntry] }200, 401, 503, 500YInventory of seen hostnames.
GET/api/correlatereadquery: reference_time (REQUIRED, RFC 3339), window_minutes? (u32), severity_min?, hostname?, source_ip?, query?, limit? (u32)CorrelateEventsResponse { reference_time, window_minutes, window_from, window_to, severity_min, total_events, truncated, hosts_count, hosts: [CorrelatedHost] }200, 400, 401, 503, 500YDistinct from /api/sessions/correlate — see disambiguation below.
GET/api/statsread(none)DbStats { total_logs, total_hosts, oldest_log?, newest_log?, logical_db_size_mb, physical_db_size_mb, free_disk_mb?, max_db_size_mb, min_free_disk_mb, write_blocked, phantom_fts_rows? }200, 401, 503, 500YHot path; no PRAGMA per request. phantom_fts_rows is null by default — its COUNT(*) FROM logs_fts scan is skipped to stay fast on large DBs; computed only via the opt-in diagnostic path.
GET/api/versionread(none)VersionInfo { version, git_sha?, schema_version }200, 401YCached at startup — never touches SQLite per request (eng-review #A3). Returns 404 if older server lacks the route (see Versioning policy).

AI session queries (14) — bead .2

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/sessionsreadquery: project?, tool?, hostname?, from?, to?, limit?ListSessionsResponse { count, sessions: [AiSessionEntry] }200, 400, 401, 503, 500YInventory of indexed AI transcripts.
GET/api/sessions/searchreadquery: query (REQUIRED), project?, tool?, from?, to?, limit? (u32)SearchSessionsResponse { total_candidates, candidate_rows, candidate_cap, candidate_window_truncated, truncated, sessions: [SearchedSessionEntry], limit_clamped_to? }200, 400, 401, 503, 500Ylimit clamped at 100 — see Response size caps.
GET/api/sessions/abusereadquery: project?, tool?, from?, to?, limit?, before? (u32), after? (u32), terms? (repeated key: ?terms=foo&terms=bar)AbuseSearchResponse { terms, candidate_rows, candidate_cap, candidate_window_truncated, truncated, matches: [AbuseMatch], limit_clamped_to? }200, 400, 401, 503, 500Ylimit clamped at 500. Decoded via serde_qs::axum::QsQuery, so Vec<String> is supported through repeated terms= keys (the CLI's HttpClient serializes the shared request type the same way).
GET/api/sessions/correlatereadquery: project?, tool?, session_id?, ai_query?, log_query?, hostname?, source_ip?, app_name?, from?, to?, window_minutes? (u32), severity_min?, limit? (u32), events_per_anchor? (u32)AiCorrelateResponse { window_minutes, severity_min, total_anchors, anchor_rows, anchor_limit, anchors_truncated, related_limit_per_anchor, total_related_events, anchors: [AiCorrelationAnchor], events_per_anchor_clamped_to? }200, 400, 401, 503, 500Yevents_per_anchor clamped at 50 — see Response size caps. Correlates AI transcript anchors against system logs.
GET/api/sessions/blocksreadquery: project?, tool?, from?, to?UsageBlocksResponse { total_blocks, truncated, blocks: [UsageBlock] }200, 400, 401, 503, 500YTime-bucketed usage.
GET/api/sessions/contextreadquery: project (REQUIRED, non-empty — handler 400s on empty per eng-review #A7), tool?, limit?ProjectContextResponse { project, tools, sessions, hostnames, first_seen?, last_seen?, event_count, recent_entries_truncated, recent_entries: [LogEntry] }200, 400, 401, 503, 500YEmpty project= rejected with explicit 400.
GET/api/sessions/toolsreadquery: project?, from?, to?ListAiToolsResponse { total_tools, truncated, tools: [AiToolEntry] }200, 400, 401, 503, 500YTool inventory.
GET/api/sessions/projectsreadquery: tool?, from?, to?ListAiProjectsResponse { total_projects, truncated, projects: [AiProjectEntry] }200, 400, 401, 503, 500YProject inventory.
GET/api/sessions/skillsreadquery: skill?, plugin?, tool?, project?, session_id?, hostname?, from?, to?, limit? (u32)ListSkillEventsResponse { total, truncated, events: [SkillEventEntry] }200, 400, 401, 503, 500Ylimit clamped at 500. Extracted AI skill-invocation events (Claude attributionSkill structured fields, Codex <skill><name> transcript tags). Backed by the ai_skill_events table (migration 38); MCP action: skill_events (cortex:read); CLI: cortex sessions skills. caller_ip + query filters audit-logged via tracing::info! before the service call (lighter than the admin-scoped warn! above, since this route is cortex:read not cortex:admin).
GET/api/sessions/skill-incidentsreadquery: skill?, plugin?, tool?, project?, session_id?, hostname?, from?, to?, limit?, window_minutes?, signals? (repeated), min_score? (f64)AiSkillIncidentResponse { incidents, total_incidents, candidate_event_rows, candidate_cap, candidate_window_truncated, truncated }200, 400, 401, 503, 500YDecoded via serde_qs::axum::QsQuery for signals[]=. Grouped skill-usage incident candidates; MCP action: skill_incidents (cortex:read); CLI: cortex sessions skillincidents.
GET/api/sessions/skill-investigatereadquery: incident_id?, skill?, plugin?, tool?, project?, from?, to?, limit?, window_minutes?, correlation_window_minutes?AiSkillInvestigateResponse { evidence, total_incidents, truncated, other_matching_incidents, no_incident_low_severity_summary, no_data, suggested_filters }200, 400, 401, 503, 500YDeterministic (never LLM) skill-usage-incident evidence bundles, skill-first. MCP action: skill_investigate (cortex:read); CLI: cortex sessions skillinvestigate.
GET/api/sessions/mcp-eventsreadquery: tool_name?, mcp_server?, mcp_tool?, tool?, project?, session_id?, hostname?, is_error?, from?, to?, limit?ListMcpEventsResponse { total, truncated, events }200, 400, 401, 503, 500YExtracted MCP tool-call events. MCP action: mcp_events (cortex:read); CLI: cortex sessions mcpevents.
GET/api/sessions/mcp-incidentsreadquery: mcp_server?, mcp_tool?, tool_name?, tool?, project?, session_id?, hostname?, since?, until?, limit?, window_minutes?, signals?, min_score?AiMcpIncidentResponse { incidents, total_incidents, candidate_event_rows, candidate_cap, candidate_window_truncated, truncated }200, 400, 401, 503, 500YGrouped MCP tool-usage incidents. MCP action: mcp_incidents (cortex:read); CLI: cortex sessions mcpincidents.
GET/api/sessions/mcp-investigatereadquery: incident_id?, mcp_server?, mcp_tool?, tool_name?, tool?, project?, since?, until?, limit?, window_minutes?, correlation_window_minutes?AiMcpInvestigateResponse { evidence, total_incidents, truncated, other_matching_incidents, no_incident_low_severity_summary, no_data, suggested_filters }200, 400, 401, 503, 500YDeterministic MCP incident evidence bundles. MCP action: mcp_investigate (cortex:read); CLI: cortex sessions mcpinvestigate.
GET/api/sessions/hooksreadquery: hook_event?, hook_name?, hook_source?, status?, evidence_kind?, tool?, project?, session_id?, hostname?, from?, to?, limit? (u32)ListHookEventsResponse { total, truncated, events: [HookEventEntry] }200, 400, 401, 503, 500Ylimit clamped at 500. Extracted/collected AI hook events: Claude runtime hook-execution attachments (evidence_kind=runtime_transcript) and Claude/Codex config/trust-state inventory (evidence_kind=config_inventory/trusted_hash_state). Backed by the ai_hook_events table (migration 40); MCP action: hook_events (cortex:read); CLI: cortex sessions hookevents.
GET/api/sessions/hook-incidentsreadquery: hook_event?, hook_name?, hook_source?, tool?, project?, session_id?, hostname?, evidence_kind?, from?, to?, limit?, window_minutes?, signals? (repeated), min_score? (f64)AiHookIncidentResponse { incidents, total_incidents, candidate_event_rows, candidate_cap, candidate_window_truncated, truncated }200, 400, 401, 503, 500YDecoded via serde_qs::axum::QsQuery for signals[]=. Grouped hook-usage incident candidates (failures, timeouts, output-parse errors, too-frequent invocation, post-hook user correction). Each incident carries has_runtime_evidence so callers can tell proven execution from config-only evidence. MCP action: hook_incidents (cortex:read).
GET/api/sessions/hook-investigatereadquery: incident_id?, hook_event?, hook_name?, hook_source?, tool?, project?, from?, to?, limit?, window_minutes?, correlation_window_minutes?AiHookInvestigateResponse { evidence, total_incidents, truncated, other_matching_incidents, no_incident_low_severity_summary, no_data, suggested_filters }200, 400, 401, 503, 500YDeterministic (never LLM) hook-usage-incident evidence bundles, hook-first. Each findings.evidence_basis explicitly states whether the bundle is backed by runtime execution or config/trust-state evidence only. MCP action: hook_investigate (cortex:read); CLI: cortex sessions hookinvestigate.

AI diagnostic + admin (4) — bead .3

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/sessions/checkpointsreadquery: errors_only? (bool), missing_only? (bool), limit? (u32). deny_unknown_fields.service-shaped (list of checkpoint records with parse-error metadata)200, 400, 401, 503, 500YDiagnostic inventory of indexed AI transcript checkpoints.
GET/api/sessions/errorsreadquery: limit?. deny_unknown_fields.service-shaped (list of recent transcript parse errors)200, 400, 401, 503, 500YSurfaces parse failures from the AI indexer.
POST/api/sessions/prune-checkpointsadminbody: { "dry_run": bool (REQUIRED), "missing_only"?: bool, "limit"?: u32 }. deny_unknown_fields.service-shaped (count of pruned/would-prune rows)200, 400, 401, 403, 409, 500NRequires the admin header. Single-flight via MAINTENANCE_PERMIT; 409 on contention with /api/db/vacuum or /api/db/checkpoint. dry_run is REQUIRED and explicit — a missing key returns 400 (defends against POST {} mass-delete, eng-review C3). caller_ip audit-logged via tracing::warn! BEFORE the service call.
GET/api/sessions/llm-invocationsadminquery: limit? (i64), since?, action?, status?Vec<LlmInvocationRow> (id, started_at, finished_at, duration_ms, caller_surface, action, provider, model, program, incident_id, ai_tool, ai_project, ai_session_id, evidence_counts_json, prompt_bytes, output_bytes, status, error, metadata_json)200, 400, 401, 403, 500YRequires X-Cortex-Admin-Token, unlike the plain-read routes above — llm_invocations exposes LlmRunner concurrency/rate-limit/circuit-breaker/kill-switch operational state, not just log content. caller_ip audit-logged via tracing::warn! before the service call, matching ack_error/unack_error. Backed by the llm_invocations audit table (migration 37); MCP action: llm_invocations (cortex:admin); CLI: cortex sessions llminvocations.

File-tail admin (1)

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
POST/api/file-tailsadminbody: { "op": "list" | "add" | "remove" | "enable" | "disable" | "status", "id"?: string, "path"?: string, "tag"?: string, "host"?: string, "facility"?: string, "severity"?: string, "start_at_end"?: bool }. op is required; add requires only path; id and tag derive from the file name and omitted host derives the stable synthetic owner file-tail-<id> rather than the server hostname. Remove/enable/disable require id.FileTailResponse { sources: [FileTailSource], statuses: [FileTailStatus] }200, 400, 401, 403, 500mixedRequires normal Authorization: Bearer $CORTEX_API_TOKEN plus X-Cortex-Admin-Token: $CORTEX_API_ADMIN_TOKEN. Manages Cortex-owned local file-tail ingest sources stored in <data-dir>/file-tails.json. add paths must be existing non-symlink regular files under CORTEX_FILE_TAIL_ALLOWED_ROOTS; keep the documented default to /file-tail-root and set an explicit allowlist to opt into broader read-only roots. Set host when the mounted file belongs to a real host that should participate in host filters and correlation. CLI command: cortex ingest filetail ...; MCP action: file_tails (cortex:admin).

DB ops (7) — bead .4

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/db/statusread(none)DbMaintenanceStatus { db_path, page_count, freelist_count, page_size, logical_size_bytes, physical_size_bytes, wal_size_bytes?, shm_size_bytes?, sqlite_page_cache_mb, sqlite_page_cache_kib_per_connection, sqlite_mmap_mb, sqlite_mmap_bytes, heavy_read_concurrency, wal_checkpoint_mb, wal_checkpoint_threshold_bytes, cgroup_memory_status, cgroup_memory_max_bytes?, cgroup_memory_current_bytes?, cgroup_memory_peak_bytes?, auto_vacuum, journal_mode, integrity_ok?, integrity_messages: [String] }200, 401, 503, 500YDIFFERENT shape from /api/stats: a maintenance-focused PRAGMA/cache/WAL/cgroup snapshot. Cgroup diagnostics expose a compact status plus numeric values only; cgroup file paths and read errors are not returned. Bypasses MAINTENANCE_PERMIT.
GET/api/db/integrityreadquery: quick? (bool — default false runs full PRAGMA integrity_check; true runs PRAGMA quick_check). deny_unknown_fields.DbIntegrityResult200, 400, 401, 503, 500YFull check on a multi-GB DB can be slow but does NOT take MAINTENANCE_PERMIT.
POST/api/db/integrity/backgroundadminquery: quick? (bool).DbIntegrityJobStarted { job_id, status }200, 400, 401, 403, 500NRequires the admin header. Starts a server-side background integrity job; poll /api/db/integrity/jobs/{id} for the outcome.
GET/api/db/integrity/jobs/{id}readpath: id (i64).MaintenanceJobStatus200, 401, 404, 503, 500YPolls a background integrity job.
POST/api/db/checkpointadminbody: { "mode": "passive" | "full" | "restart" | "truncate" }. Validated handler-side BEFORE the service call (eng-review #A17).DbCheckpointResult { mode, busy, log_frames, checkpointed_frames, complete }200, 400, 401, 403, 409, 500NRequires the admin header. Single-flight via MAINTENANCE_PERMIT; 409 on contention. caller_ip audit-logged before service call. passive can return complete=false with 200 while active writers prevent a full drain; stricter modes still return 409 when incomplete.
POST/api/db/vacuumadminbody: { "full": bool, "force"?: bool, "incremental_pages"?: u32 }. force is Option<bool> so the size pre-flight only relaxes on explicit "force": true.DbVacuumResult (incl. after_physical_size_bytes)200, 400, 401, 403, 409, 500NRequires the admin header. Single-flight via MAINTENANCE_PERMIT. Size pre-flight: full && !force reads the LIVE page_count * page_size (no cached snapshot) on every call and returns 409 if logical size > 2 GB. caller_ip audit-logged before service call. See "VACUUM on large DBs" below.
POST/api/db/backupadminbody: { "output_path"?: string } or empty body.DbBackupResult { db_path, backup_path, size_bytes }200, 400, 401, 403, 409, 500NRequires the admin header. Runs an online backup inside the server process; output_path is server-side, not a local shell path. Single-flight via MAINTENANCE_PERMIT.

Compose diagnostics (2)

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/compose/statusread(none)ComposeMcpStatus { container_name, ownership, runtime_state, health?, published_ports, diagnostics }200, 401, 500YRedacted read-only projection. If the container cannot run Docker inspection, this still returns 200 with runtime_state="docker_unavailable" and diagnostic code docker_unavailable.
GET/api/compose/doctorread(none)ComposeMcpStatus { container_name, ownership, runtime_state, health?, published_ports, diagnostics }200, 401, 503, 500YStrict readiness check. Healthy Compose-owned deployment returns 200; Docker/ownership/runtime unready states return 503 with the same structured projection, not a generic error envelope.

Investigation graph queries (4)

MethodPathScopeRequestResponse (top-level)Status codesIdempotentNotes
GET/api/graph/entityreadquery: entity_id? or entity_type + key or alias_type + alias_key; payload_budget?GraphEntityLookupResponse { resolved_entity?, candidates, metadata }200, 400, 401, 404, 503, 500YResolves one graph entity by id, canonical key, or alias without rebuilding the projection.
GET/api/graph/aroundreadquery: entity selector, depth? (1 only), limit?, evidence_sample_limit?, payload_budget?GraphAroundResponse { resolved_entity, entities, relationships, evidence, metadata }200, 400, 401, 404, 503, 500YBounded one-hop neighborhood with allowlisted evidence samples.
GET/api/graph/explainreadquery: entity selector, depth? (clamped to 3), beam_width?, max_chains?, evidence_sample_limit?, payload_budget?GraphExplainResponse { resolved_entity, chains, narrative, open_questions, missing_evidence, next_queries, metadata }200, 400, 401, 404, 503, 500YDeterministic evidence-backed explanation; weak evidence becomes open questions, not causal claims.
GET/api/graph/evidencereadquery: evidence_id (REQUIRED, minimum 1), payload_budget?GraphEvidenceLookupResponse { evidence, relationship, src_entity, dst_entity, source_log_summary?, missing_source_reason?, metadata }200, 400, 401, 404, 503, 500YProof lookup for one evidence row. Source summaries are redacted/truncated and exclude raw frames and raw metadata.

Total: 63 routes (current src/api.rs router surface, including syslog, surface-parity, AI, graph, compose, notification, error-ack, and DB routes; includes the 3 hook routes above, added alongside the ai_hook_events subsystem).

cortex assess skill / cortex assess abuse / cortex assess hooks are CLI-only in this phase (no REST route) — see README "Skill, abuse, and hook assessment". LLM assessment spawns Gemini on the local host via LlmRunner and is never exposed over MCP or REST; this mirrors the existing cortex sessions assess (no /api/sessions/assess route either).


Versioning policy

/api/version returns { version, git_sha?, schema_version } cached at startup. Two version-skew rules:

  • 404 on a known route name = "endpoint not on this server — upgrade." Newer CLI calling an older container will see Axum's default 404 for routes added in later beads (/api/db/vacuum, /api/sessions/prune-checkpoints, etc.). The CLI maps that to a user-facing "upgrade the container or unset CORTEX_USE_HTTP to use direct DB" message.
  • /api/capabilities is deferred. No structured capability map ships in v0.26. The version + schema_version pair plus 404 semantics cover the current single-deployer use case; a capabilities endpoint can be added without breaking existing clients when needed.

Performance

The HTTP transport adds roughly ~15-30 ms of round-trip overhead per call on loopback on top of the underlying service work (request parse, auth check, response serialise, TCP send/recv). For one-off commands this is invisible. For scripted loops it dominates fast queries.

Operators can measure the overhead on their own host by running the same query 100× both ways. Direct (default-pre-v0.26 path):

# direct SQLite — bypasses /api/*
unset CORTEX_USE_HTTP
time for i in $(seq 1 100); do cortex hosts > /dev/null; done

HTTP:

# REST transport — same call, with auth + transport overhead
export CORTEX_USE_HTTP=true
# CORTEX_URL + CORTEX_API_TOKEN must already be set in env
time for i in $(seq 1 100); do cortex hosts > /dev/null; done

The difference between the two real times divided by 100 is the per-call transport cost. Expect ~1.5–3.0 s of additional wall time over 100 invocations on the same host (i.e. 15–30 ms per call).

For batch loops that don't need cross-host coordination (e.g. iterating over local hostnames inside a maintenance script on the deploy host), operators can opt out of HTTP transport for the duration of the script:

( unset CORTEX_USE_HTTP; \
  for h in $(cortex hosts --json | jq -r '.hosts[].hostname'); do \
    cortex tail --host "$h" --n 50; \
  done )

Inside the subshell CORTEX_USE_HTTP is unset so each cortex call goes straight to SQLite via RuntimeCore::load_query_only. The parent shell environment is unaffected.


Security / threat model

  • Bearer tokens in env. CORTEX_API_TOKEN is passed via the container/CLI environment. On a Linux host any process running as the same user can read /proc/<pid>/environ and recover the token. The homelab acceptance is: the host is single-owner; nothing untrusted runs as the same user as the cortex container or the CLI. Do not share container-host shell access with untrusted users — this is the documented model, not a future bug to fix.
  • Token storage. setup repair writes ~/.cortex/.env with mode 0600 (-rw-------). Verify with ls -l ~/.cortex/.env before reporting a "leaked token" — a 0644 file is a configuration error, not a deliberate design.
  • TLS termination is external. The /api/* surface speaks plain HTTP. Production deployments terminate TLS at SWAG (or any reverse proxy) and forward to the container over the internal bridge. The API itself emits a startup warning when bound to a non-loopback address while CORTEX_PUBLIC_URL does not begin with https://, so a misconfiguration is loud at first boot rather than silent in production.
  • API auth model. build_auth_layer accepts the normal CORTEX_API_TOKEN; AuthPolicy::Mounted is enforced for /api/* regardless of bind address (eng-review C1). Routes marked admin require both the normal bearer and the X-Cortex-Admin-Token header; this covers file-tail management plus maintenance mutations such as session checkpoint pruning, DB background integrity, checkpoint, vacuum, and backup.

/api/correlate vs /api/sessions/correlate

These are distinct operations and the names trip people up. Quick disambiguation:

Aspect/api/correlate/api/sessions/correlate
Service methodcorrelate_eventscorrelate_ai_logs
Anchored onA caller-supplied reference_time (RFC 3339)AI transcript anchors matched by ai_query/session_id/etc.
ReturnsHosts within a time window around the anchor, grouped by hostnameAI anchor events plus correlated system logs per anchor
Use case"What was happening across hosts around 03:17 UTC?""What syslog activity correlates with this AI session?"
Capped atSingle time window, single anchorMultiple anchors; events_per_anchor capped at 50

The router in src/api.rs::router() groups them under // --- syslog queries --- and // --- ai session queries --- block comments to keep maintainers oriented (eng-review pattern note).


Response size caps

REST handlers clamp some caller-supplied limits on the way IN and mark the clamp in the response. The caps are constants in src/api.rs:

EndpointFieldCapSurfaced as
/api/sessions/searchlimit500 (REST_AI_LIMIT_CAP)limit_clamped_to: 500 + truncated: true
/api/sessions/abuselimit500 (REST_AI_LIMIT_CAP)limit_clamped_to: 500 + truncated: true
/api/sessions/correlateevents_per_anchor50 (REST_CORRELATE_EVENTS_PER_ANCHOR_CAP)events_per_anchor_clamped_to: 50

The MCP surface uses the service-layer clamps only; these REST caps are the second line of defence so a misbehaving client can't tank the container with a 100000-row request.


VACUUM on large DBs

POST /api/db/vacuum enforces a live 2 GB size pre-flight when {"full": true, "force": <not true>}db_logical_size_bytes() reads page_count * page_size fresh on every call so a long-running container cannot defeat the guard with a stale startup snapshot. Two operational caveats:

  • The default-reverse-proxy HTTP timeout (Axum upstream / SWAG) is on the order of minutes. A VACUUM on a database larger than ~10 GB can exceed it and the client will see a 504 from the proxy even though the VACUUM is still running on the server. The server-side single-flight permit (MAINTENANCE_PERMIT) is still held, so retries will 409 until the original VACUUM commits.

  • Workaround for large DBs: drop HTTP transport for this one call and run the vacuum through the service layer directly:

    ( unset CORTEX_USE_HTTP && cortex db vacuum --full --force )
    

    The subshell scoping keeps CORTEX_USE_HTTP set for everything else. Pair with a downtime/ingest-quiesce window since full blocks writers regardless of transport.


Local-only commands

A handful of CLI subcommands intentionally stay on the direct-SQLite or host-shell path even with CORTEX_USE_HTTP=true. The per-command reasons (no taxonomy):

  • cortex sessions watch — long-running daemon. HTTP would require a streaming bidirectional surface; the daemon is the writer for the same DB the container reads.
  • cortex sessions watchstatus — wraps systemctl --user show cortex-sessions-watch.service on the host. The container has no view of the host systemd state.
  • cortex sessions index, cortex sessions add, cortex sessions doctor, cortex sessions smokewatch — all touch the host filesystem (transcript paths, watcher state). The container can't see them.
  • cortex db backup — writes a backup file to a host path. Passing the destination over HTTP would force a container-side filesystem the operator never asked for.

These all keep working when CORTEX_USE_HTTP=true because the CLI dispatch table never routes them through the HTTP client.


Operational option: weekly compose-doctor

The compose doctor subcommand runs the two drift diagnostics (data-mount, ai-watch-coord) and exits non-zero on a canonical mismatch. A simple way to surface ai-watch / data-mount drift without manual invocation is a weekly user-systemd timer:

[Unit]
Description=cortex drift check

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cortex compose doctor --json
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target
[Unit]
Description=run cortex drift check weekly

[Timer]
OnCalendar=Mon *-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target

Pair with whatever push-notification path the operator already has (Gotify, ntfy, email) keyed off systemctl --user status cortex-doctor.timer exit codes. The --json output is stable enough for jq/grep alerting.


See also

  • docs/architecture.md — caller → DB diagram and the three direct-SQLite consumers.
  • docs/rollout.md — manual upgrade playbook for the v0.26 cutover.
  • docs/CLI.md — direct CLI command reference.
  • src/api.rs — router and handler source of truth.
  • src/app/models.rs — typed request/response structs.