Files
coreci-chat/.ciagent/PLAN.md
T
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

75 KiB
Raw Blame History

Plan — Milestone 2 (v0.1, M2: MCP Layer & Day 1 Adapters)

Source spec: .ciagent/steer-m2-spec.md (v1.0, locked 2026-08-25, Sarah Chen). All 9 open questions resolved; spec delta applied to REQ-015/021/027 acceptance criteria, Section 5 constraints, Section 9 M2→M3 contract freeze. M2 scope: REQ-015..027 (13 REQs). MCP capability broker gateway + 4 Day-1 adapters (Proxmox, SSH/Linux via Relay Agent, GitHub, Gitea) + SSE streaming + token-bucket rate limiting + LLM smoke + Postgres 16 CI/RLS verification. M2 acceptance gate: spec §6 (15 items). Architecture: .ciagent/ARCHITECTURE.md (M2 MCP broker section written). Decisions: .ciagent/CLARIFY.md (D-001..D-007; D-006 GitHub scopes, D-007 MCP transport). Research: .ciagent/RESEARCH.md (R-001..R-009, 7 flagged risks integrated below). Personas: .ciagent/PERSONAS.md (M2 roster: backend, data, frontend, lead, security, go-engineer for Wave H only). Requirements: .ciagent/REQUIREMENTS.md (REQ-015..027 with acceptance criteria + spec delta).

Note: This PLAN.md overwrites the M1 PLAN.md. The M1 plan is preserved in git history (commit prior to M2 overwrite). M1 is COMPLETE; all 17 M1 REQs (001-014, 038, 039, 040) must remain passing through M2 (M1 non-regression — gate item 1).


Phase mapping

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 (v0.1.6) IS the M2 milestone release. The milestone merge to main happens at the final phase.

Phase Wave Branch Patch tag REQs covered
0 pre-execution phase/00-pre-execution v0.1.0 (this plan)
1 Wave F — MCP Gateway core phase/01-mcp-gateway v0.1.1 REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024
2 Wave G — Proxmox adapter phase/02-proxmox-adapter v0.1.2 REQ-020, REQ-025
3 Wave H — SSH/Linux adapter (Relay Agent) phase/03-ssh-adapter v0.1.3 REQ-021, REQ-026 (full)
4 Wave I — Git adapters phase/04-git-adapters v0.1.4 REQ-022, REQ-023, REQ-027
5 Wave J — SSE integration + LLM smoke + adapter UI phase/05-sse-integration v0.1.5 REQ-017 integration, M2 gate item 8 (LLM smoke)
6 Final — Review + Audit + Ship phase/06-final-review-ship v0.1.6M2 milestone release all M2 (sign-off)

Wave 0 prerequisites (not a spec REQ; MUST complete before Wave F):

  • [G-011 P0] Build the CI/CD pipeline as an explicit Wave 0 task. Create .gitea/workflows/ci.yml (Gitea Actions — the repo's forge is Gitea at git.cloudinit.dev; Gitea Actions is GitHub Actions-compatible, same YAML syntax + secrets.* context + service containers) with two jobs: test-pglite (default) + test-postgres (service container + DB_MODE=pg). Postgres 16 service container with coreci_app (no BYPASSRLS) + migrator (BYPASSRLS) role setup via setup-ci-roles.sql (R-009). GitHub smoke PAT as Gitea Actions repository secret (secrets.GITHUB_SMOKE_PAT — a GitHub API token for the Track-B real-GitHub adapter smoke, stored in Gitea's secret store). Caching for pnpm + go modules. Coverage upload. This is M2 work, not an external prerequisite. Without it, 9 of 15 gate items are unprovable.
  • [G-022 P1] Run the full M1 test suite against Postgres 16 in Wave 0. After CI is provisioned, run the entire M1 test suite with DB_MODE=pg. Any M1 RLS failure (cross-tenant SELECT, missing FORCE ROW LEVEL SECURITY, wrong WITH CHECK) is an M1-regression P0 that blocks M2 until fixed. Wave 0 is the first real-RLS test of M1.
  • Real GitHub PAT available in CI (ephemeral or test-org-scoped, stored as repository secret) for the Wave I real-target smoke + Wave J LLM smoke (gate item 8). Required for the optional real-GitHub smoke track (G-018); the P0 mock-path smoke does not depend on it.

Grill fixes applied (G-011..G-022)

