Files
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
M2 delivers the read-only MCP capability broker gateway and four Day-1
infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027)
all pass. 656 tests green. M1 non-regression verified.

MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests).
Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec).
Two-track LLM smoke (Track A mock-path P0 gate passes).
CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification.

Phases shipped:
  P0  pre-execution          v0.1.0
  P1  Wave F — MCP gateway   v0.1.1
  P2  Wave G — Proxmox       v0.1.2
  P3  Wave H — SSH/Linux     v0.1.3
  P4  Wave I — Git adapters   v0.1.4
  P5  Wave J — SSE+smoke+UI  v0.1.5
  P6  Final — review+ship    v0.1.6 ← milestone release

---ci---
phase: 6
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027]
  partial: []
---/ci---
2026-08-25 06:14:21 +00:00

28 KiB
Raw Permalink Blame History

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/<tenantId>/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 <svc>, journalctl -n <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 34)

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:<targetId>", 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:<targetId>") → 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=<ulid>-<seq>, 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.

  1. Phase 0 — pre-execution. SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → MVP/UX. Ships v0.1.0.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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).
  7. 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.