# Architecture ## Overview CoreCI Chat v0.1 is a multi-tenant SaaS with a TypeScript control plane, a Go Relay Agent distributed to customer Linux hosts, an OpenAI-compatible BYOM routing layer, and (M2) an MCP capability broker gateway with four Day-1 infrastructure adapters. The control plane runs in a single AWS region (us-east-1) and enforces tenant isolation via Postgres Row-Level Security. Every request flows through an API gateway that authenticates the session, resolves the tenant, enforces RBAC, and writes an immutable audit entry. The Relay Agent is an outbound-only WebSocket client that registers as a target and heartbeats to the control plane; in M2 it receives MCP tool calls (SSH) and enforces a fixed command whitelist (defense-in-depth layer 2; the broker is layer 1). All credentials live in AWS Secrets Manager (prod) or a local-encrypted fallback (dev) behind a `SecretProvider` interface — never env vars, config files, or DB columns. The v0.1 wedge is read-only diagnostic: no write actions, no hosted inference, no remediation. Async durable execution is provided by Trigger.dev (instrumented in M3 for chat workflows; the runtime bootstrap lands in M1 Wave A so M3 plugs in cleanly). ### M2 addition — MCP capability broker M2 introduces the Model Context Protocol (MCP) capability broker as the structured layer between the LLM/UI and customer infrastructure. The broker enforces INV-7 (read-only by default) as the load-bearing safety boundary: it maintains a closed, enumerated tool registry (9 tools across 4 adapters), a per-adapter write-method blocklist, token-bucket rate limiting, multi-target scope disambiguation, and SSE streaming. The broker implements MCP spec version `2025-06-18`. Conformance is verified against modelcontextprotocol.io (artifact required at M2 gate). M3 consumes the broker's REST+SSE gateway as a stable contract (M2 spec §9). **MCP transport architecture (D-007):** three layers: 1. **Broker ↔ Proxmox/GitHub/Gitea adapters:** in-process custom MCP transport. JSON-RPC 2.0 messages (`tools/list`, `tools/call`) passed in-process between the broker and the TS adapter modules. MCP `2025-06-18` allows custom transports provided they preserve the JSON-RPC message format and lifecycle requirements. No subprocess spawning for same-process TS modules. 2. **Broker ↔ SSH adapter:** the MCP `tools/call` JSON-RPC layer sits between the broker and the TS SSH adapter module (in-process). The TS SSH adapter module then calls the M1 Relay Agent (Go binary) over WebSocket — this downstream WebSocket transport is inherited from M1 infrastructure and is downstream of the JSON-RPC layer. It does not affect MCP conformance. 3. **Broker ↔ CI/LLM smoke:** stdio transport (JSON-RPC over stdin/stdout) for the CI `packages/llm-mock` smoke test. The mock LLM acts as an MCP host. **Broker ↔ UI:** REST facade + SSE (NOT MCP Streamable HTTP — a browser-friendly facade with MCP-compliant tool schemas and results inside). `POST /api/mcp/invoke` returns `{correlationId, streamUrl}`; `GET /api/mcp/stream/:correlationId` is the SSE stream. **OpenAI ↔ MCP translation contract:** `tool_calls[].function.{name, arguments}` (OpenAI) → `params.{name, arguments}` (MCP, parsed JSON object); `result.content[].text` + `isError` (MCP) → OpenAI tool message `{role:"tool", tool_call_id, content}`. Documented as a typed translator module in `packages/mcp/translator.ts`. ### Init-time invariants (preserved by the architecture-drift guard) - Single git repository at `~/coreci-chat`, branch hierarchy `main → milestone/v0.1-bootstrap → phase/NN-*`. - `.ciagent/` reference files at repo root (single-project mode). - Release target: Gitea `coreci/coreci-chat` at `https://git.cloudinit.dev`. - Autonomy: `full` (no HITL after clarify; decision threshold 0.6). ### Architecture invariants (M1 → v0.1) - Every HTTP request hits the API gateway first: auth → tenant resolve → RBAC → audit. - Every DB query runs under `SET app.tenant_id = ?` via a session-scoped transaction; RLS policies enforce scoping. Cross-tenant queries return empty. - Every credential is resolved via `SecretProvider.get(tenantId, key)`; the provider abstracts AWS Secrets Manager (prod) and local-encrypted (dev/test). - Every auditable event (prompt, tool call, SSH command, response) is appended to `audit_log` with a `prev_hash → curr_hash` chain; `UPDATE`/`DELETE` are REVOKE'd from the app role; a write failure halts the enclosing operation (REQ-038 Edge 7). - Every LLM inference call is routed to the tenant's configured BYOM endpoint via an OpenAI-compatible `/v1/chat/completions` contract; unconfigured/unreachable → reject with actionable error (REQ-009). - The Relay Agent makes only outbound connections (WebSocket to SaaS). No inbound firewall rules on customer hosts. ### Architecture invariants (M2 addition) - **INV-7 at the broker (load-bearing):** 100% of write-action requests rejected at the MCP broker (HTTP 403) before adapter invocation. The broker is the load-bearing safety boundary, NOT the adapter. Per-adapter write-method blocklist: Proxmox POST/PUT/DELETE; SSH non-whitelist commands; GitHub scopes outside `metadata:read`+`actions:read`; Gitea POST/PUT/DELETE/PATCH on all endpoints. Verified by a test per adapter at the M2 gate. - **Closed tool registry (REQ-015):** 9-tool starter set locked. Per-tenant policy may disable individual tools but never add new ones. Additions require spec amendment (v1.2+). - **Defense-in-depth for SSH (REQ-021, REQ-026):** broker validates `command` against the 6-command whitelist subset BEFORE dispatch to Relay Agent (layer 1); M1 Relay Agent `CheckCommand` validates at execution (layer 2). Both layers must pass; either rejecting → HTTP 403 + `adapter.write_rejected` audit event. - **MCP capability invocations run under `withTenant` + RLS (INV-2):** every capability invocation runs in a tenant-scoped transaction. Adapters are per-tenant; no cross-tenant adapter sharing. - **Adapter credentials via SecretProvider (INV-3):** all adapter credentials (Proxmox token, GitHub/Gitea PAT, SSH registration token) resolved via `SecretProvider.get`. Credentials never logged; secret identifiers hashed in audit events. - **Audit completeness for adapter events (INV-4):** new event types appended to M1's `audit_log`: `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected`. All hash-chained, append-only, UPDATE/DELETE REVOKE'd. - **Rate limiting (REQ-019):** token-bucket per user (60 req/min) and per tenant (300 req/min). In-memory, process-local in M2. Capacity = rate; refill 1/sec (user) / 5/sec (tenant). - **SSE streaming (REQ-017):** per-call streams with ULID correlation IDs. Client disconnect cancels in-flight adapter call; no audit event for client-side cancellation. - **M1 non-regression:** all M1 REQs (001-014, 038-040) remain passing. No schema, invariant, or behavioral changes to M1 systems except additive (new tables, new audit event types). ## Components ### apps/control-plane (TypeScript, Next.js App Router API routes + standalone services) - **Description**: API gateway, tenant resolution, RBAC enforcement, BYOM registry/validation/routing, Relay Agent WebSocket server, audit writer, secret provider bootstrap, dashboard (Next.js). Hosts the admin dashboard in M1; the chat UI lands here in M3. - **Boundaries**: All inbound HTTP/WebSocket enters here. Owns the Postgres connection and the `SET app.tenant_id` discipline. Owns the Trigger.dev runtime bootstrap. - **Depends on**: `packages/db`, `packages/auth`, `packages/audit`, `packages/secrets`, `packages/config`, Trigger.dev, WorkOS, AWS Secrets Manager. ### apps/relay-agent (Go) - **Description**: Single static binary. Outbound WebSocket client to the control plane; registration (tenant/target/hostname/OS/IP/version); heartbeat + exponential-backoff reconnect (max 5 → alert); systemd service; SSH whitelist file format + enforcement hook (the SSH adapter plugs into the hook in M2 — no SSH execution in M1). - **Boundaries**: No inbound ports. Reads its tenant registration token from a local config written by the install script (token resolves to a secret-manager reference, never the raw secret). All state is in-memory or on the control plane. - **Depends on**: control-plane WebSocket endpoint; systemd; the fixed SSH whitelist file shipped alongside the binary. ### apps/dashboard (Next.js, part of apps/control-plane in M1) - **Description**: Tenant admin dashboard surfaced in the same Next.js app. M1 surfaces Relay Agent status (green/yellow/red), target hostname, last 100 log lines, BYOM config + validation, RBAC user/role management. Per-tenant view under RLS. - **Boundaries**: Server components read via the same API gateway (no DB access bypassing RLS). Client components subscribe to a status WebSocket fan-out. - **Depends on**: `apps/control-plane` API, `packages/auth`. ### packages/db (TypeScript) - **Description**: Postgres schema, migrations, RLS policies, the `withTenant(tenantId, fn)` helper that sets `app.tenant_id` and runs `fn` in a transaction. Tables: `tenants`, `users`, `tenant_memberships` (user × tenant × role), `targets`, `byom_endpoints` (URL in DB, key ref to secret manager), `audit_log` (append-only, hash-chain), `invitations`. All tenant-scoped tables carry `tenant_id` and an RLS policy. - **Boundaries**: The ONLY module that opens a Postgres connection. App role has INSERT/SELECT on `audit_log` only; UPDATE/DELETE REVOKE'd. Schema migrations run via a gated migrator. - **Depends on**: Postgres 16. ### packages/auth (TypeScript) - **Description**: WorkOS SSO/SAML integration, session management, tenant resolution middleware, RBAC role → route permission map (Admin/Operator/Viewer). Enforces RBAC at the API gateway from the first endpoint (REQ-005 pattern). - **Boundaries**: Sessions are httpOnly cookies; tokens never logged. WorkOS is the only IdP in v0.1. - **Depends on**: WorkOS SDK, `packages/db`. ### packages/audit (TypeScript) - **Description**: Append-only audit writer. Computes `curr_hash = sha256(prev_hash || canonical_payload)`. Payload includes tenant_id, user_id, target_id (for SSH), timestamp, correlation_id, event_type, event_body. On write failure: throws `AuditWriteHaltError` → enclosing operation halts + admin alert (REQ-038 Edge 7). - **Boundaries**: Never exposes UPDATE/DELETE. Read path is admin-export only (no query UI in MVP — spec §2.2). - **Depends on**: `packages/db`. ### packages/secrets (TypeScript) - **Description**: `SecretProvider` interface with two impls: `AwsSecretsManagerProvider` (prod, us-east-1, KMS-backed) and `LocalEncryptedProvider` (dev/test, AES-256-GCM with a key from an env var only for the master key — the master key is the ONE allowed env var, everything else is provider-resolved). Every tenant credential (BYOM key, Proxmox token, SSH key, Git token) is stored/retrieved via `provider.put(tenantId, name, value)` / `provider.get(tenantId, name)`. - **Boundaries**: Secrets never logged, never written to DB columns, never in config files. The DB stores only a reference (e.g. `aws-sm:coreci//byom`). - **Depends on**: AWS SDK (prod), Node crypto (dev). ### packages/config (TypeScript) - **Description**: Typed config loading (env-aware: prod/staging/dev/test). Loads non-secret config only. The only env vars consumed here are infra-level (DATABASE_URL, WORKOS_API_KEY, AWS_REGION, SECRET_MASTER_KEY_DEV, TRIGGER_API_KEY). All tenant/customer secrets go through `packages/secrets`. - **Boundaries**: Never reads tenant secrets directly. - **Depends on**: nothing. ## Components (M2 addition) ### packages/mcp (TypeScript — NEW in M2) - **Description**: The MCP capability broker gateway. Implements MCP spec version `2025-06-18`. Contains: (1) the closed tool registry (9 tools, REQ-015); (2) the adapter router (REQ-016) resolving `(tenant_id, adapter_type, target_id)` tuples to adapter instances; (3) the write-method blocklist enforcer (REQ-018, INV-7 — the load-bearing safety boundary); (4) the token-bucket rate limiter (REQ-019, in-memory, capacity = rate); (5) the SSE stream manager (REQ-017, per-call streams, ULID correlation IDs); (6) the OpenAI ↔ MCP translator module (`translator.ts`); (7) the in-process custom MCP transport for broker ↔ adapter JSON-RPC. - **Boundaries**: The broker NEVER invokes an adapter for a write-capable method. The broker validates SSH `command` against the whitelist subset BEFORE dispatch (defense-in-depth layer 1). All capability invocations run under `withTenant` + RLS. - **Depends on**: `packages/db` (withTenant, adapter config rows), `packages/audit` (adapter event types), `packages/secrets` (adapter credentials), `packages/auth` (RBAC), `packages/config`. ### packages/mcp/adapters/proxmox (TypeScript — NEW in M2) - **Description**: Read-only Proxmox VE adapter. PVE API client over HTTPS (cookie-based auth). Capabilities: `proxmox.list_vms` (inventory, `GET /api2/json/nodes/{node}/qemu`), `proxmox.get_vm_status` (live, `GET /api2/json/nodes/{node}/qemu/{vmid}/status/current`), `proxmox.get_node_metrics` (live, `GET /api2/json/nodes/{node}/status`). Validates `PVEAuditor` role at config submit time (REQ-025). Write-method blocklist: POST/PUT/DELETE. - **Boundaries**: Calls only GET endpoints. Token stored via `SecretProvider.set` (INV-3). - **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`. ### packages/mcp/adapters/ssh (TypeScript — NEW in M2) - **Description**: Read-only SSH/Linux adapter via M1 Relay Agent. The MCP `tools/call` JSON-RPC layer is in-process (broker → TS SSH module); the TS module then calls the M1 Relay Agent (Go binary) over WebSocket (M1 infrastructure, downstream of JSON-RPC). Capability: `ssh.run_whitelisted_command` (live). Broker validates `command` against the 6-command subset (`uptime`, `df -h`, `free -m`, `systemctl status `, `journalctl -n `, `systemctl list-units --type=service`) BEFORE dispatch (layer 1); Relay Agent `CheckCommand` validates at execution (layer 2, M1 G-004 contract). - **Boundaries**: Never invokes non-whitelisted commands. Two-layer enforcement. Relay registration token stored via `SecretProvider.set` (INV-3). - **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`, M1 Relay Agent (Go binary, WebSocket). ### packages/mcp/adapters/github (TypeScript — NEW in M2) - **Description**: Read-only GitHub adapter. REST API client. Capabilities: `github.list_repos` (inventory, `GET /user/repos?per_page=100`), `github.get_recent_ci_runs` (live, `GET /repos/{owner}/{repo}/actions/runs`), `github.get_workflow_run` (live, `GET /repos/{owner}/{repo}/actions/runs/{run_id}`). Validates fine-grained PAT scopes at submit time: `metadata:read` + `actions:read` minimum (D-006). Observes `X-RateLimit-Remaining` header; backs off on 429. - **Boundaries**: Calls only REST GET endpoints. Fine-grained PAT only (no classic PAT — coarse scopes grant write). Token stored via `SecretProvider.set` (INV-3). - **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`. ### packages/mcp/adapters/gitea (TypeScript — NEW in M2) - **Description**: Read-only Gitea adapter. REST API client. Capabilities: `gitea.list_repos` (inventory, `GET /user/repos?limit=50`), `gitea.get_recent_ci_runs` (live, `GET /repos/{owner}/{repo}/actions/runs`). Version-aware scope validation: Gitea ≥1.22 requires `read:repository`; Gitea <1.22 accepts any token with broker-side write-method blocklist (POST/PUT/DELETE/PATCH) as security backstop. Version detected via `GET /api/v1/version` and recorded in adapter config row. - **Boundaries**: Calls only REST GET endpoints. Write-method blocklist enforced at broker for all versions. Token stored via `SecretProvider.set` (INV-3). - **Depends on**: `packages/mcp` (broker), `packages/secrets`, `packages/audit`. ### packages/llm-mock (TypeScript — NEW in M2, devDependency, CI-only) - **Description**: CI-only mock LLM provider for the M2 gate LLM smoke test. Implements OpenAI-compatible `/v1/chat/completions` that accepts a `tools` parameter (OpenAI tool definitions from the broker's `GET /api/mcp/tools`), returns `tool_calls` referencing one of the provided tools, accepts follow-up `tool` role messages (broker's translated adapter result), and synthesizes a grounded text response. - **Boundaries**: `devDependency` only (not a production dependency). Import-guarded against prod bundle via build-time check/eslint rule. Consumed only by CI. - **Depends on**: nothing (mock provider; the broker calls it as a BYOM endpoint). ### apps/control-plane (M2 additions) - **M2 routes**: `GET /api/mcp/tools` (list tools, MCP `tools/list` facade), `POST /api/mcp/invoke` (invoke capability, returns `{correlationId, streamUrl}`), `GET /api/mcp/stream/:correlationId` (SSE stream), `POST /api/mcp/adapter` (configure adapter), `PATCH/DELETE /api/mcp/adapter/:id` (update/remove adapter). Settings → Adapters UI + Test-Call UI in the dashboard. - **M2 schema**: `mcp_adapters` table (tenant_id, adapter_type, target_id, config JSON, secret_ref, validated, created_at) — tenant-scoped with RLS. No cache tables (in-memory only). - **M2 audit events**: `adapter.configured`, `adapter.test_connection.{succeeded,failed}`, `adapter.capability_invoked`, `adapter.write_rejected` — all appended to M1's `audit_log` with hash-chain. ### M3 consumer contract (M2 spec §9 — frozen at M2 acceptance gate) | Endpoint | Method | Purpose | M3 consumer | |:---------|:-------|:--------|:------------| | `/api/mcp/tools` | GET | List available tools (MCP `tools/list` facade) | M3 chat UI populates the LLM's `tools` parameter | | `/api/mcp/invoke` | POST | Invoke a capability; returns `{correlationId, streamUrl}` | M3 chat orchestration calls when the LLM emits `tool_calls` | | `/api/mcp/stream/:correlationId` | GET (SSE) | Stream tool execution output | M3 chat UI streams tool traces to the trace panel | | `/api/mcp/adapter` | POST | Configure an adapter | M3 does not call (M2 Settings UI only) | | `/api/mcp/adapter/:id` | PATCH/DELETE | Update/remove adapter config | M3 does not call (M2 Settings UI only) | **Stability rules:** additive changes (new tools, adapters, SSE event types) permitted; breaking changes require M3 spec amendment + deprecation period. **Open boundary (M3 design, not M2 build):** M3 needs a separate SSE endpoint for LLM token streaming (`/api/chat/stream` or similar) — distinct from MCP tool output streaming. M2's `/api/mcp/stream/:correlationId` streams tool execution output, not LLM completion tokens. ## Data Flow ### M1 happy path — Relay Agent registration (J2 steps 3–4) ``` Platform Lead ──install script──▶ customer Linux host detect-OS → install-binary → write-systemd-unit → register-target (writes tenant reg token to local config) systemd ──start──▶ relay-agent (Go binary) relay-agent ──outbound WebSocket (wss, tenant reg token)──▶ control-plane WS server control-plane: auth token → resolve tenant (RLS) → INSERT target (hostname/OS/IP/version) → audit append control-plane ──ack + target_id──▶ relay-agent relay-agent ──heartbeat every Ns──▶ control-plane (updates last_seen → green status) control-plane ──status fan-out──▶ dashboard (server-sent events / WS) ``` ### M1 happy path — BYOM configure + validate (J2 step 2) ``` Admin ──POST /api/byom (url, key)──▶ API gateway auth → tenant resolve (RLS) → RBAC (Admin only) → audit append (config event) secrets.put(tenantId, "byom", key) → returns ref INSERT byom_endpoints (tenant_id, url, secret_ref) — under RLS BYOM validator: fetch OpenAI-compatible /v1/chat/completions with a trivial test prompt on success → mark validated=true, audit append (validation ok), return green on failure → rollback insert, audit append (validation fail), return error details (Edge 11) ``` ### M3 (forward reference) — chat diagnostic (J1) ``` Operator ──prompt──▶ API gateway → auth → tenant → RBAC (Operator) → audit append (prompt) orchestrator (Trigger.dev task) → BYOM routing shim → POST tenant's BYOM /v1/chat/completions if unconfigured/unreachable → reject (REQ-009, Edge 1), audit append, halt LLM returns tool selection → MCP gateway → route to adapter (M2) → Relay Agent (SSH, M2) read-only enforced at gateway (403 on write) and Relay Agent (whitelist) tool output streams via SSE → UI trace panel → audit append (tool call + SSH command + output) LLM synthesizes cited answer → stream to UI → audit append (response) conversation persisted (tenant-scoped) → audit append ``` ### M2 happy path — adapter configure + test connection (J1) ``` Sam ──POST /api/mcp/adapter (type=proxmox, host, token)──▶ API gateway auth → tenant resolve (RLS) → RBAC (Admin only) → audit append (adapter.configured) broker validates PVEAuditor role on target (REQ-025) if role missing → HTTP 422, no persist, audit append (validation fail) secrets.set(tenantId, "proxmox:", token) → returns ref INSERT mcp_adapters (tenant_id, type, target_id, config, secret_ref, validated) — under RLS UI confirms save Sam ──clicks "Test connection"──▶ broker invokes test_connection (REQ-016) broker: write-method blocklist check (GET only for Proxmox) → rate limit check → route to adapter adapter: GET /api2/json/nodes (PVE API, cookie auth) → 200 OK broker: audit append (adapter.test_connection.succeeded) → UI green ``` ### M2 happy path — capability invocation via Test-Call UI (J2) ``` Sam/Devon ──POST /api/mcp/invoke (tool=github.list_repos, target_id, args={})──▶ API gateway auth → tenant resolve (RLS) → RBAC → audit append (adapter.capability_invoked, correlation_id=ULID) broker: write-method blocklist check (GET only) → rate limit check (60/min user, 300/min tenant) if rate exceeded → HTTP 429 + Retry-After, no adapter call broker: resolve adapter (tenant_id, github, target_id) → route (REQ-016) broker → returns {correlationId, streamUrl} to UI UI ──GET /api/mcp/stream/:correlationId (SSE)──▶ broker broker: invoke adapter (in-process custom MCP transport, tools/call JSON-RPC) adapter: secrets.get(tenantId, "github:") → PAT adapter: GET /user/repos?per_page=100 (GitHub REST, fine-grained PAT, metadata:read+actions:read) if 429 → back off (X-RateLimit-Remaining observed) if 403 → scope violation, HTTP 403 + audit append (adapter.write_rejected) adapter: normalize response → return to broker broker: emit SSE events (id=-, event=tool_result, data={content:[...],isError:false}) broker: terminal event (event=done) → close stream broker: audit append (adapter.capability_invoked, result=success) UI: render result (inventory call → "cached Xs ago" if served from 60s TTL cache) ``` ### M2 edge — SSH whitelist violation (Edge 7, defense-in-depth) ``` Sam ──POST /api/mcp/invoke (tool=ssh.run_whitelisted_command, args={command:"rm -rf /"})──▶ broker broker: validate command against 6-command subset (layer 1) "rm" not in {uptime, df, free, systemctl, journalctl} → REJECT at broker broker: HTTP 403 + audit append (adapter.write_rejected) → never invokes adapter (if broker somehow passed it: Relay Agent CheckCommand (layer 2) would also reject — defense-in-depth) ``` ### M2 edge — SSE client disconnect (Edge 8) ``` Client ──GET /api/mcp/stream/:correlationId──▶ broker (SSE stream open) Client disconnects mid-stream broker: detect EventSource close → cancel in-flight adapter call broker: clean up correlation context → no orphan adapter calls broker: NO audit event for client-side cancellation (per spec Edge 8) ``` ## Build Order (M1 waves — each a vertical slice, testable + shippable) 1. **Wave A — Foundations.** Monorepo (pnpm): `apps/control-plane`, `apps/relay-agent` (Go module), `apps/dashboard`, `packages/{db,auth,audit,secrets,config}`. Postgres schema + RLS + `withTenant`. `audit_log` hash-chain + REVOKE UPDATE/DELETE. `SecretProvider` interface + AWS SM impl + local-encrypted impl. Trigger.dev bootstrap (runtime wired, no tasks yet). Covers REQ-038, REQ-039, REQ-040. 2. **Wave B — Identity & RBAC.** WorkOS SSO; session; tenant resolution middleware (`SET app.tenant_id`); RBAC at API gateway (Admin/Operator/Viewer → route permission map). First endpoint protected on day one. Covers REQ-001, REQ-002, REQ-003, REQ-004, REQ-005. 3. **Wave C — BYOM.** Tenant-scoped BYOM endpoint registry (URL in DB, key in secret manager); validate-on-save test inference call (OpenAI-compatible); routing shim (no inference in M1 — proxy contract + REQ-009 reject path). Covers REQ-006, REQ-007, REQ-008, REQ-009. 4. **Wave D — Relay Agent.** Modular install script (detect-OS/install-binary/write-systemd-unit/register-target; clean abort on unsupported OS). Go binary: outbound wss + tenant reg token, registration (tenant/target/hostname/OS/IP/version), heartbeat + exp-backoff (max 5 → alert), auto-reconnect, systemd unit w/ auto-restart. SSH whitelist file format + enforcement hook (no adapter yet — M2 plugs in). Covers REQ-010, REQ-011, REQ-012, REQ-013, (REQ-026 whitelist hook). 5. **Wave E — Dashboard surfacing.** WebSocket fan-out of agent status to dashboard; green/yellow/red; target hostname; last 100 log lines; per-tenant view under RLS. M1 gate demo: SSO → BYOM green → install → register → green dashboard. Covers REQ-014. ## Build Order (M2 waves — each a vertical slice, testable + shippable) M2 ships on the v0.1.x patch line (M1's previous minor). Phase 0 seeds `v0.1.0`; each execution phase ships a progressive patch; the final phase's patch IS the M2 milestone release. **Wave 0 — Prerequisites (not a spec REQ; must complete before any M2 adapter work):** CI Postgres 16 container with RLS verification (replaces PGlite-only verification); real GitHub PAT available in CI (ephemeral or test-org-scoped). Retroactively validates M1's RLS claims. 0. **Phase 0 — pre-execution.** SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → MVP/UX. Ships `v0.1.0`. 1. **Wave F — MCP Gateway core.** `packages/mcp` broker: closed tool registry (9 tools), adapter router, write-method blocklist enforcer (INV-7 at broker), token-bucket rate limiter, SSE stream manager, OpenAI ↔ MCP translator, in-process custom transport. `mcp_adapters` table with RLS. Covers REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024. 2. **Wave G — Proxmox adapter.** `packages/mcp/adapters/proxmox`: PVE API client, `PVEAuditor` role validation (REQ-025), 3 capabilities (`list_vms`, `get_vm_status`, `get_node_metrics`). Covers REQ-020, REQ-025. 3. **Wave H — SSH/Linux adapter (Relay Agent).** `packages/mcp/adapters/ssh`: broker SSH whitelist validation (layer 1), downstream WebSocket to M1 Relay Agent, `ssh.run_whitelisted_command` capability. Reactivates go-engineer persona for Relay Agent integration. Covers REQ-021, REQ-026 (full — M1 shipped the hook; M2 plugs the adapter in). 4. **Wave I — Git adapters.** `packages/mcp/adapters/github` + `packages/mcp/adapters/gitea`: REST API clients, fine-grained PAT scope validation (D-006 for GitHub, version-aware for Gitea), 5 capabilities. Covers REQ-022, REQ-023, REQ-027. 5. **Wave J — SSE integration + LLM smoke + adapter UI.** `packages/llm-mock` (CI-only devDependency), LLM smoke test (OpenAI→MCP→adapter→result→synthesis against real GitHub), Settings → Adapters UI + Test-Call UI. Covers REQ-017 integration, M2 gate item 8 (LLM smoke). 6. **Final — Review + Audit + Ship.** Multi-persona review, project health audit, milestone ship v0.1.(N+1) = milestone release, merge to main. Covers all M2 (sign-off). **Wave ordering & parallelism:** F must complete first (all adapters depend on the broker). G, H, I can run in parallel after F (different adapter territories; no cross-dependencies). J depends on F + at least one adapter (for the LLM smoke against GitHub). Final depends on all.