AegisAgent Feature & Implementation History¶
This document contains the historical status, feature releases, and ticket verification details for the Rust gateway and SDKs of AegisAgent. It is preserved here for developer reference and on-demand agent lookup, keeping the global CLAUDE.md context clean.
Status: Historical record, not the current capability ledger. Use Implementation Status for present-tense claims; test counts and file paths below reflect the recorded point in time.
How to verify a historical claim¶
Use the cited issue/PR and repository history. Do not infer current production readiness from a historical “done” entry.
Console UI — Bun rewrite (July 2026)¶
Production SOC console is ui-next/ (Bun + Vite + React 19 SPA at /dashboard/). Legacy Next ui/ was removed (Phase E).
| Phase | Deliverable | PRs (selected) |
|---|---|---|
| 0–5 | Scaffold, P0 surfaces, cutover | #1798–#1802 |
| A–D | AQL, panels, ControlsBar, editor | #1803–#1807 |
| E–F | Playwright expansion, Next delete, a11y/redact/mocks | #1808–#1809 |
| Post catalog | Lazy routes, E2E fixes, 10 system boards | #1810–#1820 |
| Charts | soc-query timeseries + heatmap (pure SVG) |
#1821–#1822 |
| Differentiators ★ | approval-card, provable-timeline, receipt-integrity |
#1824–#1826 |
| Fleet graphs | agent-risk-map, decision-graph (evidence graph SVG) |
#1827–#1828 |
Panel allowlist complete — every PanelType in ui-next/src/panels/types.ts that the editor allows is registered (standard + differentiators + risk map + decision graph). See docs/components/Console_UI.md.
Contracts: docs/components/Console_UI_Bun_Contracts.md.
SDK & Gateway Status (June 2026 Baseline)¶
Python SDK — 187 tests, fully verified on main:
- action_hash canonicalization unified as scheme aegis-jcs-1 in sdk-python/aegisagent/canon.py; SDK fails closed on hash mismatch, on approval expiry, and if it cannot atomically consume a single-use approval (replay defense).
- Verifiable receipts: format + reference verifier (aegisagent/receipts.py), CLI (aegis-verify-receipts), shared corpus (tests/receipt_chain_vectors.json).
- Async client (AegisAsyncClient) + async_protect_tool decorator.
- report_mcp_response (sync + async, #1333) — fire-and-forget helper that POSTs an MCP tool's response text to the gateway's POST /v1/mcp/servers/:server_key/inspect for server-side sensitive-data/injection scanning; the response text is never logged client-side (added to the client's debug-log redaction key list) and a gateway/network failure here is swallowed (logged, returns None) since the tool call has already completed by the time this is called.
- CLI tools: aegis-status, aegis-freeze-agent, aegis-export-audit.
- Evidence packs, webhook handler, structured JSON logging.
- End-to-end demo examples/integrity_demo.py.
Go SDK — full parity, verified on main:
- aegis-jcs-1 canonicalizer (canon/canon.go), aegis.Client, aegis.Protect, receipt verifier.
- Context-based cancellation + management/SOC methods (#1183): every Client method (Authorize, GetApproval, ConsumeApproval, and all new methods below) now takes ctx context.Context as its first parameter (idiomatic Go cancellation/timeout), threaded through Protect/ProtectWithOptions/handleApproval too — a breaking signature change, but there were no callers outside sdk-go/aegis/ itself. New methods: Approve/Reject (POST /v1/approvals/:id/approve|reject), FreezeAgent/UnfreezeAgent/RevokeAgent (POST /v1/agents/:id/freeze|unfreeze|revoke), ListAlerts/ListIncidents/GetSocSummary (GET /v1/alerts, /v1/incidents, /v1/soc/summary, with typed ListAlertsOptions/ListIncidentsOptions query filters).
- Cross-language byte-parity CI gate.
TypeScript SDK — full parity, verified on main:
- aegis-jcs-1 canonicalizer (src/canon.ts), AegisClient, protect().
- approve()/reject() (#1182): POST /v1/approvals/:id/approve|reject, mirroring the gateway's ApproveRequest body (approverUserId, optional reason); returns {status, approvalId}. listAlerts()/listIncidents() (#1182): GET /v1/alerts/GET /v1/incidents with typed query-param options (limit/offset/severity/agentId, plus status for incidents), snake_case → camelCase field mapping. getSocSummary() (#1182): GET /v1/soc/summary.
- tsc --noEmit build + node --test suite (29 tests) + cross-language corpus CI gate.
Rust gateway — 637 tests, verified on main:
- SOC MTTD/MTTR Prometheus gauges (metrics.rs/events.rs/routes.rs, #1158): aegis_soc_mttd_seconds/aegis_soc_mttr_seconds (rolling cumulative average, atomics-backed, no extra crate) on GET /metrics. MTTD is sampled in events::handle_alert from the real gap between the triggering event's occurred_at and the moment the alert is raised — deliberately not derived from SocAlertRecord.created_at, which intentionally mirrors occurred_at as an evidence timestamp rather than wall-clock alert-creation time. MTTR is sampled in routes::close_incident from the real gap between opened_at and closed_at. Unparseable timestamps or a negative duration (clock skew) are skipped silently — observability never panics the drain loop or the request path.
- MCP response inspection (mcp_inspect.rs, #1333): the gateway's /v1/authorize only ever sees the request side of a tool call — the SDK executes the tool itself and the gateway never observes the return value. POST /v1/mcp/servers/:server_key/inspect closes that gap: the SDK forwards a tool's raw response text after executing it (fire-and-forget — never blocks the already-returned tool result), and the gateway scans it server-side (single Rust implementation, no per-SDK triplication) for SSN shapes, Luhn-valid credit card numbers, known API-key prefixes (GitHub/AWS/Google/Slack/Stripe/GitLab), and a small list of prompt-injection phrases — all hand-rolled, no new regex dependency. 404 for an unknown server; 200 {"inspected": false, ...} without scanning when the server's opt-in inspection_enabled flag (default false, toggle via PATCH /v1/mcp/servers/:server_key) is off. Findings carry only a FindingCategory + count — never the matched substring (redaction invariant) — and a flagged scan inserts one soc_alerts row per matched category (rule: "mcp_response_sensitive_data"/"mcp_response_injection_attempt"), visible via GET /v1/alerts/GET /v1/soc/summary. Scope limitation (v1): unlike events::drain-sourced alerts, these don't flow through the live notify sink/webhook export/GET /v1/ws/events — polling only.
- Cross-language action_hash corpus test (canonical_action_matches_shared_corpus).
- Gateway-side approval expiry (get_approval → EXPIRED; approve_approval → 409).
- Receipt-hash parity lock (receipt_chain_matches_shared_corpus).
- Receipt emission: action_receipts table + emit_action_receipt on every decision + GET /v1/receipts/:id/verify + optional Ed25519 signing (sign.rs).
- Single-use approvals (replay T-A3): consumed_at column + atomic db::consume_approval + POST /v1/approvals/:id/consume.
- Agent SOC (Phases 0-3, 5, 6): async event stream (events.rs), detection rules (detect.rs), correlation engine + incidents (correlate.rs), notify sink with HMAC-SHA256 signing + circuit breaker (notify.rs), RCA narrator (narrate.rs), SQLite event indexer + /v1/ws/events live feed + /v1/soc/summary.
- YAML detection rule DSL (rule_dsl.rs, #1282): Detector is now YAML-driven — the original hardcoded rules (confused_deputy_block, approval_required_surface, critical_deny, replay_attempt, mcp_manifest_drift) are an embedded default YamlRule set (rule_dsl::default_rules()). events::drain loads each tenant's enabled custom rules fresh from detection_rules per event (out-of-band, Law 3) and evaluates them alongside the defaults, deduping alerts by rule name. GET /v1/soc/rules (effective rules, tagged source: "default"|"custom"), POST /v1/soc/rules (validated create/update, 400 on invalid condition/severity), POST /v1/soc/rules/reload (documented no-op — rules are always fresh).
- Phase 4 — Response Engine (respond.rs): freeze/revoke/quarantine APIs + auto-dispatch responder with configurable autonomy levels (L0-L4).
- Agentless ingestion (ingest.rs): POST /v1/ingest for GitHub webhooks, OpenAI traces.
- Behavioral baselining (baseline.rs): per-agent action frequency baselines with anomaly detection.
- Kubernetes probes: /livez, /readyz, /startupz. /readyz additionally tracks the liveness of fire-and-forget background tasks (#1152) — see below.
- Composite risk score (risk.rs, #1289): advisory composite_risk_score (0-100) on every /v1/authorize response and decisions row — base risk + environment/context-trust/MCP penalties + anomaly score - approval credit; per-tenant weight overrides via GET|PUT /v1/tenants/risk-weights (env-configured defaults otherwise). Display/audit metadata only — never gates decision (Law 1).
- Evidence graph schema (graph.rs, #1271): EvidenceGraph { nodes: Vec<GraphNode>, edges: Vec<GraphEdge> } — GraphNode (id/group: NodeType/label/timestamp/metadata) and GraphEdge (from/to/label: EdgeType/timestamp) serialize directly to vis.js Network's expected field names. NodeType covers agent|run|tool_call|decision|approval|receipt|incident|mcp_server|policy; EdgeType covers triggered_by|executed|decided|approved|produced|linked_to.
- Evidence graph query API (routes.rs, #1272): GET /v1/graph/run/:run_id, GET /v1/graph/incident/:incident_id, GET /v1/graph/agent/:agent_id?depth=N build an EvidenceGraph at query time via the shared add_decision_subgraph helper (tool_call -[decided]-> decision, plus run/agent linkage, and at depth>=2 approval/receipt and depth>=3 matched-policy nodes; depth clamped [1,5], default 3). All three are tenant-scoped, read-only, and 404 (not 500) for missing/cross-tenant root entities — Law 1 unaffected.
- Audit-writer chaos hardening (#1399): write_decision_and_audit retries db::insert_decision via db::retry_on_busy on transient SQLITE_BUSY/SQLITE_LOCKED before treating the audit write as failed. db::init_db_with_busy_timeout (parameterised variant for tests) lets a chaos test hold a real SQLite writer lock and verify: high-risk action denied with audit_writer_unavailable + audit_writer_unhealthy=true while locked; audit_writer_unhealthy resets to false and normal decisions resume once the lock clears (AC3 + AC5 of #1299/#1399).
- Tenant isolation stress tests (#1402): tenant_isolation_audit_events_alerts_incidents_and_decision_by_id seeds two independent tenants and asserts GET /v1/audit/events, GET /v1/alerts, GET /v1/incidents, and GET /v1/decisions/:id all respect tenant boundaries — no cross-tenant row leakage and a cross-tenant decision ID yields 404.
- GitHub App webhook receiver (#1381): POST /v1/webhooks/github — dedicated endpoint accepting native GitHub event payloads (pull_request, issues, issue_comment) with mandatory HMAC-SHA256 X-Hub-Signature-256 verification (fail-closed: 401 when AEGIS_GITHUB_WEBHOOK_SECRET is not set), tenant scoping via X-Aegis-Tenant-ID, and SOC pipeline integration. Unsupported event types return 202 ignored. 21 new unit + integration tests.
- Event schema versioning (events.rs, #1387): AseEvent gains schema_version: u32 (default 1, #[serde(default)]). Old serialized events without the field deserialize to v1 (forward-compatible). All ~17 construction sites updated. 5 new tests covering new/legacy/round-trip/future-version paths.
- HMAC-SHA256 request signing (#1403): optional signing_key on agents — when set, every POST /v1/authorize must carry X-Aegis-Request-Signature: sha256=<hmac-hex>; gateway verifies with constant-time Mac::verify_slice (401 on missing/invalid). authorize_action switched from Json<> to Bytes extractor. Migration 0011_agent_signing_key.sql. 9 new gateway tests + 5 Python + 3 Go + 3 TypeScript tests.
- Agent environment restrictions (#1391): optional allowed_environments list on agents — when set, any /v1/authorize call from an environment not in the list is denied 403 FORBIDDEN before Cedar evaluation (confused-deputy / cross-env exploitation defense). None / empty = unrestricted (backwards-compatible). Migration 0012_agent_allowed_environments.sql. 3 new gateway tests.
- Agent-to-tool permission bindings (#1390): optional explicit tool allow-list per agent — if any bindings exist, tools not in the list are denied 403 FORBIDDEN before Cedar evaluation (fail-closed). No bindings = unrestricted (backwards-compatible). GET|POST /v1/agents/:id/permissions, DELETE /v1/agents/:id/permissions/:tool_key. Migration 0013_agent_tool_permissions.sql. 5 new gateway tests.
- Cedar @decision("quarantine") annotation (#1386): Cedar policies can emit @decision("quarantine") on any permit rule to immediately quarantine the agent after the call is recorded. authorize_action runs set_agent_status → quarantined and fires an agent_quarantined SOC event (Law 3, out-of-band). Subsequent calls auto-denied via get_agent_by_token filter. Canary-endpoint example policy in both policies.cedar and gateway/policies.cedar. POST /v1/agents/:id/restore reactivates quarantined agents. 5 new gateway tests (2 policy, 3 routes).
- Action normalization layer (#1384): normalize_policy_identifier added to policy.rs applies percent-decode → NFC → trim → lowercase before building Cedar entity UIDs, closing a bypass where GitHub/Merge_Pull_Request built a UID that didn't match Cedar policies targeting the canonical lowercase form. normalize_tool_identifier in routes.rs updated to also trim surrounding whitespace. 10 new tests (4 policy unit + 1 policy Cedar integration, 4 routes unit + 1 routes Cedar integration).
- redact decision type (#1385): Cedar policies can emit @decision("redact") @redact_fields("field1,field2") to allow a tool call but return a redacted_fields list; authorize_action passes the list through AuthorizeResponse; AseEvent records it for audit. protect_tool decorator (sync + async) strips listed kwargs before executing the tool (sets value to "[REDACTED]"). Severity: quarantine > require_approval > redact > allow. Example canary policy in both policies.cedar files. 4 new gateway tests (2 policy, 2 routes) + 3 Python SDK tests.
- GitHub Checks API integration (gh_checks.rs, #1383): every /v1/authorize decision on a github-tool call whose resource parses as a PR ref (org/repo#42, via the shared gh_comment::extract_pr_ref) updates an "Aegis Security Gate" check run on the PR's head commit — created on first decision, updated thereafter (fire-and-forget, Law 3 out-of-band, never blocks /v1/authorize). Tally buckets: allow/redact → allowed, require_approval → pending, deny/quarantine/unknown → denied (fail-closed). Conclusion: failure if any denied, else action_required if any pending, else success. Summary includes the N-allowed/N-denied/N-pending breakdown; risky actions (non-allowed) surface as check-run annotations (capped at 20) against a synthetic .aegisagent/decisions path, since Aegis tool calls aren't tied to a real diff line. Reuses the AEGIS_GITHUB_APP_TOKEN installation token from gh_comment.rs (#1382); in-memory per-PR tally, resets on gateway restart. 17 new gateway tests (pure tally/conclusion/formatting helpers).
- Trust propagation across agent chains (trust_chain.rs, #1293): when Agent A triggers Agent B (which may trigger Agent C…), trust propagates tighten-only — a downstream hop can never end up more trusted than the most restrictive level seen anywhere upstream, closing a confused-deputy gap where B could claim its own trigger looks internal despite being invoked by A acting on untrusted content. AuthorizeRequest.trace gains optional root_trust_level/parent_run_id; policy::PolicyEngine::authorize gates Cedar's context.trust_level/context.root_trust_level on trust_chain::propagate(trace.root_trust_level, context.source_trust) instead of the raw self-declared trust. AuthorizeResponse.root_trust_level always returns the effective tightened value — the caller forwards it as the next hop's trace.root_trust_level to propagate correctly through multi-hop chains. Persisted on decisions.root_trust_level/decisions.parent_run_id (migration 0014_decisions_trust_chain.sql) for audit/evidence-graph reconstruction. 14 new gateway tests (9 pure trust-chain unit tests, 3 policy.rs Cedar-gating tests, 2 routes.rs end-to-end tests including a 3-hop chain scenario).
- Policy dry-run / simulation mode (#1281): AuthorizeRequest.dry_run: Option<bool> (body field, not a query param) evaluates Cedar + risk scoring exactly like a normal call but skips every persistence/side-effecting path inside authorize_action — no decisions/audit_events/approvals/action_receipts row, no idempotency read/write, no SOC agent_quarantined event or actual agent quarantine, no GitHub PR comment or check-run update. write_decision_and_audit returns the computed composite_risk_score early when dry_run is set, before any DB write. AuthorizeResponse.dry_run: bool echoes whether the response was simulated. 4 new gateway tests (allow/require_approval/quarantine/default-unset paths, each asserting zero rows written where applicable).
- Auto-rotate leaked agent token (#1295): POST /v1/agents/:id/rotate-token (operator-triggered, always allowed) and POST /v1/agents/:id/report-leaked-token (leak-signal entrypoint) both call the shared rotate_and_audit_agent_token helper, which generates a fresh plaintext token, hashes + stores it via the existing db::rotate_agent_token (already used by register_agent re-registration, #1366), and writes an agent_token_rotated audit event with reason. The old token's hash no longer matches any row, so it is rejected on the very next /v1/authorize call — no grace period. The leak-report path is gated on the new per-tenant tenants.auto_rotate_token_on_leak_enabled (migration 0016_tenant_auto_rotate_token.sql, default enabled): when disabled, no rotation happens but the report is still recorded (agent_token_leak_detected_no_rotation audit event) and a agent_token_leak_detected SOC event is emitted either way (Law 3, out-of-band). The new plaintext token is returned once in the API response only — never pushed over a webhook or persisted, since that would just relocate the secret-handling risk. 5 new gateway tests.
- Configurable webhook export, generic SIEM (webhook_export.rs, #1285): adds real delivery on top of the TASK-0092 (#938) webhook_subscriptions CRUD scaffold, which registered subscriptions but never delivered anything to them. Each subscription now gets a server-generated delivery_secret (returned once at creation, like agent_token) used to HMAC-SHA256-sign outbound deliveries on X-Aegis-Signature — distinct from the legacy operator-supplied secret/secret_hash pair, which is a one-way hash the gateway can't itself sign with. Subscriptions add min_severity (info/high) and format (json/minimal CEF) filters. events::drain calls webhook_export::dispatch at the same three high-signal points notify.rs already uses — deny/require_approval decisions, HIGH alerts, HIGH incidents — spawning one fire-and-forget delivery task per matching subscription (up to 3 attempts, exponential backoff, 5s per-attempt timeout). db::record_webhook_delivery_result tracks consecutive_failures and derives delivery_status (healthy→degraded→dead), separate from the legacy status column. Migration 0017_webhook_export_delivery.sql (additive ALTER TABLE). 9 new gateway tests.
- Auto-escalate agent risk tier on repeated denials (risk_escalation.rs, #1296): unlike the advisory composite_risk_score (Law 1 — never gates a decision), agents.risk_tier is real authorization state that Cedar now reads via context.agent_risk_tier (always the trusted server-side value passed explicitly into PolicyEngine::authorize, never the client-supplied request body) to require approval for ANY mutating action once an agent is tier "high" — even one a trusted-internal context would otherwise auto-allow. Escalation ("low"→"medium"→"high", one step at a time) runs inline on /v1/authorize immediately after a deny decision is recorded — fast, local-SQLite-only, no network I/O, so the very next call from that agent already sees the tightened tier — once more than a per-tenant denial_threshold of that agent's deny decisions land within a rolling window_minutes (default 5 / 60, configurable via GET|PUT /v1/tenants/risk-escalation). Writes an agent_risk_escalated audit event and SOC event (Law 3, out-of-band emit). Manual de-escalation already worked via the existing PATCH /v1/agents/:id risk_tier field. 16 new gateway tests (10 in risk_escalation.rs, 3 Cedar-gating tests in policy.rs, 3 route/end-to-end tests in routes.rs).
- Detection rule backtesting (backtest.rs, #1283): POST /v1/soc/rules/:rule_key/backtest (optional body {"from": "...", "to": "..."}, default trailing 7 days) reconstructs the AseEvent shape each historical decisions row would have produced and runs the looked-up rule's existing YamlRule::matches against it — the exact same predicate events::drain evaluates live — entirely in memory. 404 if rule_key doesn't match any effective rule (default or enabled tenant-custom). Returns match_count, matched_decision_ids, decisions_scanned, and estimated_daily_alert_volume (match_count projected over [from, to]). Pure read + in-memory evaluation — never writes soc_alerts/soc_incidents, so a backtest can never affect the live SOC pipeline (Law 1). db::list_decisions_in_range (capped at 50k rows) formats its date-range bind explicitly to match SQLite's DEFAULT CURRENT_TIMESTAMP format on decisions.created_at (a plain DateTime<Utc> bind serializes with a T separator, which sorts incorrectly against the column's space-separated format in a string comparison). 9 new gateway tests.
- Config-change audit event for policy reload (#1159): POST /v1/policies/reload now requires TenantId and, on a successful Cedar file reload, writes a config_change audit event (action: "policy_file_reload", event_json carries the reloaded policy_path) — previously silent. Distinct from the existing hash-chained policy_audit_log trail (record_policy_audit_log) that already covered tenant-scoped Cedar policy CRUD (create/update/delete). 1 new gateway test.
- Dependency license compliance gate (gateway/deny.toml, CI license-check job, #1174): cargo deny check licenses blocks GPL/AGPL (or any other newly-introduced copyleft license) from entering the gateway's dependency tree — deny.toml is an allow-list of every license already present in the dependency graph, so anything not already on it (current or future) fails closed. Distinct from the existing security-audit job's cargo audit/pip-audit (vulnerabilities, not licensing).
- CI code coverage gates (rust-coverage/python-coverage jobs, #1173): blocking thresholds of 70% (cargo-llvm-cov --fail-under-lines, gateway currently ~90%) and 75% (coverage.py --fail-under, Python SDK currently ~81%). Each job posts its per-file report to the run's Job Summary rather than a true PR-comment bot, since the latter needs pull-requests: write on a pull_request-triggered workflow that also runs against untrusted fork PRs — a write-token surface this repo's CI deliberately avoids (see ci.yml's permissions: contents: read).
- Container image vulnerability scanning (.github/workflows/container-scan.yml, CI-007 #1176): builds the gateway's distroless image (#1207) and scans it with Trivy on every push/PR. CRITICAL/HIGH findings fail the job (ignore-unfixed: true, so the gate only fires on CVEs with an available fix), blocking merge; results upload as SARIF to the GitHub Security tab (github/codeql-action/upload-sarif, job-scoped security-events: write) regardless of pass/fail. Distinct from the existing cargo audit/pip-audit/cargo deny jobs (#1174), which scan Rust/Python dependency manifests, not the built OS-level image layers. Pin note: aquasecurity/trivy-action must stay on a version that pins its setup-trivy dependency by SHA (currently v0.36.0) — v0.29.0 referenced a setup-trivy tag that was later deleted upstream, breaking action resolution.
- ETag conditional caching for GET responses (main.rs::etag_middleware, #1141): a generic middleware (not an endpoint-specific allow-list) hashes every GET 200 response body (SHA-256) into a quoted ETag; a repeat request with a matching If-None-Match gets a 304 with an empty body instead of re-sending the payload. Placed before the compression layer in the stack so the hash covers the canonical uncompressed body — verified the ETag is identical with or without Accept-Encoding: gzip. POST/PUT/PATCH/DELETE and non-200 responses are untouched. 4 new tests.
- Cursor-based (keyset) pagination for 5 list endpoints (#1142): GET /v1/decisions, /v1/receipts, /v1/audit/events, /v1/alerts, /v1/incidents now accept ?cursor= (opaque, hex-encoded rowid) and return X-Next-Cursor on the response when more rows follow — None/absent at the end of the result set. The JSON body stays a bare array, deliberately unchanged from before cursor support existed, since the Python/Go/TS SDKs already parse these 5 endpoints that way and a next_cursor body field would be a breaking shape change; the cursor rides on the header instead. Cursors seek on rowid (rowid < cursor, not the existing created_at/opened_at ORDER BY) since rowid is strictly monotonic and tie-free, which keyset seeking needs; ?offset= still works unchanged when no cursor is supplied, and cursor takes priority over offset when both are present. db::paginate_rows fetches limit + 1 rows and uses the extra row purely as a has-more signal, truncating back to limit — fetching exactly limit rows and checking len() >= limit is an off-by-one that wrongly emits a next-cursor whenever a result set ends precisely on a page boundary. 5 new boundary-regression tests (one per endpoint) plus an end-to-end multi-page route test.
- SQLite FTS5 keyword search for /v1/decisions and /v1/audit/events (#1450): migration 0018_fts5_search_index.sql adds a shared audit_search_index FTS5 virtual table plus AFTER INSERT triggers on decisions and audit_events that index tenant_id/source_table/source_id/searchable_text (skill/action/resource/reason/decision/agent_id, table-specific) on every new row — no backfill or batch reindex job, since both source tables are append-only. Both list endpoints accept an optional ?q= query param; routes::sanitize_fts5_query strips everything except alphanumerics/whitespace/_ and appends a trailing * for prefix matching, returning None (no filter) for an empty/all-punctuation input — this also closes the FTS5 query-syntax injection surface (CWE-89 analog for FTS5's own query language) since raw operators (", -, OR, NEAR, etc.) never reach MATCH. The q filter is a parameterized id IN (SELECT source_id FROM audit_search_index WHERE searchable_text MATCH ? AND source_table = '...' AND tenant_id = ?) subquery layered onto the existing cursor/offset pagination in db::list_decisions_cursor/db::get_all_audit_events_cursor — defense-in-depth tenant scoping inside the FTS subquery itself, not just the outer query. 2 new tests covering exact match, prefix match, tenant isolation, and no-match.
- SOC Console Dashboard end-to-end test suite + approval/agent/stats field-mismatch fixes (#1326): e2e/ is a Playwright suite (23 tests, dashboard-shell.spec.ts + dashboard-data.spec.ts) covering page load, the CSP header (#1309) and CSRF cookie/meta-tag (#1308), all 7 sidebar views, Agents Fleet, Alerts, MCP Servers, Explore search, Integrity Logs/receipts, and a full Approvals workflow (seed a pending approval via /v1/authorize under semi_trusted_customer trust, approve it through the real rendered UI, assert it leaves the queue) — Dashboard E2E (Playwright) CI job runs it against the docker compose gateway + seed-demo.sh on every PR. Writing real tests against a live gateway (not mocks) surfaced that gateway/dashboard/app.js read several field names that don't exist on the actual API responses, silently broken in production: GET /v1/approvals never exposed agent_id or the original tool_call at all, and its own row id was approval_id not id — every Approve/Reject button posted to /v1/approvals/undefined/approve; POST /v1/approvals/:id/approve|reject requires approver_user_id (non-optional) but the dashboard posted {} — every click silently failed validation; Agents Fleet/incident-detail read operational_status (actual: status); MCP Servers read transport_type/drift_status/last_active_at (actual: transport/status/last_discovery_at); Explore read source_trust on decisions, which doesn't exist (root_trust_level) — threw inside the filter callback on every Source Trust facet click; Overview stats read protected_actions_total/denied_actions_total, neither on TenantStats (total_decisions/decisions_deny). Fixed list_approvals/get_approval to additively enrich the response with agent_id (batch-joined from the linked decision via the new db::list_decisions_by_ids, avoiding N+1) and tool_call (the already-stored original AuthorizeToolCall, previously never serialized), and fixed every app.js field reference to match. Also fixed an unrelated CWE-200/CWE-522 finding hit in the same code: AgentRecord serialized agent_token (a SHA-256 hash) and signing_key (a live plaintext HMAC secret) directly over GET /v1/agents/GET /v1/agents/:id — neither SDK reads either field from those endpoints (only from register/rotate responses) — both now #[serde(skip_serializing)].
- MCP Approved Tool Allowlist UI (#1334): the dashboard's MCP Servers view gains a detail view (reusing the Incidents list/detail container show/hide pattern) — clicking a server row opens manifest hash + last discovery + trust level + transport metadata, the per-tool Approved Tool Allowlist table (status badges pending/approved/disabled, with Approve/Disable buttons calling the existing POST .../tools/:tool_key/approve|disable), a Manifest Drift History table, and server-level Quarantine/Restore containment buttons (confirm()-gated, calling the existing POST /v1/mcp/servers/:server_key/quarantine|restore — quarantine is server-wide, not per-tool, matching the issue's own clarified scope). Backed by the new GET /v1/mcp/servers/:server_key/manifest-history route (see API contract above), the only piece that didn't already exist. 1 new Playwright E2E test, self-contained via a new registerTestMcpServer helper (dedicated server + discovery call, doesn't mutate the shared seeded github-mcp-demo).
- Trust Level Dashboard View (#1294): the Explore decision list's Source Trust column and facet sidebar now color-code each of the 6 context-trust-provenance levels (trusted_internal_signed=green, trusted_internal_unsigned=blue, semi_trusted_customer=yellow, untrusted_external=red, malicious_suspected=critical-red, unknown=grey) instead of one flat grey badge for every level. Overview page gains an "Untrusted Sources" stat card ((untrusted_external + malicious_suspected) / total_decisions, client-computed from the new trust_level_breakdown) and a Trust Level Distribution bar chart. Filtering decisions by trust level already existed via the Explore trust facet (unchanged). 1 new gateway test (get_tenant_stats_includes_trust_level_breakdown) + 1 new Playwright E2E test.
- Agent Risk Scoreboard (#1290): db::get_agent_risk_scoreboard ranks agents by rolling 24h average composite_risk_score (GET /v1/agents/risk-scoreboard, ?format=csv for export), deriving a trend against the prior 24h window (>5-point delta threshold to avoid flapping on noise; "stable" when there's no current activity or no prior baseline). New "Risk Scoreboard" dashboard view (ranked table, trend arrows: rising=red up/falling=green down/stable=grey right) with a "View in Explore" action per agent — reuses the existing Explore view (already filterable by agent_id) as the agent detail context rather than building a dedicated agent detail page. Overview page gains a "Top 5 Riskiest Agents" card. 2 new gateway tests (db ranking/trend + route JSON/CSV) + 1 new Playwright E2E test.
- Admin-operation audit trail (#1157): delete_agent and revoke_agent_tool_permission previously wrote no audit event at all on success, unlike sibling admin endpoints (freeze_agent/unfreeze_agent/revoke_agent, quarantine_mcp_server/restore_mcp_server, close_incident) which already insert an operation-specific event_type. New shared write_admin_action_audit_event helper (gateway/src/routes/mod.rs) writes a single, filterable event_type: "admin_action" row for these two (action: "agent_deleted" / "agent_tool_permission_revoked"), queryable via GET /v1/audit/events?event_type=admin_action. delete_tenant is deliberately excluded — its GDPR right-to-erasure delete wipes the tenant's own audit_events rows, so auditing the deletion would be self-defeating. 2 new gateway tests.
- Background-task liveness on GET /readyz (#1152): the event drain task, audit-batch writer, and 3 periodic jobs (receipt-chain integrity, audit-event archival, approval cleanup) run detached and fire-and-forget — previously, if one panicked it stopped running permanently while the gateway kept reporting healthy. AppState.background_task_handles now holds an AbortHandle per task (cloned via JoinHandle::abort_handle(), which never aborts the task itself, so drain_handle/audit_batch_handle are still owned/awaited during graceful shutdown exactly as before). readyz_handler checks AbortHandle::is_finished() (zero I/O) for each and returns 503 with background_tasks: "down" + dead_background_tasks: [...] if any died — gating the status code, unlike the existing advisory-only audit_writer field (which self-heals on the next successful write; a dead background task never does). Deliberately not on /livez, which has a documented no-I/O/no-dependency-check invariant (#1208) so a wedged dependency can't trigger a pod restart loop. 1 new gateway test.
- Field-based filtering for /v1/agents and /v1/incidents (#1145): GET /v1/agents previously had no filtering at all beyond pagination — now accepts ?status= for an exact match (parameterized (? IS NULL OR status = ?), same pattern as the existing severity/agent_id filters on incidents), with the unset case preserving the original "hide soft-deleted agents" default. GET /v1/incidents already filtered status/severity/agent_id; adds the missing ?kind=. The issue's "invalid filter fields return 400" criterion is deliberately out of scope — that's a uniform error-response contract across every list endpoint, which is #1144's territory (standardize error response format), not bundled into this filter addition. 4 new gateway tests.
- SSE watch mode for /v1/alerts and /v1/incidents (#1146): ?watch=true returns text/event-stream and pushes newly created rows — no historical backfill, matching Kubernetes ?watch=true semantics — as a simpler REST alternative to the existing /v1/ws/events WebSocket stream. A background task polls new forward-watch queries (db::list_soc_alerts_since/list_soc_incidents_since, rowid > since, parameterized, tenant-scoped) every 2s and feeds an mpsc channel adapted into a Stream via futures_util::stream::unfold (futures-util promoted from dev- to a direct runtime dependency, same locked version). axum::response::sse::Sse::keep_alive() handles the heartbeat-ping criterion natively. Deliberately polling-based rather than wiring a new broadcast channel into events::drain's detection/correlation path — lower risk, zero changes to the hot SOC pipeline (Law 3); the issue itself frames SSE watch mode as the simpler, not lower-latency, alternative. Existing severity/agent_id (alerts) and status/severity/agent_id/kind (incidents, #1145) filters apply to watch mode too. 4 new gateway tests.
- DB connection-pool health monitoring (#1150): GET /metrics gains db_pool_connections_active/db_pool_connections_idle (point-in-time gauges, read directly from the live pool — pool.size()/pool.num_idle() — at scrape time) and db_pool_acquire_wait_seconds (rolling mean, sampled by a new periodic job jobs::sample_pool_health, default every 30s via AEGIS_POOL_HEALTH_SAMPLE_INTERVAL_SECS, that times a synthetic pool.acquire() released immediately — instrumenting every real query call site for the same signal would be far more invasive). The sampler also logs a warning when the pool is over 80% busy, and is registered in AppState.background_task_handles (#1152) so a panicked sampler surfaces on GET /readyz too. 3 new gateway tests.
- Per-incident evidence pack export (GET /v1/incidents/:id/evidence-pack, SOC-006, #1189): bundles a single incident together with the alerts and decisions that contributed to it, their linked receipts and audit events, and an on-demand RCA narrative, into a downloadable ZIP — a focused, single-incident counterpart to the tenant-wide GET /v1/compliance/evidence-pack (#1298), same in-memory zip::ZipWriter construction pattern. Linkage resolves through the same event_id chain GET /v1/graph/incident/:incident_id (#1272) already relies on: soc_incidents.source_event_ids → soc_alerts via exact source_event_id match, and → decisions via audit_events.decision_id. New db::list_soc_alerts_by_source_event_ids and db::list_audit_events_by_decision_ids batch-fetch across that chain in two queries total. 404 for an unknown or cross-tenant incident. 4 new gateway tests.
- OpenAPI Spec Auto-Generation (#1401): auto-generates the OpenAPI 3.0 specification from route metadata, serving the dynamic schema at GET /v1/openapi.json and integrating Swagger UI at GET /v1/docs (fetching from GET /v1/docs/openapi.json to avoid route overlap).
- Native TLS Support via Rustls (#1209): optional TLS support when AEGIS_TLS_CERT and AEGIS_TLS_KEY environment variables are set. Implements same-port TCP multiplexing: peeks incoming streams to route TLS traffic (Client Hello 0x16) to Rustls, and redirects plain HTTP to HTTPS using an HTTP 308 Permanent Redirect response. Enforces a minimum of TLS 1.2 and supports HTTP/1.1 and HTTP/2.
- Distroless runtime image (gateway/Dockerfile, #1207): the runtime stage switched from debian:bookworm-slim + apt-get install ca-certificates curl to gcr.io/distroless/cc-debian12:nonroot — no shell, no package manager, runs as the built-in nonroot uid/gid (65532) instead of root. CA certs are copied explicitly from the builder stage (outbound TLS for webhook delivery/GitHub API/Qdrant); the release binary is stripped. Since distroless has no curl/shell for Docker's HEALTHCHECK to exec, the gateway binary gained a --healthcheck CLI flag (main.rs's check_liveness) that GETs its own /livez and exits 0/1 — both the Dockerfile's HEALTHCHECK and docker-compose.yml's healthcheck exec the binary itself instead of curl. Running as nonroot also broke docker-compose.yml's ./data bind mount (the in-image chown doesn't apply to a host bind mount — the host directory's ownership wins), fixed with a one-shot busybox init-data-dir service that chmod 0777s the mount before the gateway starts. Known deviation from the issue's <50MB target: measured image size is ~88MB — cedar-policy, qdrant-client/tonic, sqlx, and rustls/aws-lc-rs are substantial dependencies (the cc-debian12 base alone is ~37MB); a <50MB musl-static build against distroless/static-debian12 is a possible future follow-up, not done here.
- SQLite advisory-lock leader election for background jobs (REL-003, #1149): multiple gateway instances sharing one DB previously all ran the same periodic maintenance jobs (receipt-chain integrity, audit-event archival, approval cleanup) concurrently and redundantly. A leader_lock table (migration 0019, one global row, not tenant-scoped — same precedent as schema_meta) plus db::try_acquire_or_renew_leadership — an atomic UPDATE ... WHERE that only succeeds when the calling instance already holds the lease or the existing lease has expired (INSERT OR IGNORE handles the very first boot) — means two instances racing this call can never both believe they're the leader. jobs::run_leader_election_loop renews every AEGIS_LEADER_ELECTION_INTERVAL_SECS (default 5s) with an AEGIS_LEADER_LEASE_SECS lease (default 20s); worst-case transfer after a leader dies is lease + election_interval ≈ 25s. The three maintenance job loops now skip their real work when not leader (info! only on a leader/standby transition, debug! per unchanged tick). Registered in AppState.background_task_handles (#1152) so a panicked election loop surfaces on GET /readyz too. 7 new tests.
- Standardized Kubernetes-style error responses (API-005, #1144): error responses across the gateway were ad-hoc {"error": "..."} JSON bodies with no consistent shape — gateway/src/error.rs adds StatusError, a Kubernetes Status-style envelope (kind/apiVersion/status/message/reason/details/code), and a finite ErrorReason enum (BadRequest, Unauthorized, Forbidden, NotFound, Conflict, AlreadyExists, Invalid, Timeout, TooManyRequests, NotImplemented, UnsupportedMediaType, InternalError, Unknown) where each variant maps to exactly one HTTP status code. StatusError implements IntoResponse, so it dropped in for the old (StatusCode, Json<Value>) tuples at every error call site across all 13 route files plus main.rs (~250 sites); any extra ad-hoc fields the old bodies carried (informal reason/approval_id/status strings used for rate-limit/replay/webhook-signature diagnostics) are preserved via .with_details(json!({...})) rather than dropped. TenantId's FromRequestParts::Rejection is now StatusError directly. StatusError/ErrorReason are registered in the OpenAPI component schemas with body = StatusError added to all 53 already-documented 4xx/5xx responses in routes/openapi.rs. Two pre-existing response shapes were deliberately left untouched since they aren't generic API errors: /v1/authorize's {"decision": "deny", "reason": "..."} body (mirrors the existing AuthorizeResponse contract SDKs already parse), and the receipt-chain verifier's per-index {"error": ...} entries inside a 200 OK verify-chain response (describes why one receipt failed, not an HTTP error). 5 new tests.
- Tokio runtime metrics endpoint (OBS-007, #1160): GET /debug/runtime mirrors Kubernetes' /debug/pprof convention — bound only on the existing 127.0.0.1 listener (same invariant as /metrics), no new bind, no public exposure. Returns active_tasks_count/workers_count/total_poll_count/global_queue_depth/scheduler_utilization/uptime_secs as JSON, sourced directly from tokio::runtime::Handle::current().metrics() (no extra dependency). Per-worker poll/busy-duration counters require the tokio_unstable cfg flag, applied repo-wide via the new root .cargo/config.toml — gateway/Dockerfile's builder stage also gained COPY .cargo ./.cargo so the same flag reaches the actual container build, not just local/CI cargo invocations from the repo root. A process-wide OnceLock<Instant> approximates "runtime start" on first request rather than threading a new field through all 14 AppState construction sites for one derived metric. 1 new test.
- docker-compose.dev.yml with auto-seeded demo data (DX-002, #1203): docker compose up previously started with an empty DB, needing a separate manual bash scripts/seed-demo.sh run. docker compose -f docker-compose.dev.yml up now starts the gateway and auto-seeds richer demo data via a one-shot seed-demo service (curlimages/curl, network_mode: host, gated on the gateway's healthcheck) — the dev file reuses the base docker-compose.yml's gateway/init-data-dir services via the include: directive rather than duplicating them. The new scripts/seed-demo-dev.sh (POSIX sh, runs identically on a host or inside the seed container) seeds 2 tenants, 3 agents (2+1 split), the demo GitHub tool registrations, and a real deny_storm HIGH incident — triggered through the actual /v1/authorize → correlate.rs pipeline (5 denied merge_pull_request attempts under malicious_suspected trust within the 60s correlation window), not a canned fixture — so the SOC dashboard has live data immediately after startup.
- Chaos/fault-injection tests for DB failures (TEST-004, #1164): three real (not mocked) failure-injection scenarios. DB unreachable: health_returns_503_when_db_pool_closed/authorize_action_returns_500_when_db_pool_closed close the pool and assert GET /health degrades to 503 db: "down" and authorize_action returns a graceful 500 rather than panicking. Pool exhausted: pool_acquire_times_out_gracefully_when_exhausted_not_panic holds the sole connection of a max_connections(1) pool open and asserts a second query resolves to Err(PoolTimedOut) within acquire_timeout, wrapped in an outer tokio::time::timeout so the test itself fails loudly rather than hanging the suite if that contract ever breaks. Corrupted database file: init_db_returns_error_not_panic_on_corrupted_database_file writes non-SQLite garbage bytes to a fresh path and asserts init_db returns Err gracefully; main()'s db::init_db(&db_url).await? now wraps the call with tracing::error! logging on failure before propagating, closing the "detects and logs error" criterion. 4 new tests.
- Admission webhooks — pluggable pre-authorize hooks (API-004, #1143): gateway/src/admission.rs's AdmissionWebhookClient is an optional Kubernetes-style MutatingAdmissionWebhook/ValidatingAdmissionWebhook for /v1/authorize, configured via AEGIS_ADMISSION_WEBHOOK_URL (required to enable; AppState.admission_webhook stays None and /v1/authorize makes zero extra network calls when unset), AEGIS_ADMISSION_WEBHOOK_TIMEOUT_SECS (default 5s), and AEGIS_ADMISSION_WEBHOOK_FAIL_OPEN (default true, governs the outcome on timeout/network/parse failure). Wired into authorize_action right after the frozen/revoked-agent check and before any risk/MCP/Cedar evaluation: a pass proceeds unchanged; a reject denies inline with the webhook's own reason (matched_policies: ["admission_webhook_reject"]); a mutate replaces tool_call.parameters before Cedar runs, so the mutation is visible to every downstream decision (including the approval that gets bound, per #1326's enriched tool_call). 12 new tests.
- Canonicalization & receipt-hash criterion benchmarks (TEST-005, #1165): gateway/benches/canon_benchmark.rs benchmarks aegis_canon::canonicalize_json/canonical_value_string (flat object, nested mixed-type object, 200-element array) and gateway/benches/receipt_hash_benchmark.rs benchmarks gateway::routes::compute_receipt_hash (bumped pub(crate) → pub, matching TASK-1313's lib.rs re-export pattern) against a chain-head and a mid-chain receipt — isolating these two hot-path primitives from authorize_benchmark's end-to-end (~6.7ms, SQLite-dominated) measurement; both land sub-millisecond. Two new CI steps reuse the existing check_bench_regression.py at a 20% threshold against new checked-in baselines (benches/baseline_canon.json, benches/baseline_receipt_hash.json). k6/vegeta HTTP load testing and docs/performance-baseline.md baseline recording already existed from prior work; this closes the remaining "criterion benchmark for canonicalization and receipt hashing" + "CI fails on >20% regression" acceptance criteria.
- Database vacuum scheduling (TASK-0061, #907): db::vacuum_database (VACUUM) + jobs::run_vacuum_job reclaim free space left behind by the audit-event archival (#0106) and approval-cleanup (#0105) jobs' deletes, which previously had no space-reclamation follow-up. Same fixed-interval + is_leader-gated pattern as the other three maintenance jobs (a full-file VACUUM running concurrently from multiple instances sharing one DB would be wasteful/lock-contentious); configurable via AEGIS_VACUUM_INTERVAL_SECS (default 86400s/24h), registered in background_task_handles for #1152 liveness monitoring on GET /readyz. 2 new tests proving real behavior (bulk insert+delete leaves free pages behind; PRAGMA page_count measurably shrinks after vacuum; the is_leader gate blocks the actual work, not just a flag).
- Tower load-shed layer for overload protection (TASK-0065, #911): an optional process-wide concurrency cap (AEGIS_MAX_CONCURRENT_REQUESTS, default 1000) — once that many requests are in flight, new ones are rejected immediately with a structured 503 (new ErrorReason::ServiceUnavailable in the #1144 envelope, distinct from the per-tenant TooManyRequests policy decision) instead of queuing behind the existing timeout layer. Deliberately built on tower::limit::GlobalConcurrencyLimitLayer, not the ServiceBuilder::concurrency_limit() convenience (ConcurrencyLimitLayer): for a .route(path, get(handler)) registration, axum defers the Handler -> Route conversion and re-applies every outer Router::layer() fresh on every incoming request (axum::boxed::Map::call_with_state) rather than once at startup, and ConcurrencyLimitLayer::layer() allocates a brand-new Arc<Semaphore> on each call — so under that path every request got its own private, always-free permit and shedding silently never triggered. GlobalConcurrencyLimitLayer stores one Arc<Semaphore> on the layer value itself and only ever clones that Arc from .layer(), keeping the permit count shared regardless of how many times axum reapplies the layer.
- Zero-downtime secret rotation (PROD-006, #1211): AEGIS_JWT_SECRET now accepts a comma-separated list ("new_secret,old_secret") — validate_jwt tries each entry in order, so tokens signed with either secret validate during a rotation window, via a shared jwt_secret_candidates helper (also used by main.rs's startup validation) that filters out blank/default_secret entries. AEGIS_RECEIPT_SIGNING_KEY now accepts an optional "key_id:hex_secret" prefix (plain hex stays backward-compatible); ReceiptSigner::key_id() is persisted as a new additive signer_key_id column (migration 0020) alongside signer_public_key, surfaced on GET /v1/receipts/:id/verify — never folded into receipt_hash (byte-parity guarded by a test). Receipt verification already used the public key embedded per-receipt at signing time rather than a live lookup against the currently-active key, so old receipts were already verifiable forever after a key rotation; signer_key_id is an audit/operator convenience for identifying which key generation signed a given receipt, not a verification requirement. Documented end-to-end in docs/runbooks/secret-rotation.md.
- Rendered OpenAPI reference via Redoc, published to GitHub Pages (DOC-003, #1198): a new standalone gateway/src/bin/export_openapi.rs binary prints routes::openapi::ApiDoc::openapi() (pure compile-time route metadata — no database, no running server) as JSON; docs/api/index.html renders it with Redoc's CDN bundle. docs/api/openapi.json is regenerated fresh on every Docs CI run (never checked in) by a new Rust build step in .github/workflows/docs.yml, which now also triggers on gateway/src/**/Cargo.toml/Cargo.lock changes so an API-surface change always redeploys the page — previously the workflow only watched docs/**/mkdocs.yml. Complements the existing live Swagger UI at GET /v1/docs; both render the same ApiDoc. New docs/api-reference.md links the two and is wired into mkdocs.yml's nav.
- Splunk HTTP Event Collector (HEC) export (#1286): an optional, ops-wide periodic export job (splunk_export.rs, AEGIS_SPLUNK_HEC_URL/AEGIS_SPLUNK_HEC_TOKEN to enable) that forwards every tenant's new decisions/alerts/incidents to a single configured Splunk destination as newline-delimited HEC batches — is_leader-gated like the other periodic maintenance jobs, looping over every tenant via per-tenant in-memory rowid cursors (seeded at "current max" on first sight, so a restart never re-floods Splunk with full history) that only advance after a successful dispatch. decision_to_hec_event forwards structured metadata only — input_json (the raw tool-call payload) is never forwarded to the third-party SIEM. Connection health (consecutive_failures/last_success_unix_secs) is exposed on GET /metrics; the job's AbortHandle is registered for #1152 liveness monitoring on GET /readyz.
- Per-tenant risk-weights TTL cache (#1513): db::get_risk_weights was previously re-read from SQLite inside write_decision_and_audit on every single /v1/authorize call, even though these operator-configured weights (PUT /v1/tenants/risk-weights) change only rarely. RiskWeightsCache (routes/mod.rs) is a plain per-tenant TTL cache (not an LRU like the existing SkillActionCache) — get/insert take an explicit now: Instant, mirroring ReplayNonceCache::check_and_insert's pattern, so TTL expiry is testable without real sleeps. Default TTL 60s, configurable via AEGIS_RISK_WEIGHTS_CACHE_TTL_SECS; put_tenant_risk_weights invalidates the relevant tenant's entry on a successful upsert so an operator override takes effect immediately instead of waiting out the TTL.
- Parallelized independent DB reads in authorize_action (#1510): two pairs of independent reads now run concurrently via tokio::join! instead of serially. db::agent_tool_permission_status + the idempotency lookup (db::get_decision_by_request_id, gated on !dry_run + request_id present) — the in-memory nonce/replay check between them in the original code has no DB dependency, so each result is still checked in the original priority order (permission denial, then replay, then idempotent replay) immediately after the join; tradeoff: a permission-denied call carrying a request_id now does one "wasted" idempotency read it previously skipped. db::get_skill_action (on a skill-action-cache miss) + db::get_mcp_server_by_key (for an mcp:-prefixed tool) — mcp_server_key only depends on the already-normalized tool identifier, not the skill-action lookup's result. Deliberately untouched: db::touch_agent_last_seen (a write, #1511's territory) and db::get_risk_weights (already removed from this hot path entirely by #1513's TTL cache). 2 new tests proving exact behavior is preserved when both joined futures are exercised together.
- Debounced agent heartbeat write (#1511): db::touch_agent_last_seen was the one remaining synchronous DB write on the authorize_action hot path (#1510 left it untouched deliberately). HeartbeatDebouncer (routes/mod.rs) is an in-memory Mutex<HashSet<(tenant_id, agent_id)>> that authorize_action touches instead of writing immediately; a new periodic job (jobs::run_heartbeat_flush_job, default every 30s via AEGIS_HEARTBEAT_FLUSH_INTERVAL_SECS) drains the set and batches the real writes. Deliberately not is_leader-gated like the other periodic maintenance jobs, since heartbeats are per-instance-observed activity, not global state needing single-leader coordination — every gateway instance must flush its own observed heartbeats. Graceful shutdown flushes any remaining buffered heartbeats before the pool is dropped. Registered in AppState.background_task_handles for #1152 liveness monitoring. 3 new tests.
References¶
Implementation Status · Current vs Roadmap · Repository Knowledge Map · Documentation Audit
- OpenAPI Spec Auto-Generation (#1401): auto-generates the OpenAPI 3.0 specification from route metadata, serving the dynamic schema at GET /v1/openapi.json and integrating Swagger UI at GET /v1/docs (fetching from GET /v1/docs/openapi.json to avoid route overlap).
- Native TLS Support via Rustls (#1209): optional TLS support when AEGIS_TLS_CERT and AEGIS_TLS_KEY environment variables are set. Implements same-port TCP multiplexing: peeks incoming streams to route TLS traffic (Client Hello 0x16) to Rustls, and redirects plain HTTP to HTTPS using an HTTP 308 Permanent Redirect response. Enforces a minimum of TLS 1.2 and supports HTTP/1.1 and HTTP/2.
- Hashed agent tokens (SHA-256), tenant validation (404 for non-existent), graceful shutdown with SOC channel drain, CatchPanic layer, schema_meta version tracking.