This plan was grilled (.ciagent/GRILL.md, verdict FAIL → auto-resolved at full autonomy with all 12 binding fixes applied). The 12 binding fixes (continuing from M1 GRILL's G-001..G-010) are integrated below and flagged inline as [G-NNN]. Summary:

  • G-011 (P0): Build the CI/CD pipeline as an explicit Wave 0 task (not a "prerequisite"). Applied above.
  • G-012 (P1): Own the M1 AuditEventType union extension as an explicit M1-file edit in Wave F (type widening, not "additive — no schema change" at the TS layer).
  • G-013 (P1): Expand the cross-layer SSH test to a divergence matrix in Wave H (both-reject, both-accept, broker-rejects-Go-accepts, Go-accepts-broker-rejects). Tighten the Go deny list to include bare rm or validate systemctl status trailing args.
  • G-014 (P1): Document the known closed-tool-set gaps in the Settings → Adapters UI help text in Wave J.
  • G-015 (P1): Correct the INV-7 framing: the closed 9-tool registry (REQ-015) is the primary boundary; the write-blocklist is a backstop for adapter bugs. Document in Wave F PROTOCOL.md.
  • G-016 (P1): Separate the write-blocklist into two enforcement models (method blocklist for Proxmox/Gitea; scope-via-403 for GitHub). Document GraphQL/PVE-GET-with-side-effects future risks in PROTOCOL.md.
  • G-017 (P1): Add a 7th MCP conformance test over stdio transport in Wave F (stdio-interop.test.ts) — proves an external MCP client can connect.
  • G-018 (P0): Add a github-mock adapter and a two-track LLM smoke in Wave J. Mock-path (canned repos) is the P0 gate; real-path (real GitHub) is optional/allow-failure.
  • G-019 (P0): Harden packages/llm-mock pattern matching (regex set, not 2-word conjunction) + add retry policy for real-GitHub smoke in Wave J.
  • G-020 (P1): Ship a documented McpAdapter interface in Wave F that both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement.
  • G-021 (P1): Add M1-relay-WS regression test in Wave H + restructure the Go agent reader goroutine to dispatch on type before unmarshaling.
  • G-022 (P1): Run the full M1 test suite against Postgres 16 in Wave 0. Applied above.

Flagged risk mitigations (from RESEARCH R-001..R-009)

For each of the 7 flagged risks, the wave that addresses it and how:

# Risk Wave Mitigation
R-001 Synthetic MCP initialize/initialized handshake for in-process adapters (lowest-confidence gate item 15) Wave F Implement a lightweight synthetic lifecycle exchange at adapter registration (broker → {method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}, adapter → {capabilities:{tools:{}}}). Produce packages/mcp/PROTOCOL.md + 6 conformance tests in tests/mcp-conformance/ (gate item 15 artifact).
R-002 PVEAuditor role introspection gap (PVE has no clean "what role does this token have" endpoint) Wave G Broker validates "token works for reads" (GET /api2/json/version + GET /api2/json/nodes) at submit, NOT "token lacks writes." Document the introspection gap in the Settings → Adapters UI help text ("Create a token with PVEAuditor role"). The broker write-method blocklist (reject POST/PUT/DELETE) is the load-bearing INV-7 boundary. Confidence 0.70 on this sub-point.
R-004 GitHub fine-grained PAT scope introspection gap (no public API to list a fine-grained PAT's granted scopes) Wave I Submit-time: github_pat_ prefix check (reject classic PATs) + GET /user (validates token + implicit metadata:read). Per-invocation: handle 403 with X-Accepted-GitHub-Permissions header (e.g., actions=read required) → HTTP 403 "insufficient scope" + adapter.capability_invoked with result=failure (NOT write_rejected — no write attempted). Document in UI.
R-005 Gitea runtime verification (docs page required JS; findings from spec + GitHub-mirroring conventions) Wave I Verify GET /api/v1/version, GET /api/v1/user/repos, GET /api/v1/repos/{owner}/{repo}/actions/runs against a running Gitea 1.22+ AND a <1.22 instance during Wave I. Confirm read:repository scope behavior (≥1.22) and the Authorization: token <token> header. Confidence 0.72 → raise by runtime verification.
R-003 Cross-layer SSH defense-in-depth test (two independent whitelist implementations: TS broker layer 1, Go agent layer 2) Wave H Add a cross-layer test asserting a non-whitelisted command (rm -rf /) is rejected by BOTH the broker (layer 1, TS) AND the Relay Agent CheckCommand (layer 2, Go). Keep the two implementations semantically in sync but code-independent (a bug in one doesn't bypass the other).
R-006 30s stream-not-opened timeout (orphan adapter calls if client never opens the SSE stream) Wave F Stream manager enforces a 30s "stream not opened" timeout: if GET /api/mcp/stream/:correlationId isn't called within 30s of POST /api/mcp/invoke, cancel the adapter call and delete the correlation context. Plus a 60s max-stream lifetime safety net for orphaned contexts.
R-009 Wave 0 RLS assertions (M1 pen-test has placeholder expect(true).toBe(true) because PGlite doesn't enforce RLS on SELECT) Wave 0 Deliver packages/db/scripts/setup-ci-roles.sql (coreci_app no BYPASSRLS, migrator BYPASSRLS). Parameterize the pen-test by DB_MODE (pglite default, pg in CI). Replace placeholder with real RLS WITH CHECK assertions gated on DB_MODE=pg. Two CI jobs: test-pglite + test-postgres (service container).

Additional research decisions integrated (D-M2-R001..R009, all above 0.6 threshold): D-M2-R004 GitHub classic-PAT rejection by prefix; D-M2-R005 Gitea version-aware scope routing; D-M2-R006 SSE no-audit-on-client-cancel; D-M2-R007 token-bucket refund-on-tenant-fail + no-audit-on-429; D-M2-R008 packages/llm-mock import-guarding (eslint no-restricted-imports + build-time grep).


User-Facing Surface (MVP/UX §1)

M2 ships TWO user-facing surfaces in the existing M1 dashboard (apps/control-plane/app/(dashboard)/). Non-UI surfaces (the MCP broker gateway, the 4 adapters, the packages/llm-mock CI-only smoke provider, the mcp_adapters Postgres table) are backend and verified by tests, not by user demonstration.

Surface 1 — Settings → Adapters configuration UI (/dashboard/settings/adapters)

  • Adapter type picker: Proxmox / GitHub / Gitea / SSH (the 4 Day-1 adapter types; closed set, no "custom adapter" option).
  • Per-adapter config forms: type-specific fields:
    • Proxmox: host (HTTPS URL), PVEAuditor token (secret), allowSelfSigned toggle (R-002 — common for customer PVE labs).
    • GitHub: host (defaults to api.github.com), fine-grained PAT (secret). Help text: "Create a fine-grained PAT with metadata:read + actions:read minimum. Classic PATs (ghp_) are rejected."
    • Gitea: host (customer Gitea URL), read-only token (secret), allowSelfSigned toggle. Help text: "Gitea ≥1.22 requires read:repository scope. Gitea <1.22 accepts any token (broker enforces write-method blocklist)."
    • SSH: hostname, port (default 22), Relay registration token (secret; resolves to the M1 Relay Agent target). Help text: "Install the M1 Relay Agent on the target host first; paste the registration token here."
  • SecretProvider-backed credential entry: all secrets enter via SecretProvider.put (INV-3); the DB stores only secret_ref. The UI never displays the raw secret after submit (redacted, edit-only).
  • Role/scope validation on submit (REQ-025, REQ-026, REQ-027): the broker validates the token at submit time and returns a structured pass/fail. On failure → HTTP 422 with role/scope-violation error, no config persisted, UI shows the error inline.
  • "Test connection" button: invokes the test_connection capability (REQ-016) through the closed tool registry; returns structured pass/fail within 5s (spec Journey 1 Step 7).
  • Audit event confirmation: after a successful save, the UI shows "Saved (audit event adapter.configured appended)."
  • Multi-target support (REQ-024): the config form supports multiple adapters of the same type (e.g., 2 Proxmox hosts). Each row has a target_id (display name) so the Test-Call UI's target picker can disambiguate.
  • PVEAuditor introspection gap (R-002): the Proxmox config form's help text documents that the broker validates "token works for reads," not "token lacks writes," and the operator is responsible for creating a PVEAuditor-scoped token.

Surface 2 — Test-Call UI (/dashboard/test-call)

  • Capability picker: the closed 9-tool set from GET /api/mcp/tools (REQ-015). Tools are grouped by adapter type in the dropdown. Per-tenant policy may disable individual tools (greyed out in the picker).
  • Argument forms: each tool's arguments are rendered from the tool's JSON Schema inputSchema (REQ-015) — required fields marked, type-validated on submit. Invalid args → inline validation error (mirrors the broker's HTTP 400 schema-validation error, Edge 4).
  • Target picker for multi-target tenants (REQ-024): when a tenant has ≥2 adapters of the same type, the UI surfaces a target picker. If the user submits a same-type capability without selecting a target, the broker returns HTTP 400 "target required" (Edge 3) and the UI prompts for selection.
  • SSE stream consumer (REQ-017): the UI opens an EventSource on GET /api/mcp/stream/:correlationId and renders events as they arrive (id, event, data fields per SSE spec). Terminal events done (completion) / error (failure) close the stream.
  • Staleness indicator for inventory calls: for list_* capabilities (inventory, 60s TTL cache), the UI shows "cached Xs ago" when the result is served from cache. For live capabilities (non-list_*), no staleness indicator (fresh result). (Spec Journey 2 Step 6.)
  • Result rendering: tool results render as JSON in a collapsible tree. Errors render with the isError: true flag surfaced.

Non-UI surfaces (verified by tests, not user demo)

  • The MCP broker gateway (packages/mcp): registry, router, write-blocklist, rate-limiter, stream-manager, translator, in-process transport.
  • The 4 adapters (packages/mcp/adapters/{proxmox,ssh,github,gitea}).
  • The packages/llm-mock CI-only LLM smoke provider (devDependency, import-guarded).
  • The mcp_adapters Postgres table (tenant-scoped, RLS).
  • The 5 gateway REST+SSE endpoints (M2→M3 contract freeze, spec §9).

Happy Path (MVP/UX §2)

Given a fresh M2 deployment with M1 complete (SSO, BYOM, Relay Agent, audit, RLS, secrets operational) and Wave 0 prerequisites met (CI Postgres 16 with role setup, real GitHub PAT available in CI), when Sam (Tenant Admin) configures and validates all four Day-1 adapters and runs a Test-Call, then the M2 acceptance gate (spec §6) passes: all 4 adapters respond to a test call, multi-target scoping is functional, read-only enforcement is verified at the broker (INV-7), LLM smoke passes, M1 non-regression.

BDD steps (Given/When/Then) — written BEFORE EXECUTE

Adapter config + validation (Journey 1):

  • Given Sam is signed in via WorkOS SSO (M1) as admin, when Sam navigates to Settings → Adapters and clicks "Add adapter", then the adapter type picker shows exactly 4 types (Proxmox, GitHub, Gitea, SSH).
  • Given Sam selects "Proxmox" and enters host + a PVEAuditor token, when Sam submits, then the broker validates the token (GET /api2/json/version + GET /api2/json/nodes succeed — R-002), SecretProvider.put stores the token, the mcp_adapters row is inserted under withTenant + RLS, adapter.configured is appended to audit_log, and the UI confirms save in <5s.
  • Given a Proxmox adapter is configured, when Sam clicks "Test connection", then the broker invokes test_connection through the closed tool registry, returns a structured pass/fail within 5s, and adapter.test_connection.succeeded (or .failed) is appended.
  • Given Sam submits a Proxmox token that fails GET /api2/json/version (invalid token), when the broker validates, then submission returns HTTP 422 with role-violation error and no config is persisted (REQ-025).
  • Given Sam submits a GitHub classic PAT (ghp_...), when the broker validates scope, then submission returns HTTP 422 "fine-grained PAT required" and no config is persisted (D-006).
  • Given Sam submits a GitHub fine-grained PAT (github_pat_...) that passes GET /user, when the broker validates, then the config is persisted with validated=true (implicit metadata:read); actions:read is validated per-invocation (R-004).
  • Given Sam submits a Gitea token for a Gitea ≥1.22 instance, when the broker calls GET /api/v1/user/repos?limit=1 and it returns 403, then submission returns HTTP 422 "insufficient scope — read:repository required" (R-005).
  • Given Sam submits a Gitea token for a Gitea <1.22 instance, when the broker validates, then the token is accepted (any valid token) with the broker-side write-method blocklist as the security backstop.
  • Given Sam submits an SSH adapter config (hostname + Relay registration token), when the broker stores the token via SecretProvider.put, then the mcp_adapters row is persisted and adapter.configured is appended (REQ-026).

Test-Call capability invocation (Journey 2):

  • Given a Proxmox adapter is configured and rate limits are not exceeded, when Sam invokes proxmox.list_vms (with node arg) via the Test-Call UI, then the broker mints a ULID correlation ID, returns {correlationId, streamUrl}, the UI opens an SSE stream, the adapter calls GET /api2/json/nodes/{node}/qemu, the stream emits tool_result events and terminates with done, and adapter.capability_invoked is appended with correlation_id.
  • Given proxmox.list_vms was invoked within the last 60 seconds, when Sam invokes it again, then the broker returns the cached result with a "cached Xs ago" staleness indicator in the UI (inventory TTL cache, LRU).
  • Given Sam invokes a non-list_* capability (e.g., proxmox.get_node_metrics), when the call completes, then the UI shows the fresh result with NO staleness indicator (live path).
  • Given Sam invokes github.list_repos via the Test-Call UI, when the adapter calls GET /user/repos?per_page=100 (real GitHub PAT), then the normalized repo list streams back and the UI renders it.
  • Given Sam invokes ssh.run_whitelisted_command with command: "uptime", when the broker validates (layer 1) and dispatches to the Relay Agent, then the agent's CheckCommand (layer 2) passes, exec.Command("uptime") runs (no shell), and the output streams back.

Write rejection at broker (Edge 1, INV-7):

  • Given a malicious payload attempts proxmox.shutdown_vm via POST /api/mcp/invoke, when the request reaches the broker, then the broker's write-method blocklist rejects with HTTP 403, adapter.write_rejected is appended, and the adapter is NEVER invoked (verified by test per adapter at the M2 gate).

SSH whitelist violation (Edge 7, defense-in-depth):

  • Given Sam attempts ssh.run_whitelisted_command with command: "rm -rf /", when the broker validates (layer 1, 6-command subset), then the broker rejects with HTTP 403 + adapter.write_rejected (the command is not in the 6-command subset) — the Relay Agent is never reached. And the cross-layer test (R-003) asserts that IF the broker were bypassed, the Relay Agent CheckCommand (layer 2) would also reject.

Multi-target disambiguation (Edge 3):

  • Given a tenant has 2 Proxmox adapters configured (target_a, target_b), when Sam invokes proxmox.list_vms without selecting a target_id, then the broker returns HTTP 400 "target required" with a list of available targets, and the UI surfaces the target picker.

Rate limit (Edge 5):

  • Given Sam has exceeded 60 req/min (user bucket), when Sam invokes any capability, then the broker returns HTTP 429 with Retry-After header and no adapter call is made (REQ-019).
  • Given the tenant has exceeded 300 req/min (tenant bucket), when any user in that tenant invokes a capability, then the broker returns HTTP 429 with Retry-After and refunds the user token (fairness, D-M2-R007).

SSE client disconnect (Edge 8):

  • Given an SSE stream is open on GET /api/mcp/stream/:correlationId and the client disconnects mid-stream, when the broker detects the EventSource close (req.signal abort), then the in-flight adapter call is cancelled, the correlation context is deleted, and NO audit event is appended for the client-side cancellation (spec Edge 8).

LLM smoke (M2 gate item 8, P0 — not deferrable):

  • Given CI starts the control plane with packages/llm-mock as the BYOM endpoint and a real GitHub adapter (real PAT, test-org-scoped), when the smoke test sends POST /v1/chat/completions with tools=[github.list_repos] and prompt "List my GitHub repositories.", then llm-mock returns tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}], the broker translates to MCP tools/call, routes to the GitHub adapter, the adapter calls GET /user/repos (real GitHub), the broker translates the result to an OpenAI tool message, llm-mock synthesizes a grounded response, and the test asserts the response contains real repo names from the CI test org.

UX Acceptance Criteria (MVP/UX §3)

Pass/fail criteria (all must pass for M2 ship):

  • Adapter config saves in <5s with validation feedback (success or HTTP 422 role/scope-violation error inline).
  • "Test connection" returns in <5s with structured pass/fail; adapter.test_connection.{succeeded,failed} audit event appended.
  • Test-Call SSE stream starts within 100ms of POST /api/mcp/invoke returning {correlationId, streamUrl} (NFR: SSE chunk delivery <100ms).
  • Staleness indicator shows "cached Xs ago" for cached inventory (list_*) calls; no indicator for live calls.
  • Write attempts rejected at broker with 403 before adapter invocation (INV-7 — verified by a test per adapter at the M2 gate; the adapter is never invoked for write methods).
  • SSH non-whitelist commands rejected at broker (layer 1) AND Relay Agent (layer 2) (defense-in-depth, R-003 cross-layer test).
  • Multi-target picker surfaces when ≥2 same-type adapters are configured; submitting without target_id returns HTTP 400 "target required."
  • Rate limit 429 with Retry-After when user >60/min OR tenant >300/min; no adapter call made on 429.
  • LLM smoke returns grounded response with real repo names from the CI test org (P0 gate item 8 — not deferrable to M3).
  • M1 non-regression: all M1 tests still pass (gate item 1).
  • Coverage ≥ 80% on new M2 modules (packages/mcp/**, packages/llm-mock, adapter UI components) (gate item 3).

Wave F — MCP Gateway core (Phase 1)

Goal: The MCP capability broker gateway: closed tool registry, adapter router, write-method blocklist enforcer (INV-7 at broker — the load-bearing safety boundary), token-bucket rate limiter, SSE stream manager, OpenAI↔MCP translator, in-process custom transport, synthetic MCP initialize handshake (R-001). mcp_adapters table with RLS. No adapter implementations yet (those are waves G/H/I) — Wave F ships the broker with stub adapters for testing.

Depends on: Phase 0, Wave 0. REQs covered: REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-024. Personas: backend-engineer (broker modules), data-engineer (table + RLS), security-engineer (sign-off on write-method blocklist + INV-7 at broker). Patch tag: v0.1.1. Branch: phase/01-mcp-gateway.

Tasks

  1. mcp_adapters table + migration + RLS (data-engineer)

    • Migration packages/db/migrations/0002_mcp_adapters.sql: mcp_adapters (id uuid PK, tenant_id uuid, adapter_type text CHECK in (proxmox,ssh,github,gitea), target_id text, config jsonb, secret_ref text, validated boolean, created_at timestamptz, updated_at timestamptz). UNIQUE (tenant_id, adapter_type, target_id). RLS policy: tenant-scoped SELECT/INSERT/UPDATE/DELETE with WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid). ALTER TABLE ... FORCE ROW LEVEL SECURITY.
    • Verify against real Postgres 16 in CI (Wave 0 DB_MODE=pg job), not PGlite.
  2. packages/mcp/registry.ts — closed 9-tool registry with JSON Schema inputSchemas (backend-engineer)

    • 9 tools (REQ-015 frozen set): proxmox.list_vms (inventory, {node: string}), proxmox.get_vm_status (live, {node: string, vmid: integer}), proxmox.get_node_metrics (live, {node: string}), ssh.run_whitelisted_command (live, {command: string}), github.list_repos (inventory, {}), github.get_recent_ci_runs (live, {owner: string, repo: string, per_page?: integer, status?: string}), github.get_workflow_run (live, {owner: string, repo: string, run_id: integer}), gitea.list_repos (inventory, {}), gitea.get_recent_ci_runs (live, {owner: string, repo: string, limit?: integer}).
    • Each tool: {name, description, inputSchema} (JSON Schema object with type:"object"). Export MCP_PROTOCOL_VERSION = "2025-06-18".
    • Per-tenant policy: disabledTools: Set<toolName> — may disable individual tools but never add new ones (closed registry). Registry metadata isInventory: boolean is the cache authority (NOT MCP annotations, which are advisory/untrusted per R-001).
    • Argument validation: any tool call with args not matching inputSchema → HTTP 400 with schema-validation error (Edge 4) BEFORE adapter invocation.
  3. packages/mcp/router.ts — adapter router (backend-engineer)

    • Resolves (tenant_id, adapter_type, target_id) tuples to adapter instances (REQ-016). Reads mcp_adapters under withTenant + RLS. Routing errors (unknown adapter, target offline) → HTTP 404 with structured error.
    • Multi-target scope disambiguation (REQ-024): if a tenant has ≥2 adapters of the same type and no target_id is provided, return HTTP 400 "target required" with a list of available targets (Edge 3).
  4. packages/mcp/write-blocklist.ts — per-adapter write-method blocklist enforcer (security-engineer + backend-engineer) — INV-7 BACKSTOP [G-015, G-016]

    • [G-015] Framing correction: The closed 9-tool registry (REQ-015) is the PRIMARY INV-7 boundary — proxmox.shutdown_vm is not a tool and cannot be routed. The write-method blocklist is defense-in-depth against adapter bugs (an adapter mistakenly constructing a non-GET). Security review must audit BOTH the registry (closed enumeration) AND the blocklist (method reject). Document this framing in PROTOCOL.md and the module docstring.
    • [G-016] Two enforcement models: (a) Method blocklist (Proxmox/Gitea: pre-dispatch HTTP-method check — reject POST/PUT/DELETE/PATCH; REST-specific). (b) Scope-via-403 (GitHub: runtime 403 + X-Accepted-GitHub-Permissions handling, per R-004 — NOT a pre-dispatch method check, because GitHub fine-grained PAT scopes are not introspectable). These are distinct mechanisms; do not conflate them.
    • [G-016] Future risks documented in PROTOCOL.md: "The method blocklist is REST-specific. A future GraphQL adapter (not in M2) needs a different enforcement model (operation allowlist, not HTTP method — GraphQL uses POST for both queries and mutations). PVE has some GET endpoints with side effects; the 3 M2 endpoints (/nodes, /nodes/{node}/qemu, /nodes/{node}/qemu/{vmid}/status/current, /nodes/{node}/status) are verified read-only. An endpoint allowlist (only permit specific paths) is the M3+ evolution if the tool set grows."
    • Per-adapter blocklist (spec §5): Proxmox POST/PUT/DELETE; SSH non-whitelist commands (6-command subset per REQ-021); GitHub scopes outside metadata:read+actions:read (validated per-invocation via 403 + X-Accepted-GitHub-Permissions); Gitea POST/PUT/DELETE/PATCH on all endpoints.
    • 100% of write attempts rejected at broker with HTTP 403 + adapter.write_rejected audit event; adapter NEVER invoked. Verified by a test per adapter at the M2 gate.
    • Order of enforcement (R-007): auth → tenant resolve → RBAC → rate-limit checkwrite-method blocklist → adapter resolve → invoke. Rate limit is the outermost gate; write-blocklist is the INV-7 gate.
  5. packages/mcp/rate-limiter.ts — token-bucket, in-memory (backend-engineer)

    • Per user (capacity=60, refill=1/sec) AND per tenant (capacity=300, refill=5/sec). Both must pass (AND logic). Refund user token on tenant-fail (fairness, D-M2-R007). O(1) check <5ms (NFR).
    • RateLimiter interface Promise-returning now (M2 sync impl wrapped in Promise) so M3 can swap in RedisRateLimiter with no signature change (D-M2-R007). Redis migration path documented in code comments.
    • On reject: HTTP 429 + Retry-After: <seconds> header (RFC 7231). Body {error:"rate_limited", retryAfterSec}. No adapter call made. Do NOT audit 429s (not adapter events; could amplify a flood — log at warn level instead).
  6. packages/mcp/stream-manager.ts — SSE stream manager (backend-engineer)

    • In-memory Map<correlationId, CorrelationContext>. Context: {correlationId, tenantId, userId, adapterType, toolName, controller?, abortController, createdAt}.
    • ULID correlation IDs (26-char, lexicographically sortable) minted at POST /api/mcp/invoke (add ulid npm dep to packages/mcp). SSE event format: id: <ulid>-<seq>\nevent: tool_result\ndata: {"content":[...],"isError":false}\n\n. Terminal events done/error.
    • 30s stream-not-opened timeout (R-006): if GET /api/mcp/stream/:correlationId isn't called within 30s of POST /invoke, cancel the adapter call and delete the context. Plus 60s max-stream lifetime safety net.
    • Client disconnect (Edge 8): on req.signal abort, cancel in-flight adapter call (AbortController), delete context, NO audit event for client-side cancellation. Guard with a closed flag (abort may fire after normal close).
    • Backpressure: cap controller queue at 100 events; if exceeded, cancel with "client too slow."
  7. packages/mcp/translator.ts — OpenAI↔MCP translation (backend-engineer)

    • tool_calls[i].function.nameparams.name; JSON.parse(tool_calls[i].function.arguments)params.arguments (parsed JSON object — pitfall: OpenAI sends arguments as a JSON string; MCP expects an object; handle parse failures as protocol errors, not tool execution errors).
    • MCP result.content[].text + isError → OpenAI tool message {role:"tool", tool_call_id, content}. isError:falsecontent: result.content[0].text (concatenate if multiple). isError:truecontent: "ERROR: " + result.content[0].text (M2 convention; OpenAI has no native isError).
  8. packages/mcp/transport/in-process.ts — in-process custom MCP transport with synthetic initialize/initialized handshake (R-001) (backend-engineer)

    • JSON-RPC 2.0 messages (tools/list, tools/call) passed in-process between broker and TS adapter modules (no wire serialization, but shape must match). D-007.
    • Synthetic lifecycle exchange at adapter registration (R-001 mitigation): broker → {method:"initialize", params:{protocolVersion:"2025-06-18", capabilities:{tools:{listChanged:false}}}}, adapter → {capabilities:{tools:{}}}. Cheap; produces clean conformance evidence.
  9. API routes (backend-engineer + frontend-engineer for route handlers)

    • GET /api/mcp/tools — list tools (MCP tools/list facade; returns 9-tool closed set; per-tenant disabled tools filtered).
    • POST /api/mcp/invoke — invoke a capability; mints ULID, creates correlation context, kicks off adapter call async, returns {correlationId, streamUrl}. Order: auth → tenant → RBAC → rate-limit → write-blocklist → resolve → invoke.
    • GET /api/mcp/stream/[correlationId]/route.ts — SSE stream (runtime = "nodejs", dynamic = "force-dynamic" per R-006). Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive.
    • POST /api/mcp/adapter — configure an adapter (J1 Step 3). Validates role/scope at submit (REQ-025/026/027), SecretProvider.put, INSERT under withTenant + RLS, adapter.configured audit.
    • PATCH/DELETE /api/mcp/adapter/:id — update/remove adapter config.
    • All routes use M1 patterns: requireAdmin/requireRead auth guard (mirrors apps/control-plane/app/api/byom/route.ts), withTenant, appendAudit.
  10. Audit event types (backend-engineer) [G-012]

    • [G-012] M1-file edit (type widening, NOT "additive — no schema change" at the TS layer): Extend AuditEventType in packages/db/src/audit.ts (M1 source file) with adapter.configured | adapter.test_connection.succeeded | adapter.test_connection.failed | adapter.capability_invoked | adapter.write_rejected. The DB column (audit_log.event_type) is TEXT with no CHECK constraint, so no DB migration. But appendAudit(client, event: AuditEvent) is typed to event.eventType: AuditEventType — passing the new types is a TS compile error without the union extension. This is a backward-compatible type widening (M1 tests still pass).
    • New event types: adapter.configured, adapter.test_connection.succeeded, adapter.test_connection.failed, adapter.capability_invoked, adapter.write_rejected. All hash-chained via M1 appendAudit (packages/db/src/audit.ts). Include correlation_id field for capability invocations.
  11. Multi-target scope disambiguation (REQ-024) (backend-engineer)

    • Broker returns HTTP 400 "target required" with a list of available targets when ≥2 same-type adapters exist and no target_id is provided (Edge 3).
  12. MCP conformance verification artifact (R-001) (backend-engineer + security-engineer) [G-017]

    • tests/mcp-conformance/ (7 tests, all must pass — gate item 15):
      1. tools-list.test.tsGET /api/mcp/tools returns 9 tools with {name, description, inputSchema} matching REQ-015 exactly. Snapshot the full tools/list response.
      2. tools-call-happy.test.ts — mock adapter invocation returns MCP result shape {content:[{type:"text",text}], isError:false} via SSE.
      3. tools-call-error.test.ts — mock adapter isError:true returns MCP error shape via SSE error terminal event.
      4. tools-call-invalid-args.test.ts — args failing inputSchema → HTTP 400 schema-validation error (broker rejects before adapter).
      5. translator.test.ts — OpenAI tool_calls ↔ MCP tools/call bidirectional translation, including arguments string→object parse and isError→content prefix.
      6. lifecycle.test.ts — in-process custom transport synthetic initialize/initialized handshake preserves JSON-RPC 2.0 envelope.
      7. [G-017] stdio-interop.test.ts — connects to the broker via stdio transport (the real-transport path used by the LLM smoke), issues a real tools/list JSON-RPC request over stdin/stdout, asserts the response is a valid JSON-RPC 2.0 envelope with the 9 tools, then issues a tools/call for a mock adapter and asserts the result shape. This is the test that proves an external MCP client can connect — moves the lowest-confidence axis (0.80) to evidence-backed. Optionally: run the official MCP inspector against the broker as a CI step.
    • packages/mcp/PROTOCOL.md documenting: pinned spec version 2025-06-18 with links to the three spec pages (tools, transports, lifecycle); transports used (in-process custom, stdio for LLM smoke, REST facade + SSE for UI — NOT Streamable HTTP, compliant as custom transport); JSON-RPC 2.0 shapes preserved; OpenAI ↔ MCP translation contract; synthetic lifecycle handshake. [G-015] INV-7 framing (registry is primary, blocklist is backstop). [G-016] Two enforcement models + GraphQL/PVE-GET future risks.
    • MCP_PROTOCOL_VERSION = "2025-06-18" constant exported from packages/mcp and asserted in the conformance test header.
  13. Stub adapters for testing (backend-engineer) [G-020]

    • [G-020] McpAdapter interface shipped in packages/mcp/types.ts: both in-process adapters (G/I) and the WebSocket-backed SSH adapter (H) implement this interface. The interface specifies: tools/list() → Promise<Tool[]>, tools/call(name: string, args: Record<string, unknown>) → Promise<McpResult>, and a registration mechanism. F's stubs implement it; G/H/I's real adapters implement it. The router (T3) accommodates both in-process module adapters and WebSocket-backed adapters (the SSH adapter wraps a WebSocket round-trip inside tools/call). This makes F→G/H/I a contract handoff, not a code-reading exercise — enables real parallelism.
    • Minimal mock adapters (one per type: proxmox, ssh, github, gitea) that the broker can route to, for testing the broker in isolation (waves G/H/I plug in real adapters). Each stub: registers via synthetic initialize, responds to tools/list with its tool subset, responds to tools/call with a canned {content:[{type:"text",text:"stub"}], isError:false} or isError:true for error tests. All stubs implement McpAdapter.

Must-haves (verify before ship)

  • Closed tool registry: 9 tools with name, description, inputSchema (JSON Schema per MCP 2025-06-18).
  • Write-method blocklist: 100% of write attempts rejected at broker with 403 + audit event, adapter never invoked (test per adapter type with stubs).
  • Rate limiter: 60/min user + 300/min tenant enforced, HTTP 429 + Retry-After, refund-on-tenant-fail, no audit on 429.
  • SSE stream: per-call, ULID correlation IDs, terminal events done/error, 30s stream-not-opened timeout (R-006), <100ms chunk delivery.
  • Client disconnect (Edge 8): in-flight adapter call cancelled, no audit event for client-side cancellation.
  • Multi-target: ≥2 same-type adapters without target_id → HTTP 400 "target required" with available targets list.
  • MCP conformance artifact: PROTOCOL.md + 6 tests passing (gate item 15).
  • Synthetic initialize/initialized handshake for in-process adapters (R-001).
  • OpenAI↔MCP translator: tool_callstools/call (with JSON.parse(arguments)); result.content + isError → tool message.
  • mcp_adapters table with RLS (tenant-scoped, verified against real Postgres 16 in CI per Wave 0).
  • Audit events: adapter.configured, adapter.test_connection.{succeeded,failed}, adapter.capability_invoked, adapter.write_rejected — all hash-chained via M1 appendAudit.
  • Coverage ≥ 80% on packages/mcp.
  • M1 non-regression: all M1 tests still pass.
  • Security-engineer sign-off on write-method blocklist + INV-7 at broker (blocks ship on P0/P1 finding).

Wave G — Proxmox adapter (Phase 2)

Goal: Read-only Proxmox VE adapter with PVEAuditor role validation. 3 capabilities: proxmox.list_vms (inventory), proxmox.get_vm_status (live), proxmox.get_node_metrics (live). Depends on: Wave F (broker). REQs covered: REQ-020, REQ-025. Personas: backend-engineer (adapter), security-engineer (sign-off on PVEAuditor validation + write-blocklist). Patch tag: v0.1.2. Branch: phase/02-proxmox-adapter.

Tasks

  1. packages/mcp/adapters/proxmox/client.ts (backend-engineer)

    • PVE API client (HTTPS, port 8006, cookie/token auth). Uses API Token auth (NOT ticket/cookie — stateless, no 2h expiry, no CSRF needed for tokens per R-002): Authorization: PVEAPIToken=USER@REALM!TOKENID=UUID header on every GET.
    • pveGet(host, token, path, allowSelfSigned): global fetch with AbortSignal.timeout(10_000) (10s upstream NFR, mirrors packages/byom/src/validator.ts pattern). allowSelfSigned per-adapter config flag → https.Agent({rejectUnauthorized: false}) for customer PVE labs (R-002 pitfall).
    • Unwrap {data: <payload>} response shape. Check both !res.ok AND body.data === null (PVE returns null data for some not-found cases). 5xx → HTTP 502/504 to caller (transient upstream error, NOT write rejection).
    • Endpoints (R-002, verified): GET /api2/json/nodes, GET /api2/json/nodes/{node}/qemu, GET /api2/json/nodes/{node}/qemu/{vmid}/status/current, GET /api2/json/nodes/{node}/status. Pitfall: /api2/json/qemu is NOT a valid endpoint (qemu is under a node). No version branching needed (endpoints stable PVE 6.x8.x, R-002).
  2. packages/mcp/adapters/proxmox/adapter.ts (backend-engineer)

    • Implements the 3 capabilities (GET endpoints only — never POST/PUT/DELETE):
      • proxmox.list_vms (inventory): GET /api2/json/nodes/{node}/qemu (requires node arg; returns VMs on that node). inputSchema: {node: string (required)}.
      • proxmox.get_vm_status (live): GET /api2/json/nodes/{node}/qemu/{vmid}/status/current. inputSchema: {node: string, vmid: integer}.
      • proxmox.get_node_metrics (live): GET /api2/json/nodes/{node}/status. inputSchema: {node: string}.
    • Registers via synthetic initialize handshake (Wave F transport). Responds to tools/list with the 3 proxmox tools; responds to tools/call with {content:[{type:"text", text: JSON.stringify(normalizedResult)}], isError:false}.
  3. packages/mcp/adapters/proxmox/validate.ts (backend-engineer + security-engineer)

    • PVEAuditor role validation at submit time (REQ-025, R-002): call GET /api2/json/version (any valid token) → token is valid; then GET /api2/json/nodes → token has at least Sys.Audit (read access). If both succeed → validated=true. If either fails → HTTP 422 with role-violation error, no config persisted.
    • R-002 documented gap: PVE has no clean "what role does this token have" introspection endpoint. The broker validates "token works for reads," NOT "token lacks writes." True PVEAuditor enforcement is the operator's responsibility at token creation time. Document in the Settings → Adapters UI help text: "Create a token with PVEAuditor role. The broker validates read access; the write-method blocklist (POST/PUT/DELETE → 403) is the load-bearing safety boundary." Confidence 0.70 on this sub-point.
    • Record the PVE version (from GET /api2/json/version during test_connection) in the mcp_adapters.config JSON column for diagnostics. No version branching (R-002).
  4. SecretProvider integration for Proxmox token (INV-3) (backend-engineer)

    • SecretProvider.put(tenantId, "proxmox:<targetId>", token) on config; SecretProvider.get on invocation. DB stores only secret_ref. Token never logged; SecretValue.unwrap() passed directly to the fetch Authorization header.
  5. Audit events for Proxmox adapter (backend-engineer)

    • adapter.configured (on save), adapter.test_connection.{succeeded,failed} (on "Test connection"), adapter.capability_invoked (on each capability call, with correlation_id, params hash, result status), adapter.write_rejected (if POST/PUT/DELETE somehow reached the broker — belt-and-suspenders). All via M1 appendAudit.
  6. Tests (backend-engineer + security-engineer)

    • Mock PVE API (no live Proxmox in CI — use a mock fetch responder). Validate the 3 capabilities call only GET endpoints. Validate write-method blocklist: POST/PUT/DELETE → 403 + adapter.write_rejected at broker (adapter never invoked). Validate PVEAuditor validation: token failing GET /version → 422; token failing GET /nodes (no Sys.Audit) → 422. Validate 5xx from PVE → HTTP 502/504 (not write rejection). Validate allowSelfSigned flag.
  7. Inventory TTL cache (60s, LRU) for proxmox.list_vms (backend-engineer)

    • In-memory cache keyed by (tenantId, targetId, toolName, argsHash). list_* capabilities cached for 60s; live capabilities (get_vm_status, get_node_metrics) never cached. Cache hit → return cached result with staleness metadata (cachedAt: timestamp) so the SSE event / UI can show "cached Xs ago."

Must-haves

  • 3 capabilities call only GET endpoints (never POST/PUT/DELETE).
  • PVEAuditor validation: token validated at submit (GET /version + GET /nodes); UI documents the introspection gap (R-002).
  • Token stored via SecretProvider.put; DB holds only secret_ref.
  • Write-method blocklist: POST/PUT/DELETE → 403 + adapter.write_rejected audit event at broker (adapter never invoked).
  • Inventory cache: list_vms served from 60s TTL cache on repeat calls; staleness surfaced.
  • Coverage ≥ 80% on packages/mcp/adapters/proxmox.
  • PVE 7.x and 8.x supported (no version branching needed per R-002).
  • Security-engineer sign-off on PVEAuditor validation + write-blocklist.

Wave H — SSH/Linux adapter (Phase 3)

Goal: Read-only SSH/Linux adapter via M1 Relay Agent. ssh.run_whitelisted_command capability. Defense-in-depth: broker validates command (layer 1) + Relay Agent CheckCommand (layer 2). Full REQ-026 (M1 shipped the hook; M2 plugs the adapter in). Depends on: Wave F (broker), M1 Relay Agent. REQs covered: REQ-021, REQ-026 (full). Personas: go-engineer (Wave H only — Relay Agent integration), backend-engineer (TS SSH adapter module), security-engineer (sign-off on defense-in-depth). Patch tag: v0.1.3. Branch: phase/03-ssh-adapter.

Tasks

  1. packages/mcp/adapters/ssh/whitelist-check.ts (backend-engineer + security-engineer)

    • Broker-side whitelist validation (layer 1): validates command against the 6-command subset BEFORE dispatch to the Relay Agent (REQ-021, defense-in-depth layer 1).
    • 6 commands (spec §7 Q3, conservative subset of M1's broader whitelist):
      • uptime — exact match (no args).
      • df -h — exact match.
      • free -m — exact match.
      • systemctl status <svc> — prefix systemctl status + service name (regex ^[a-zA-Z0-9_.-]+$, max 64 chars — pitfall: sanitize to prevent injection like systemctl status nginx; rm -rf /).
      • journalctl -n <N>journalctl -n + integer 1-500 (regex ^journalctl -n ([1-9][0-9]{0,2}|500)$).
      • systemctl list-units --type=service — exact match.
    • validateSshCommand(command: string): {ok: boolean, reason?: string}. Independent TS implementation from the Go CheckCommand (R-003: two independent codepaths so a bug in one doesn't bypass the other). Layer 1 (6 commands) is stricter than layer 2 (M1's broader whitelist) — correct defense-in-depth.
    • On rejection: HTTP 403 + adapter.write_rejected audit event; Relay Agent never reached.
  2. packages/mcp/adapters/ssh/adapter.ts (backend-engineer)

    • TS SSH adapter module; MCP tools/call in-process (Wave F transport), then sends a tool_call WebSocket message to the M1 Relay Agent (downstream WebSocket — D-007: the MCP JSON-RPC layer is in-process; the WebSocket to the Relay Agent is downstream transport, does not affect MCP conformance).
    • ssh.run_whitelisted_command (live): inputSchema: {command: string}. Broker validates (layer 1) → resolve adapter → send tool_call over WebSocket to the connected Relay Agent for target_id → receive tool_result → return MCP result.
    • Target routing (R-003): reverse index targetsByTenant: Map<tenantId, Map<targetId, WebSocket>> built on the M1 connectedAgents registry in ws-server.ts. If target offline → HTTP 404 (do NOT queue the call). Verify target belongs to the same tenant (RLS — withTenant + targets table tenant_id).
  3. apps/relay-agent/wsclient/handler.go (go-engineer) [G-021]

    • [G-021] Reader goroutine restructure: the M1 reader goroutine (apps/relay-agent/wsclient/client.go:182-203) currently unmarshals every message as pongMessage and continues on parse failure — tool_call messages are silently dropped today. M2 must restructure the reader goroutine to dispatch on type field BEFORE unmarshaling into a specific struct: read type from the raw JSON, route pong to the existing handler, route tool_call to the new handler. The heartbeat pongArrived signaling must not break.
    • Add tool_call message handler. Handler receives {type:"tool_call", callId, command, timeoutMs}, calls CheckCommand(command) (M1 G-004 contract, layer 2 — signature CheckCommand(cmd string) error UNCHANGED), executes via exec.Command with split argv (NO shellexec.Command("systemctl", "status", "nginx"), never sh -c "..." — third enforcement layer against shell injection).
    • Returns {type:"tool_result", callId, stdout, stderr, exitCode} (success) or {type:"tool_result", callId, error:"whitelist rejected: ...", exitCode:-1} (CheckCommand rejection) or {type:"tool_result", callId, error:"timeout after 10s", exitCode:-1} (timeout).
    • 9.5s exec.Command timeout (R-003: agent times out 0.5s before the broker's 10s timeout so the agent returns a timeout result before the broker gives up → SSE stream closes cleanly).
  4. apps/relay-agent/main.go (go-engineer)

    • Wire the tool_call handler into the WebSocket message router. M1 non-regression: the register/ping/pong paths must continue to work; the heartbeat loop must not break.
  5. Cross-layer SSH test — divergence matrix (R-003) (security-engineer) [G-013]

    • [G-013] Divergence matrix (not just both-reject — also both-accept and divergence cases): (a) Both reject rm -rf / (existing — rm not in 6-command subset at broker; rm not in M1 whitelist at Go). (b) Both accept systemctl status nginx (new — proves both layers agree on a valid command). (c) Broker rejects systemctl status nginx rm -rf / (regex fails on spaces in service name) — assert Go also rejects (currently Go accepts because rm is not in the deny list and the prefix systemctl status matches). Fix: tighten the Go deny list to include bare rm OR validate systemctl status trailing tokens against ^[a-zA-Z0-9_.-]+$ (matching the broker's regex). Choose option (ii) — validate trailing tokens — to make the two layers semantically equivalent for the 6-command subset. (d) Go accepts systemctl status nginx$(curl evil) (deny list misses $()) — assert broker rejects (regex fails). Document that exec.Command with split argv runs nginx$(curl evil) as a literal service name (no shell expansion), so the Go layer is saved by the no-shell third layer, but the divergence is real and must be documented.
    • All 4 cases pass on both layers; Go deny list / trailing-token validation tightened per (c).
  6. M1-relay-WS regression test (G-021) (go-engineer + security-engineer)

    • [G-021] Test asserting registerregistered and pingpong still work after the tool_call case is added to ws-server.ts handleMessage switch and the Go reader goroutine is restructured. This is an M1-non-regression test for the shared M1 files Wave H edits.
  7. 10s upstream timeout (backend-engineer + go-engineer)

    • Broker: AbortController 10s on the tool_calltool_result round-trip. On timeout → SSE error terminal event with "upstream timeout" + HTTP 504 semantics.
    • Agent: exec.Command 9.5s context timeout (R-003 — agent times out first).
  8. SecretProvider integration for SSH registration token (INV-3) (backend-engineer)

    • SecretProvider.put(tenantId, "ssh:<targetId>", relayRegistrationToken) on config. The SSH adapter uses the M1 Relay Agent's existing auth (the registration token resolves to a connected target; the broker routes tool_call to that target's WebSocket).
  9. Audit events for SSH adapter (backend-engineer)

    • adapter.configured (on save), adapter.test_connection.{succeeded,failed} (on "Test connection" — e.g., ping the target via uptime), adapter.capability_invoked (on each ssh.run_whitelisted_command call, with correlation_id, command hash, result status), adapter.write_rejected (on broker layer-1 rejection).

Must-haves

  • Broker validates command against 6-command subset BEFORE dispatch (layer 1).
  • Relay Agent CheckCommand validates at execution (layer 2, M1 G-004 contract unchanged).
  • [G-013] Cross-layer divergence matrix (R-003): all 4 cases pass — both-reject rm -rf /, both-accept systemctl status nginx, broker-rejects-Go-also-rejects systemctl status nginx rm -rf / (after Go tightening), Go-accepts-broker-rejects systemctl status nginx$(curl evil) (documented divergence).
  • [G-021] M1-relay-WS regression test: registerregistered + pingpong still work after tool_call addition + reader goroutine restructure.
  • tool_call WebSocket message type added to Relay Agent (clean extension of M1 protocol; register/ping/pong still work).
  • CheckCommand(cmd string) error signature UNCHANGED (G-004 contract lock).
  • No shell in Go executor (exec.Command with split argv).
  • 10s broker timeout + 9.5s agent exec timeout.
  • ssh.run_whitelisted_command returns whitelisted command output via SSE.
  • Coverage ≥ 80% on packages/mcp/adapters/ssh + relay-agent additions.
  • Security-engineer sign-off on defense-in-depth (blocks ship on P0/P1 finding).
  • go-engineer persona removed after Wave H ships; apps/relay-agent/** territory reverts to backend-engineer for M2 follow-up.

Wave I — Git adapters (Phase 4)

Goal: Read-only GitHub + Gitea adapters. 5 capabilities: github.list_repos, github.get_recent_ci_runs, github.get_workflow_run, gitea.list_repos, gitea.get_recent_ci_runs. Depends: Wave F (broker). REQs covered: REQ-022, REQ-023, REQ-027. Personas: backend-engineer (adapters), security-engineer (sign-off on scope validation). Patch tag: v0.1.4. Branch: phase/04-git-adapters.

Tasks

  1. packages/mcp/adapters/github/client.ts (backend-engineer)

    • GitHub REST API client (fine-grained PAT, Authorization: Bearer <token>, Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28 — stable GA version per R-004). Global fetch with AbortSignal.timeout(10_000).
    • ghGet(path, token, query?): rate limit handling — observe x-ratelimit-remaining; if 0, do NOT make the call, return HTTP 429 to caller with Retry-After: <seconds until x-ratelimit-reset>. If GitHub returns 429 (or 403 with x-ratelimit-remaining: 0), back off exponentially (1s, 2s, 4s, max 3 retries) then surface 429.
    • 403 with X-Accepted-GitHub-Permissions header (e.g., actions=read required) → GithubScopeError → broker surfaces HTTP 403 "insufficient scope" + adapter.capability_invoked with result=failure (NOT write_rejected — no write attempted; this is a scope mismatch, R-004).
  2. packages/mcp/adapters/github/adapter.ts (backend-engineer)

    • 3 capabilities (GET endpoints only):
      • github.list_repos (inventory): GET /user/repos?per_page=100. Normalize to {id, name, full_name, owner, private, description, html_url, default_branch, updated_at}. inputSchema: {} (first page; multi-page is M3, R-004).
      • github.get_recent_ci_runs (live): GET /repos/{owner}/{repo}/actions/runs?per_page={per_page}. Normalize to {total_count, runs:[{id, head_branch, status, conclusion, html_url, created_at, actor}]}. inputSchema: {owner: string, repo: string, per_page?: integer (default 30, max 100), status?: string, branch?: string}.
      • github.get_workflow_run (live): GET /repos/{owner}/{repo}/actions/runs/{run_id}. inputSchema: {owner, repo, run_id: integer}.
  3. packages/mcp/adapters/github/validate.ts (backend-engineer + security-engineer)

    • Fine-grained PAT validation (D-006, R-004):
      1. Detect classic vs fine-grained: classic PATs start with ghp_/gho_/ghu_; fine-grained start with github_pat_. Reject classic PATs at submit → HTTP 422 "fine-grained PAT required" (D-006: classic repo scope grants write).
      2. Validate token works + metadata:read: GET /user with token. 401 → invalid token (HTTP 422). 200 → token valid; all fine-grained PATs require metadata:read implicitly, so a successful GET /user implies metadata:read.
      3. actions:read validated per-invocation: when github.get_recent_ci_runs/get_workflow_run is called, if GitHub returns 403 with X-Accepted-GitHub-Permissions indicating actions=read required → HTTP 403 "insufficient scope" + audit adapter.capability_invoked with result=failure. Submit-time best effort (R-004 gap documented in UI help text: "ensure the PAT has actions:read").
  4. packages/mcp/adapters/gitea/client.ts (backend-engineer)

    • Gitea REST API client (Authorization: token <token>pitfall: Gitea uses token not Bearer, R-005). Base URL = customer's Gitea host (https://gitea.example.com/api/v1/). allowSelfSigned per-adapter flag (customer Gitea often self-signed).
  5. packages/mcp/adapters/gitea/adapter.ts (backend-engineer)

    • 2 capabilities (GET endpoints only — no gitea.get_workflow_run in M2, deferred to v1.2+ per Q2):
      • gitea.list_repos (inventory): GET /api/v1/user/repos?limit=50. Normalize to {id, name, full_name, owner, private, description, html_url, default_branch, updated_at}. inputSchema: {}.
      • gitea.get_recent_ci_runs (live): GET /api/v1/repos/{owner}/{repo}/actions/runs?limit={limit}. inputSchema: {owner, repo, limit?: integer (default 30, max 50)}. Pitfall: Gitea Actions may be disabled (actions.ENABLED=true in app.ini) → 404; surface as "Gitea Actions not enabled on this instance" (HTTP 502, not write rejection).
  6. packages/mcp/adapters/gitea/validate.ts (backend-engineer + security-engineer)

    • Version-aware validation (R-005, spec §7 Q6):
      1. GET /api/v1/version → parse version, compare major.minor to 1.22 (semver-ish; compare as integers). Record version in mcp_adapters.config.
      2. Gitea ≥1.22: GET /api/v1/user/repos?limit=1 → 200 = read:repository ok; 403 = insufficient scope → HTTP 422 "insufficient scope — read:repository required".
      3. Gitea <1.22: GET /api/v1/repos/search?limit=1 → 200 = token valid (any token accepted; no read-only scope available). Broker-side write-method blocklist (POST/PUT/DELETE/PATCH) is the security backstop.
  7. packages/mcp/adapters/gitea/version-check.ts (backend-engineer)

    • Gitea version detection + scope routing (R-005). Verify against a running Gitea 1.22+ AND a <1.22 instance during Wave I (R-005 action: confirm GET /api/v1/version, GET /api/v1/user/repos, GET /api/v1/repos/{owner}/{repo}/actions/runs against real instances; confirm read:repository scope behavior and Authorization: token <token> header).
  8. SecretProvider integration for Git tokens (INV-3) (backend-engineer)

    • SecretProvider.put(tenantId, "github:<targetId>", pat) / SecretProvider.put(tenantId, "gitea:<targetId>", token). DB stores only secret_ref. Tokens never logged.
  9. Rate limit handling for GitHub (backend-engineer)

    • Observe X-RateLimit-Remaining; back off on 429 (R-004). M2 broker's own token-bucket (60/min user) is well below GitHub's 5000/hour, so the GitHub limit is unlikely to bind unless many tenants share a token (they shouldn't — per-tenant tokens).
  10. Inventory TTL cache (60s, LRU) for github.list_repos and gitea.list_repos (backend-engineer)

    • Same pattern as Proxmox (Wave G). list_* cached 60s; live capabilities (get_recent_ci_runs, get_workflow_run) never cached. Staleness surfaced.
  11. Audit events for Git adapters (backend-engineer)

    • adapter.configured, adapter.test_connection.{succeeded,failed}, adapter.capability_invoked, adapter.write_rejected (for Gitea POST/PUT/DELETE/PATCH blocklist violations). All via M1 appendAudit.
  12. Tests (backend-engineer)

    • GitHub: validated via real-target smoke in CI (real GitHub PAT, Wave 0 prerequisite). Validate 3 capabilities call only REST GET endpoints. Validate classic PAT rejection (ghp_ prefix → 422). Validate fine-grained PAT (github_pat_) → GET /user → 200 = valid. Validate rate limit handling (mock 429 + x-ratelimit-remaining: 0).
    • Gitea: validated via mock + verify against running instance (R-005). Validate 2 capabilities call only GET endpoints. Validate version-aware scope routing (≥1.22 read:repository; <1.22 any token + write blocklist). Validate Authorization: token <token> header. Validate Gitea Actions disabled → 404 → "not enabled".

Must-haves

  • GitHub: fine-grained PAT only (classic PAT rejected by github_pat_ prefix); metadata:read + actions:read minimum (D-006).
  • GitHub: 3 capabilities call only REST GET endpoints; rate limit handling (X-RateLimit-Remaining, backoff on 429); per-invocation 403 + X-Accepted-GitHub-Permissions handling (R-004).
  • Gitea: version-aware scope validation (≥1.22 read:repository; <1.22 any token with broker write-method blocklist).
  • Gitea: verify against running instance during Wave I (R-005).
  • Write-method blocklist: GitHub scopes outside read-only → 403; Gitea POST/PUT/DELETE/PATCH → 403 + adapter.write_rejected at broker.
  • Tokens stored via SecretProvider.put; DB holds only secret_ref.
  • Inventory cache: list_repos served from 60s TTL cache on repeat calls; staleness surfaced.
  • Coverage ≥ 80% on packages/mcp/adapters/github + gitea.
  • Security-engineer sign-off on scope validation.

Wave J — SSE integration + LLM smoke + adapter UI (Phase 5)

Goal: SSE integration end-to-end, packages/llm-mock CI-only LLM smoke provider, Settings → Adapters UI + Test-Call UI. The LLM smoke (M2 gate item 8, P0 — not deferrable) must pass before M2 ships. Depends on: Wave F (broker) + at least Wave I (GitHub adapter for real-target smoke). REQs covered: REQ-017 integration (SSE to UI), M2 gate item 8 (LLM smoke). Personas: frontend-engineer (UI), backend-engineer (llm-mock + SSE integration). Patch tag: v0.1.5. Branch: phase/05-sse-integration.

Tasks

  1. packages/llm-mock/ — CI-only mock LLM provider (backend-engineer) [G-019]

    • devDependency (not a production dependency) — R-008. Implements OpenAI-compatible /v1/chat/completions accepting the tools parameter (OpenAI tool definitions from the broker's GET /api/mcp/tools).
    • [G-019] Hardened pattern matching (regex set, not 2-word conjunction — tolerates prompt wording drift):
      • /(list|show|get|display).*\b(repo|repositor)/itool_calls:[{id:"call_1", type:"function", function:{name:"github.list_repos", arguments:"{}"}}].
      • /(recent|latest|last).*\b(run|ci|workflow)/itool_calls:[{function:{name:"github.get_recent_ci_runs", arguments:'{"owner":"...","repo":"..."}'}}].
      • Tests assert the pattern matches "Show me my GitHub repositories", "List my repos", "Get repositories", "What are my recent CI runs" — wording-tolerant.
    • Accepts follow-up tool role messages (broker's translated adapter result). On second call (with a tool message present): synthesize grounded text — parse repo names from the tool message content, return "Your repos are: <names>".
    • Deterministic — no randomness; the smoke test asserts specific repo names appear (R-008).
    • Reuse BYOM request/response types from packages/byom/src/types.ts (D-001 contract).
  2. apps/control-plane/app/(dashboard)/settings/adapters/page.tsx — Settings → Adapters UI (frontend-engineer) [G-014]

    • Adapter type picker (4 types), per-adapter config forms, SecretProvider-backed credential entry (redacted after submit, edit-only), role/scope validation on submit (REQ-025/026/027), "Test connection" button (REQ-016), audit event confirmation, multi-target support (target_id per row), PVEAuditor introspection gap help text (R-002), GitHub fine-grained PAT help text (D-006), Gitea version-aware help text (R-005).
    • [G-014] Closed-tool-set gap documentation in UI help text (sets operator expectations pre-ship so the "additions require spec amendment" gate is politically enforceable):
      • SSH: "M2 supports 6 diagnostic commands (uptime, df -h, free -m, systemctl status, journalctl -n, systemctl list-units). ps, ss, top, ip are deferred to v1.2+."
      • Proxmox: "list_vms requires a node argument. A list_nodes tool is deferred to v1.2+."
      • GitHub: "list_repos returns up to 100 repos (first page). Pagination, PR lists, and issue lists are deferred to v1.2+."
      • Gitea: "get_workflow_run is deferred to v1.2+."
    • Server components read via the API gateway (never bypass RLS — D-005 pattern).
  3. apps/control-plane/app/(dashboard)/test-call/page.tsx — Test-Call UI (frontend-engineer)

    • Capability picker (closed 9-tool set from GET /api/mcp/tools, grouped by adapter type, per-tenant disabled tools greyed out). Argument forms (rendered from JSON Schema inputSchema, required fields marked, type-validated). Target picker for multi-target tenants (REQ-024). SSE stream consumer (EventSource on GET /api/mcp/stream/:correlationId, renders events as they arrive, terminal done/error close the stream). Staleness indicator for inventory calls ("cached Xs ago"). Result rendering (JSON tree, isError flag surfaced).
  4. SSE consumer in Test-Call UI (frontend-engineer)

    • EventSource opens on the streamUrl from POST /api/mcp/invoke. Renders tool_result events incrementally; terminal done/error close the stream. <100ms chunk delivery (NFR). On client disconnect (page close), the broker detects req.signal abort and cancels the in-flight adapter call (Edge 8).
  5. LLM smoke test — TWO-TRACK (M2 gate item 8) (backend-engineer) [G-018, G-019]

    • [G-018] Two-track smoke (mock-path is the P0 gate; real-path is optional/allow-failure — ensures the gate is reliable regardless of GitHub availability):
    • Track A — Mock-path smoke (P0 gate, runs ALWAYS, no external dependency):
      1. CI starts the control plane with packages/llm-mock as the BYOM endpoint + a github-mock adapter (deterministic canned-repo adapter, distinct from the broker stub — returns [{"name":"coreci-test-repo-1"},{"name":"coreci-test-repo-2"}]).
      2. Test sends POST /v1/chat/completions with tools=[github.list_repos definition] and prompt "List my GitHub repositories."
      3. llm-mock returns tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}].
      4. Broker's translator converts to MCP tools/call → broker routes to github-mock adapter → canned repo data.
      5. Broker's translator converts the MCP result to an OpenAI tool message.
      6. Test sends a second POST /v1/chat/completions with messages=[original prompt, assistant tool_call, tool message].
      7. llm-mock synthesizes a grounded response ("Your repos are: coreci-test-repo-1, coreci-test-repo-2"). Test asserts the response contains the canned repo names.
      • This track proves the OpenAI→MCP→adapter→result→synthesis integration with zero external dependencies. It is the P0 gate.
    • Track B — Real-path smoke (optional, runs when secrets.GITHUB_SMOKE_PAT is available, allow-failure — does NOT block the gate):
      1. Same flow as Track A but against the real GitHub adapter (real PAT, test-org-scoped).
      2. Test asserts the response contains real repo names from the CI test org.
      3. [G-019] Retry policy: on 429/5xx/timeout, retry up to 3 times with exponential backoff (1s, 2s, 4s); on final failure, skip with a warning (the mock-path smoke is the gate, not this track).
      • This track proves real-target connectivity. It is the ideal, not the gate.
    • P0 — not deferrable: Track A (mock-path) must pass reliably in CI (no GitHub dependency). Track B (real-path) is optional/allow-failure. This ensures the M2 gate is reliable regardless of GitHub availability.
  6. Import guard (R-008) (backend-engineer)

    • packages/llm-mock is a devDependency of the CI test package (or apps/control-plane devDeps), NOT a dependency. pnpm install --prod excludes it.
    • Eslint rule no-restricted-imports banning @coreci/llm-mock in apps/control-plane/app/** and packages/mcp/** (prod code paths). Allowed only in tests/** and packages/llm-mock/**.
    • Build-time check: CI step greps the prod build output (.next/ or dist/) for llm-mock and fails if found.
    • Pitfall: the mock must not be imported transitively by a prod dependency. The broker talks to it over HTTP (as a BYOM endpoint), not via import.

Must-haves

  • packages/llm-mock is a devDependency; import-guarded against prod bundle (eslint + build-time grep).
  • [G-018] Two-track LLM smoke: Track A (mock-path, github-mock adapter, canned repos) passes reliably in CI — this is the P0 gate.
  • [G-018] Track B (real-path, real GitHub) runs when PAT available, allow-failure — does NOT block the gate.
  • [G-019] llm-mock pattern matching hardened (regex set, tolerates wording drift); retry policy for real-GitHub track.
  • [G-014] Settings → Adapters UI: closed-tool-set gap documentation in help text (SSH/Proxmox/GitHub/Gitea limitations).
  • Settings → Adapters UI: adapter type picker (4 types), config forms, validation on submit (REQ-025/026/027), "Test connection" button, multi-target support, help text for introspection gaps (R-002, D-006, R-005).
  • Test-Call UI: capability picker (9 tools), argument forms (JSON Schema validated), target picker for multi-target (REQ-024), SSE stream consumer, staleness indicator for inventory calls.
  • SSE stream renders in Test-Call UI within 100ms of events.
  • Coverage ≥ 80% on packages/llm-mock + UI components.

Final Phase — Review + Audit + Ship (Phase 6)

Goal: Multi-persona code review + project health audit + milestone ship (v0.1.6 release, merge to main). REQs covered: all M2 (sign-off). Personas: lead-developer (review + audit + ship), all personas (review). Patch tag: v0.1.6M2 milestone release. Branch: phase/06-final-review-ship.

Tasks

  1. Code review (lead-developer + all personas)

    • Review all changes in milestone/v0.2 (or the M2 integration branch) since main. Auto-apply P0 fixes; flag P1+ for post-hoc. Security-engineer reviews INV-7 at broker, write-method blocklist per adapter, SSH defense-in-depth, fine-grained PAT scope validation, Gitea version-aware scope validation. go-engineer reviews Relay Agent tool_call additions + CheckCommand contract lock (Wave H only, then removed).
  2. Audit (lead-developer)

    • Reconstruction test: git log matches .ciagent/ files (every decision, research finding, persona assignment traceable to a commit). File/branch/commit discipline verified (all commits on phase/NN-* branches with ---ci--- blocks; no direct commits to main).
    • M2 gate items 1-15 all pass (spec §6).
  3. M2 acceptance gate verification (lead-developer)

    • Spec §6 — all 13 REQs (015-027) pass with Given/When/Then coverage.
    • Mocks + real GitHub smoke + LLM smoke (P0 gate item 8) + INV-7 verified (per-adapter write-blocklist tests) + CI Postgres RLS verified (Wave 0) + M1 non-regression.
    • Coverage ≥ 80% on new M2 modules; DB coverage ≥ 80% maintained on packages/db.
  4. MCP conformance verification artifact review (R-001) (lead-developer + security-engineer)

    • packages/mcp/PROTOCOL.md + 6 conformance tests in tests/mcp-conformance/ reviewed and confirmed (gate item 15).
  5. Milestone ship (lead-developer)

    • Tag v0.1.6 (final phase patch = M2 milestone release).
    • Merge phase/06milestone/v0.1 (or main per branch hierarchy) → main.
    • Create Gitea release (https://git.cloudinit.dev/coreci/coreci-chat) with full M2 summary.
  6. Complete (lead-developer)

    • Mark all M2 REQs (015-027) complete in REQUIREMENTS.md.
    • Mark M2 complete in ROADMAP.md.
    • Clear checkpoint.

Wave ordering & parallelism

Phase 0 (this plan) ──▶ Wave 0 (prerequisites) ──▶ Wave F (gateway core)
                                                      │
                   ┌───────────────────────────────────┼───────────────────────┐
                   │                                   │                       │
                   ├──▶ Wave G (Proxmox)               ├──▶ Wave H (SSH)        ├──▶ Wave I (Git adapters)
                   │                                   │                       │
                   └───────────────────────────────────┴───────────────────────┘
                                                      │
                                                      ▼
                                              Wave J (SSE + LLM smoke + UI)
                                                      │
                                                      ▼
                                              Final (review + audit + ship)
  • 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). Wave H reactivates the go-engineer persona for Relay Agent integration only.
  • J depends on F + at least I (GitHub adapter for the real-target LLM smoke, gate item 8).
  • Final depends on all.

Test strategy

  • Unit: vitest in packages/mcp/** + apps/control-plane; Go testing in apps/relay-agent.
  • Integration: Postgres 16 container in CI (Wave 0); RLS + withTenant + audit tests against real Postgres (replaces PGlite-only verification, R-009). Two CI jobs: test-pglite (default) + test-postgres (service container + DB_MODE=pg + role setup).
  • Adapter validation:
    • Proxmox: mock PVE API (no live Proxmox in CI).
    • SSH: mock + cross-layer test (R-003).
    • Gitea: mock + verify against running instance during Wave I (R-005).
    • GitHub: real-target smoke in CI (real PAT, Wave 0 prerequisite — gate item 7).
  • LLM smoke: packages/llm-mock driving the full OpenAI→MCP→adapter→result→synthesis path against real GitHub (P0 gate item 8 — not deferrable).
  • MCP conformance: PROTOCOL.md + 6 tests in tests/mcp-conformance/ (R-001 artifact, gate item 15).
  • Cross-layer SSH test: broker (layer 1) + Relay Agent (layer 2) both reject non-whitelisted commands (R-003).
  • Coverage gate: ≥ 80% on new M2 modules (gate item 3); DB coverage ≥ 80% maintained (gate item 4).
  • M1 non-regression: all M1 tests still pass (gate item 1).

Decisions logged (to DecisionEngine)

  • D-M2-P001: Wave 0 is a hard prerequisite for Wave F — CI Postgres 16 + role setup (coreci_app no BYPASSRLS, migrator BYPASSRLS) + real GitHub PAT. RLS assertions gated on DB_MODE=pg replace M1's PGlite-only placeholder. Confidence 0.90.
  • D-M2-P002: Wave F ships the broker with stub adapters (not real adapters); real adapters land in waves G/H/I. This isolates broker verification (INV-7, rate-limit, SSE, conformance) from adapter upstream concerns. Confidence 0.85.
  • D-M2-P003: G/H/I parallel after F; J depends on F + at least I (GitHub for real-target LLM smoke). go-engineer active for Wave H only, removed after. Confidence 0.85.
  • D-M2-P004: v0.1.6 (final phase patch) IS the M2 milestone release; merge to main at the final phase. Tags run on v0.1.x patch line (M1's previous minor). Confidence 0.90.

All above the 0.6 threshold. No escalations. Pipeline proceeds to GRILL.


End of M2 PLAN. M1 PLAN preserved in git history (commit prior to M2 overwrite).