ARCHITECTURE.md
Rama main · réplica de lectura del repositorio original.
# AgentRQ Architecture
## Overview
AgentRQ is a task orchestration platform that connects human operators and AI agents through a structured, real-time communication layer. Each **workspace** is an isolated execution context with its own dedicated **MCP (Model Context Protocol) server**, and agents connect to workspaces via those servers to receive tasks, send replies, and report status. A supervisor agent — by connecting to the **Core MCP server** (`coremcp`) with a single OAuth2-authenticated session — gains cross-workspace visibility and can orchestrate multi-agent workflows by assigning tasks to specialist agents across any number of workspaces.
---
## Core Concepts
### Workspace
A workspace represents a scoped mission environment. It holds:
- A **name** and **description** (the mission brief)
- A **self-learning loop note** (guidelines injected into agent-assigned tasks)
- An **encrypted session token** (16-char secret, AES-GCM encrypted at rest) that authenticates agent connections
- A list of **auto-allowed tools** (agent tool calls pre-approved without human intervention)
- An optional **`AllowAllCommands`** flag (bypasses all permission gating for a workspace)
Workspaces can be archived (frozen, read-only). They can be active or archived; archived workspaces reject new task creation.
### Task
A task is the atomic unit of work. Key fields:
| Field | Description |
|-------|-------------|
| `WorkspaceID` | The workspace this task belongs to |
| `CreatedBy` | `human` or `agent` |
| `Assignee` | `human` or `agent` |
| `Status` | `notstarted`, `ongoing`, `completed`, `rejected`, `blocked`, `cron` |
| `Title` / `Body` | Task description |
| `CronSchedule` | 5-field cron string (for scheduled task templates) |
| `ParentID` | Set when a task is spawned from a cron template |
| `TriggerID` | ID of the event that caused this task (set by the event consumer) |
| `EventID` | ID of the event this task should publish on completion |
| `SortOrder` | Controls queue priority for `task.get` (next-task dequeue) |
| `AllowAllCommands` | Per-task override for permission gating |
### Message
Each task has a threaded conversation history. Messages record every exchange between the human operator, the agent, and the system. Messages carry:
- `Sender`: `human`, `agent`, or `slack`
- `Text` and optional `Attachments`
- `Metadata`: JSON blob used for permission request lifecycle tracking (`{type, request_id, tool_name, status}`)
---
## MCP Server Layer
AgentRQ exposes **two distinct MCP server tiers**: the Core MCP server for supervisors and human-level orchestration, and per-workspace MCP servers for specialist worker agents.
### Supervisor MCP Server (`coremcp`)
The Core MCP server is a **single, stateless server** instance accessible at `/mcp` (path-based) or `mcp.{domain}` (host-based). It is the entry point for supervisor agents and any MCP client that needs cross-workspace visibility.
**Authentication** uses OAuth2 with a short-lived access token bearing audience `"coremcp"`. The token encodes the authenticated user's ID (not a workspace token), so every tool call runs in the context of that user's full workspace access.
**User-scope enforcement**: Every coremcp handler extracts the user ID from the JWT context and passes it to the CRUD controller. The controller validates workspace ownership on every operation via `ensureActiveWorkspace(ctx, workspaceID, userID)`, which issues a DB query requiring both the workspace ID and the user's ID to match. A workspace belonging to another user returns a not-found error — cross-user access is impossible regardless of which tool is called.
The coremcp exposes a broad, cross-workspace tool surface. Every tool takes two inputs, an `action` and its `payload`; below, an action's payload is written as its arguments:
| Tool | Description |
|------|-------------|
| `workspace(action, payload?)` | The account's workspaces. `list(includeArchived?)`: List all user-accessible workspaces; each result includes the workspace's `mcpURL` (the per-workspace MCP endpoint URL); `create(name, description?, ...)`: Create a new workspace; `get(id)`: Get a single workspace by ID (with `mcpURL`); `update(id, ...)`: Update workspace metadata; `stats(id, range)`: Task statistics for a workspace; `fork(id)`: a fork with its own queue and agent; `mergeFork(id, deleteFolder?)`: merge a fork back into its parent |
| `task(action, payload?)` | Every task operation, in **any** workspace by ID. Actions: `list(workspaceId, filter?, status?, limit?)`, `listAll(filter?, status?, limit?)`, `create(workspaceId, title, body?, assignee?, cronSchedule?)`, `get(workspaceId, taskId)`, `reply(workspaceId, taskId, text)`, `respond(workspaceId, taskId, action)` (allow/deny a permission request), `updateStatus`, `updateSortingOrder`, `updateAssignee`, `updateAllowAll`, `updateSchedule` (a cron task template), `delete` (with its messages and attachments — the only way to retire a schedule rather than leave it behind), `getAttachment(workspaceId, taskId, attachmentId)`; `help` prints each action's payload |
| `memory(action, payload?)` | A workspace's memory, read-only: `list(workspaceId)`, `get(workspaceId, name)` |
| `skills(action, payload?)` | The account's skills as a workspace sees them, read-only: `search(workspaceId, q?)`, `get(workspaceId, uri)` |
| `events(action, payload?)` | `list()`: List the events defined for the user's account; `create(name, payloadGuidelines?)`: Define a named signal workspaces can publish; `get(eventId)`: Fetch a single event; `update(eventId, payloadGuidelines)`: Revise an event's payload guidelines (its name is fixed); `delete(eventId)`: Delete an event; its triggers stop firing; `createTrigger(eventId, workspaceId, title, body?, assignee?, cronSchedule?, allowAllCommands?, emitEventId?)`: Create a task in a workspace whenever the event fires; `listTriggers(eventId)`: List everything that happens when an event fires; `getTrigger(triggerId)`: Fetch a single trigger by ID; `updateTrigger(triggerId, workspaceId, title, ...)`: Rewrite a trigger; every field is written as given; `deleteTrigger(triggerId)`: Delete a trigger, leaving its event in place; `listTasks(eventId)`: List the tasks an event has spawned |
| `workflows(action, payload?)` | `list()`: List the workflows defined for the user's account; `create(name, description?, startEventId?)`: Create an empty workflow around a start event; `get(workflowId)`: Fetch a single workflow; `update(workflowId, name?, description?, startEventId?, layout?)`: Revise a workflow; omitted fields are left alone; `delete(workflowId)`: Delete a workflow and its steps; the events it named survive; `createStep(workflowId, eventId, workspaceId, title, body?, assignee?, allowAllCommands?, emitEventId?)`: Add one node to the graph; `listSteps(workflowId)`: List a workflow's steps; `deleteStep(workflowId, stepId)`: Remove one step, leaving the rest of the graph; `listTasks(workflowId)`: List the tasks a workflow has spawned; `getText(workflowId)`: Read the whole graph as the indented document text mode edits; `replaceFromText(workflowId, text)`: Replace the entire graph from a document, resolving every name before writing |
| `machines(action, payload?)` | `list()`, `createEnrolmentCode()`, `launchAgent(workspaceId, machineId, kind, ...)`, `stopAgent(workspaceId, machineId)` |
| `pano(action, payload?)` | The account's shared markdown docs, written as the supervisor workspace |
| `help(action?)` | Every tool and its actions; with `tool`, its actions; with `tool.action`, that action's payload and an example |
The event tools are what let a supervisor wire workspaces to each other rather
than relaying every hand-off itself: it defines an event, attaches a trigger
per workspace that should react, and reads back the tasks the event spawned.
`emitEventId` chains one system to the next by publishing a second event when
the spawned task completes. Publishing remains agent-side (`workspace.publishEvent` on
the per-workspace server) — the supervisor builds the wiring, the workers fire
it.
The workflow tools are the same wiring named and drawn as one graph. They exist
on MCP rather than REST alone because *reconciliation* needs them: a desktop
extension declares the standing work it wants, and the host creates what is
missing, revises what has drifted and removes what the extension dropped. All
three are needed, and the third is the one that makes an uninstall clean — the
tools mirror the REST surface one-to-one for exactly that reason.
`workflows.replaceFromText` is the declarative half of the pair: two documents
compare where a graph would have to be diffed node by node.
### Per-Workspace / Agent Worker MCP Servers
Each workspace gets its own MCP server instance (`WorkspaceServer`), created lazily on the first agent connection and managed by a central `Manager`. The manager uses a read-write lock and a `map[workspaceID → WorkspaceServer]` for safe concurrent access.
Worker agents connect via:
- **Path-based**: `POST /mcp/{workspaceID}` (Streamable HTTP transport)
- **Subdomain-based**: `{workspaceID}.mcp.{domain}`
Authentication uses the workspace's encrypted token (a 16-char secret, AES-GCM encrypted at rest, provided as a bearer token). Once authenticated, the agent communicates with that workspace exclusively — it has no visibility into other workspaces.
Every agent connected to a per-workspace MCP server has access to exactly these tools, each taking an `action` and its `payload`:
| Tool | Description |
|------|-------------|
| `task(action, payload?)` | Every task operation in this workspace. `get(taskId?, includeConversation?, cursor?, limit?)`: with no `taskId`, dequeues the next `notstarted` agent-assigned task (sorted by `SortOrder`); with a `taskId`, returns that task, `includeConversation` appending paginated message history. `create(title, body, assignee?, attachments?, cronSchedule?, eventId?)`: a new task (or cron template); `eventId` links it to the event it should publish on completion. `updateStatus(taskId, status)`: transitions task state, auto-appending a status message. `reply(taskId, text, attachments?)`: a message in the task's thread. `elicit(taskId, message, mode, ...)`: asks the human and waits. `getAttachment(taskId, attachmentId, format?)`: a file attachment. `help` prints each action's payload |
| `memory(action, payload?)` | `load(name?)`, `save(name?, content)`, `delete(name?)`: notes that outlive a task; `memory.md` is the index |
| `skills(action, payload?)` | `search(q?)`, `load(uri)`, `save(uri, content)`, `delete(uri)`: the skills turned on in this workspace |
| `workspace(action, payload?)` | `get()`: workspace name, description, and task stats. `publishEvent(name, taskId?, payload, faq?)`: fires a named event; fans out to all matching `EventTrigger` rows, creating tasks in subscribed workspaces |
| `siteTools(action, payload?)` | `search(q?, pattern?)`, `getDefinition(site, tool)`, `call(taskId, site, tool, arguments?)`: websites shared from the Chrome extension |
| `pano(action, payload?)` | The account's shared markdown docs |
| `help(action?)` | Every tool and its actions; with `tool`, its actions; with `tool.action`, that action's payload and an example |
All state mutations go through the MCP tool layer — there is no direct database access for agents.
---
## Supervisor → Worker Architecture
### How a Supervisor Agent Coordinates Workspaces
A supervisor agent connects to the **Supervisor MCP server** (`coremcp`) via a single OAuth2-authenticated session. This one connection gives the supervisor full cross-workspace control — no need to hold per-workspace tokens or manage multiple MCP sessions simultaneously.
The coordination flow looks like this:
```
Supervisor Agent
└── MCP Session → coremcp (/mcp) [OAuth2, user-scoped]
├── workspace list() → discovers all workspaces + their mcpURL
├── task create(workspaceId=B) → assigns task to coding agent
├── task create(workspaceId=C) → assigns task to docs agent
├── task create(workspaceId=D) → assigns task to publishing agent
└── task list(workspaceId) → monitors progress across any workspace
Worker Agents (one per workspace, each with its own MCP session)
├── MCP Session → /mcp/{workspaceB} [workspace token, isolated]
├── MCP Session → /mcp/{workspaceC} [workspace token, isolated]
└── MCP Session → /mcp/{workspaceD} [workspace token, isolated]
```
The supervisor never touches per-workspace MCP endpoints. It sees everything through the coremcp and uses `task.create(workspaceId, ...)` to delegate subtasks to specialist agents. Each worker agent is entirely isolated within its own workspace MCP server and is unaware of the broader pipeline.
### Step-by-Step: Supervisor Spawning a Cross-Workspace Workflow
1. **Human → Supervisor**: Human creates a task in the supervisor workspace (via the UI or REST API) with a high-level goal (e.g., "Write, document, and publish the new authentication module").
2. **Supervisor picks up task**: The supervisor, connected to `coremcp`, receives the task via the AgentRQ workspace MCP server it also runs in. It calls `task.updateStatus(workspaceId, taskId, "ongoing")`.
3. **Supervisor discovers workspaces**: Supervisor calls `workspace.list` on coremcp. The response includes each workspace's ID, name, description, and `mcpURL` — the URL the specialist worker agent in that workspace is connected to.
4. **Supervisor creates subtasks**: For each specialist workspace (coding, docs, publishing), the supervisor calls `task.create(workspaceId="...", title="...", body="...", assignee="agent")` on coremcp. Each call creates a task in the target workspace and returns its ID.
5. **Specialist agents work**: Each specialist workspace agent runs its own `task.get` → `ongoing` → `reply` → `completed` cycle independently, entirely within their workspace MCP server.
6. **Supervisor monitors**: Supervisor calls `task` actions `list(workspaceId, status="completed")` or `get(workspaceId, taskId)` on coremcp to track worker progress. When a worker marks its task `completed`, the supervisor reads the result from the task data and uses it as input for the next stage.
7. **Supervisor chains the pipeline**: Once Stage 1 (coding) completes, the supervisor creates the Stage 2 (docs) task in the docs workspace, embedding Stage 1's output in the task body. This continues through each stage of the pipeline.
8. **Supervisor reports back**: When all stages are complete, the supervisor calls `task` actions `reply(workspaceId, supervisorTaskId, summary)` and `updateStatus(workspaceId, supervisorTaskId, "completed")` via coremcp. The human sees the full result in the UI.
---
## Request / Event Flow
### Task Creation (Human Path)
```
Human (UI)
→ POST /api/v1/workspaces/{id}/tasks
→ CRUD Controller (validates, persists, stores attachments)
→ Repository (GORM → SQLite / PostgreSQL)
→ PubSub event (ActionTaskCreate, ActorHuman)
→ Central Forwarder (PubSub → EventBus)
→ SSE stream → Human UI (task.created event)
→ MCP Manager notifies workspace agent (if task is agent-assigned)
```
### Task Creation (Agent Path via MCP)
```
Agent
→ MCP tool call: task create(...)
→ WorkspaceServer callback
→ CRUD Controller (same validation + persistence path)
→ PubSub event (ActionTaskCreate, ActorAgent, OriginMCP)
→ Central Forwarder → EventBus SSE → Human UI
→ (if assignee="agent" in another workspace) Supervisor delegates via coremcp task create(workspaceId, ...)
```
### Agent Task Execution Loop
```
Agent
→ task get() ← dequeues first notstarted task
→ task updateStatus(ongoing)
→ [work happens]
→ task reply(taskId, "...") ← intermediate progress updates
→ task reply(taskId, result) ← final output
→ task updateStatus(completed)
```
At each step, the MCP server persists the message and emits an SSE event so the human operator can follow along in real time.
---
## Permission Gating
Agents operating via Claude Code may request permission for sensitive tool calls (bash commands, file writes, etc.). AgentRQ intercepts these via the MCP protocol's notification channel:
1. **Agent requests permission** → MCP server receives `notifications/claude/channel/permission_request`
2. **Auto-allow check**: Is the tool in `workspace.AutoAllowedTools` or does the task have `AllowAllCommands = true`? If yes → auto-approve, record approval, continue.
3. **Human review**: Otherwise, a `Message` is created with `metadata.type = "permission_request"` and pushed to the human UI via SSE.
4. **Human responds** via `POST /workspaces/{id}/tasks/{taskId}/permission` with behavior: `allow`, `deny`, or `allow_always`.
5. **Verdict delivered** → MCP server sends `notifications/claude/channel/permission` back to the agent session. If `allow_always`, the tool is added to `workspace.AutoAllowedTools` for all future calls.
This gating applies per-session, per-workspace, and can be bypassed at the task or workspace level for trusted automation.
---
## Real-Time Event System
AgentRQ uses two complementary event systems:
### EventBus (SSE)
- In-memory pub/sub keyed by `workspaceID` and `userID`
- Human UI subscribes via `GET /workspaces/{id}/events`
- Events are JSON-encoded SSE frames: `data: {type, payload}\n\n`
- Slow consumers are dropped (non-blocking sends)
- Event types: `task.created`, `task.updated`, `status.updated`, `message.created`, `agent.connected`, `workspace.updated`
### PubSub (Internal)
- Separate async bus for system-level concerns
- Topics: `PubSubTopicCRUD` (entity lifecycle events), `PubSubTopicMCP` (tool call telemetry), `PubSubTopicEvents` (named event signals)
- Consumed by: Slack controller, notification service, push service, central SSE forwarder, telemetry, event consumer
The **Central Forwarder** bridges them: it subscribes to the CRUD PubSub topic and translates entity events into workspace-scoped SSE events on the EventBus.
---
## Scheduled Tasks (Cron)
Tasks with `status = "cron"` are templates managed by the Scheduler service:
- Scheduler polls every 60 seconds
- Parses each template's 5-field cron schedule
- When the current minute matches, spawns a **child task** (copies title, body, attachments, `AllowAllCommands`; sets `ParentID`, `status = "notstarted"`)
- **One-time tasks** (fixed day/month): parent template is deleted after spawning
- **Recurring tasks** (wildcards in day/month): parent persists for future runs
- **Granularity rule**: minute field must be a single integer (0–59); wildcards/ranges/steps are rejected — enforcing a minimum hourly granularity
This allows supervisors to schedule recurring subtask generation without human intervention.
---
## Events (Cross-Workspace Signals)
Events are named signals that let one workspace trigger tasks in other workspaces — enabling decoupled, event-driven multi-agent pipelines without requiring a supervisor agent to coordinate.
### Core Concepts
| Entity | Description |
|--------|-------------|
| `Event` | A named signal definition owned by a user. Has a `name` (unique per user, lowercase letters/digits/underscores) and an optional `payloadGuidelines` hint for agents publishing it |
| `EventTrigger` | A subscription: when a named event fires, create a task in `WorkspaceID` using the stored `Title` and `Body` template. Optional `EmitEventID` chains to a second event on the spawned task's completion |
| `Task.EventID` | Links a task to the event it should publish when completed. The assigned agent receives an explicit `workspace.publishEvent` instruction in its task notification |
| `Task.TriggerID` | Records which event caused this task (set by the event consumer at creation time) |
### Publishing Flow
```
Agent calls workspace publishEvent {name: "deploy_done", payload: "v2.3 deployed to prod"}
→ WorkspaceServer.handlePublishEvent
→ PublishEventFunc (wired in app.go): looks up event by name + userID
→ pubsubSvc.Publish(PubSubTopicEvents, EventPublishedPayload{...})
→ Event Consumer (controller/event) receives message
→ repo.SystemListEventTriggersByEventID(eventID)
→ For each EventTrigger:
body = renderTemplate(trigger.Body, payload, faqText) ← {{EVENT_PAYLOAD}} / {{EVENT_FAQ}} substituted
title = trigger.Title ← always static text, no substitution
if trigger.EmitEventID != 0:
body += "[On completion, before marking this task completed: call workspace {action: \"publishEvent\", ...}]"
task.EventID = trigger.EmitEventID
repo.CreateTask(...)
pubsubSvc.Publish(PubSubTopicCRUD, ActionTaskCreate) ← SSE push to human UI
bus.Publish(workspaceID, ...) ← EventBus SSE for real-time update
```
### Agent Instruction Injection
When a task is created with an `EventID` (via REST or triggered by an `EventTrigger.EmitEventID`), the MCP channel notification delivered to the agent is augmented:
```
[Task abc123] Review and summarize the latest deploy
<task body>
[On completion, before marking this task completed: call workspace {action: "publishEvent", payload: {name: "deploy_reviewed", taskId: …, payload: "<your output payload>"}}]
Payload guidelines: Summarise key changes, risks, and sign-off status
```
This ensures the agent knows to publish the event explicitly with a meaningful payload before marking the task completed.
### Event Chaining
`EventTrigger.EmitEventID` enables pipelines: the spawned task inherits an `EventID` pointing to a second event. When the agent completes that task and calls `workspace.publishEvent`, the second event fires, triggering its own set of `EventTrigger` rows — and so on.
```
Event A fires
→ Trigger T1 creates Task X in Workspace B (EventID = Event B)
→ Agent completes Task X, calls workspace publishEvent("event_b", payload)
→ Event B fires
→ Trigger T2 creates Task Y in Workspace C
```
### Diagrams
**Single event fan-out:**
```mermaid
sequenceDiagram
participant A as Agent (Workspace A)
participant MCP as WorkspaceServer (MCP)
participant PS as PubSub<br/>(topic: Events)
participant EC as Event Consumer
participant DB as Repository
participant B as Workspace B
participant C as Workspace C
participant UI as Human UI (SSE)
A->>MCP: workspace publishEvent("deploy_done", payload)
MCP->>DB: GetEventByName("deploy_done", userID)
DB-->>MCP: Event{id, name}
MCP->>PS: Publish(EventPublishedPayload)
PS->>EC: EventPublishedPayload received
EC->>DB: SystemListEventTriggersByEventID(eventID)
DB-->>EC: [Trigger→WS-B, Trigger→WS-C]
EC->>DB: CreateTask(WS-B, title, renderTemplate(body, payload))
EC->>DB: CreateTask(WS-C, title, renderTemplate(body, payload))
EC->>PS: Publish(ActionTaskCreate, WS-B)
EC->>PS: Publish(ActionTaskCreate, WS-C)
PS-->>UI: SSE task.created (WS-B)
PS-->>UI: SSE task.created (WS-C)
B-->>B: Agent picks up task via task get()
C-->>C: Agent picks up task via task get()
```
**Event chaining (EmitEventID):**
```mermaid
sequenceDiagram
participant A as Agent A<br/>(Workspace A)
participant EC as Event Consumer
participant B as Agent B<br/>(Workspace B)
participant C as Agent C<br/>(Workspace C)
participant PS as PubSub
Note over A: Task has EventID = "event_b"
A->>PS: workspace publishEvent("event_a", payload_a)
PS->>EC: fan-out → create Task in WS-B<br/>(body includes the publishEvent("event_b") instruction)<br/>Task.EventID = event_b
B-->>B: task get() → works on task
Note over B: Notification includes:<br/>[On completion, before marking this task completed: call workspace {action: "publishEvent", ...}]
B->>PS: workspace publishEvent("event_b", payload_b)
PS->>EC: fan-out → create Task in WS-C<br/>(body includes payload_b via {{EVENT_PAYLOAD}})
C-->>C: task get() → works on task
```
### Ownership & Authorization
All event and trigger operations are scoped to the requesting user at the repository layer (`WHERE id = ? AND user_id = ?`). The `CreateEventTrigger` controller additionally validates:
- The target event belongs to the user (`GetEvent(eventID, uid)`)
- The target workspace belongs to the user (`CheckWorkspaceAccess(workspaceID, uid)`)
- The `EmitEventID`, if set, belongs to the user (`GetEvent(emitEventID, uid)`)
---
## Attachment Handling
Attachments are stored on disk under `./_storage/{attachmentID}` as base64-encoded files:
- On save: base64 data written to disk; only metadata (ID, filename, mimeType) persisted in the DB
- On download: `task.getAttachment` reads from disk, returns base64 to agent
- On delete: attachment files purged from disk; a cleanup service (`internal/service/cleanup`) runs daily to remove orphaned files older than the configured retention period
---
## Data Persistence
- **Primary**: PostgreSQL (optional, for production)
- **Fallback**: SQLite (default for development / single-node deployment), used only when PostgreSQL is disabled. An enabled PostgreSQL that does not answer is retried with exponential backoff for about a minute and a half, then startup fails
- **ORM**: GORM with a repository abstraction layer — controllers never touch the DB directly
- **IDs**: Monoflake (monotonic snowflake-style) IDs generated in-process for all entities
- **Encryption**: Workspace tokens encrypted with AES-GCM using a server-side key from config
---
## System Topology
```
┌──────────────────────────────────────┐
│ HTTP Server │
│ (Fiber, unified port :3000) │
└──────┬──────────────┬──────┬─────────┘
│ │ │
┌────────────┘ ┌─────────┘ └──────────────┐
│ │ │
┌───────────▼──────────┐ ┌───▼────────────────────────┐ ┌───▼─────────────────┐
│ REST API │ │ Supervisor MCP (coremcp) │ │ SSE Events │
│ /api/v1/workspaces │ │ /mcp • mcp.{domain} │ │ /workspaces/{id} │
│ /api/v1/tasks │ │ OAuth2, user-scoped │ │ /events │
└───────────┬──────────┘ └───────────┬────────────────┘ └───▲─────────────────┘
│ │ │
│ ┌───────────▼────────────────┐ │
│ │ Per-Workspace MCP Servers │ │
│ │ /mcp/{workspaceID} │ │
│ │ workspace-token auth │ │
│ └───────────┬────────────────┘ │
│ │ │
┌───────────▼─────────────────────────▼──────────┐ ┌─────────┴───────────────┐
│ CRUD Controller │ │ EventBus (SSE) │
│ tasks, workspaces, messages, permissions │ │ workspace-scoped │
└───────────────────────┬────────────────────────┘ │ in-memory pub/sub │
│ └─────────────────────────┘
┌───────────────────────▼────────────────────────┐ ▲
│ Repository (GORM) │ │
│ SQLite ◄──────────────► PostgreSQL │ │
└───────────────────────┬────────────────────────┘ │
│ │
┌───────────────────────▼──────────────┐ ┌────────────────────┴────────┐
│ PubSub (internal) ├─►│ Central Forwarder │
│ CRUD / MCP topics │ │ (PubSub → EventBus) │
└──────────────────────────────────────┘ └─────────────────────────────┘
```
---
## Security Boundaries
| Boundary | Mechanism |
|----------|-----------|
| Supervisor ↔ Core MCP Server | OAuth2 with JWT access token (audience `"coremcp"`, user-scoped; grants cross-workspace access) |
| Worker Agent ↔ Per-Workspace MCP | Workspace token (AES-GCM encrypted at rest, bearer token in transit; scoped to one workspace) |
| Human ↔ REST API | JWT (session cookie or Authorization header) |
| Permission gating | Auto-allow rules + human approval flow via SSE + MCP notification |
| Workspace isolation | Per-workspace MCP servers are scoped exclusively to their workspace; no cross-workspace tool calls within a worker session |
| Rate limiting | Per-user request counters for tasks, messages, and workspace creation |
| DDoS protection | Per-IP request counting with configurable block duration |
---
## Telemetry
All significant actions are recorded as telemetry events (`uint8 action`, `uint8 actor`) and batched to the database every 5 seconds. Tracked events include workspace and task lifecycle actions, message creation, MCP tool calls, permission approvals/denials, and scheduled task spawns. These power the workspace dashboard stats and global analytics.