796 lines
31 KiB
Markdown
796 lines
31 KiB
Markdown
# Daedalus ↔ Pallas ↔ Mnemosyne Integration — v1
|
||
|
||
**Status:** Draft / approved design
|
||
**Authoritative home:** `mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`
|
||
**Versioning:** subsequent major revisions ship as `..._v2.md`, `..._v3.md`
|
||
alongside this file rather than overwriting it. Cross-service docs
|
||
(Daedalus, Pallas) link here rather than duplicating.
|
||
|
||
---
|
||
|
||
## 1. Summary
|
||
|
||
This document describes the end-state authentication / authorization
|
||
model connecting three services:
|
||
|
||
* **Mnemosyne** — knowledge platform. Owns Libraries, users, and the
|
||
MCP surface third-party clients query.
|
||
* **Daedalus** — workspace + file-lifecycle UI. Single-user per
|
||
instance. Registers Pallas instances, syncs file content to
|
||
Mnemosyne, drives chat.
|
||
* **Pallas** — FastAgent-backed MCP host that exposes agent teams
|
||
(Kottos, Mentor, Iolaus, Daedalus-chat, …) as HTTP MCP servers.
|
||
|
||
The model replaces the per-turn JWT *forwarding* scheme with a unified
|
||
**bearer → resolved library set** abstraction. Every authenticated
|
||
Mnemosyne request resolves to a single ordered `resolved_libraries`
|
||
list of Library UIDs the caller may read; the principal type (opaque
|
||
`MCPToken`, Daedalus per-turn JWT, team JWT) only determines how that
|
||
list is derived. `Library.workspace_id` is a Daedalus content-routing
|
||
attribute used by the ingest and workspace-lifecycle APIs; it is **not**
|
||
consulted by the auth layer.
|
||
|
||
It also records the UX shift in Daedalus: **workspaces attach Teams
|
||
(Pallas instances), not individual agents**; the agent picker in chat
|
||
is filtered by the workspace's attached teams.
|
||
|
||
---
|
||
|
||
## 2. Motivation
|
||
|
||
The previous design forwarded a Daedalus-minted per-turn JWT through
|
||
Pallas to Mnemosyne via a custom `_DynamicBearerAuth`, a `ContextVar`,
|
||
a YAML scanner (`_refresh_forward_servers`), a header-mutation
|
||
monkey-patch, and three trace wrappers in
|
||
`pallas/pallas/_fastagent_patch.py`. When something broke on this
|
||
path, tracebacks landed nowhere visible because fast-agent's
|
||
`MCPAggregator._execute_on_server.try_execute` swallowed exceptions
|
||
(`except Exception as e: logger.error(…str(e)…); return error_factory(...)`),
|
||
and we were monkey-patching under it.
|
||
|
||
The failure mode (agent teams like Harper going into infinite
|
||
token-burning loops when Mnemosyne was unhappy) combined with the
|
||
diagnostic opacity made this architecture unsustainable. Per-turn
|
||
forwarding was also the wrong shape for non-interactive agent teams
|
||
that have no user session per call.
|
||
|
||
This design eliminates forwarding. Each Pallas deployment carries a
|
||
static, long-lived team JWT in its own `fastagent.secrets.yaml`. No
|
||
custom transport code in Pallas. Authorization happens server-side in
|
||
Mnemosyne against live DB rows.
|
||
|
||
---
|
||
|
||
## 3. Architecture
|
||
|
||
### 3.1 Services and responsibilities
|
||
|
||
| Service | Role in auth model |
|
||
|---|---|
|
||
| **Mnemosyne** | Owns Libraries, Library memberships, MCPTokens, Teams, TeamWorkspaceAssignments, signing keys. Validates bearers. Resolves every authenticated request to a Library set. |
|
||
| **Daedalus** | Control plane. Registers Pallas instances as Teams in Mnemosyne. Manages workspace ↔ team attachments. Stores team JWTs for copying into Pallas deployment configs. |
|
||
| **Pallas** | Stateless MCP host. Holds a static team JWT in `fastagent.secrets.yaml`. No custom auth-forwarding code. |
|
||
|
||
### 3.2 Three credential types
|
||
|
||
Every Mnemosyne MCP call presents a Bearer token that falls into one
|
||
of three categories:
|
||
|
||
| # | Credential | `iss` | Issuer | Lifetime | Library scope source |
|
||
|---|---|---|---|---|---|
|
||
| 1 | **Opaque `MCPToken`** | n/a | Mnemosyne admin | Until revoked | `MCPToken.allowed_libraries` (M2M, set at mint) |
|
||
| 2 | **Per-turn JWT** | `daedalus` | Daedalus | Minutes | `libs` claim (baked in at mint) |
|
||
| 3 | **Team JWT** | `mnemosyne` | Mnemosyne | 10 years | Live DB lookup via `Team.workspaces → Library` |
|
||
|
||
Category 2 is used only by Daedalus chat. Once Daedalus-chat itself
|
||
registers as a Pallas Team in Phase 4, category 2 retires entirely
|
||
and the design collapses to two credential types.
|
||
|
||
### 3.3 Resolved-library abstraction
|
||
|
||
Mnemosyne's auth middleware populates a single
|
||
`resolved_libraries: list[str]` per request. Downstream code (search,
|
||
get_chunk, list_libraries, list_collections, list_items, …) only
|
||
reads that list; it does not care where it came from.
|
||
|
||
```
|
||
Bearer → classify → dispatch
|
||
├─ Opaque MCPToken → token.allowed_libraries (JSON list of UIDs)
|
||
├─ per-turn JWT → claims["libs"]
|
||
└─ team JWT (typ=team) → live DB join:
|
||
TeamWorkspaceAssignment.workspace_id
|
||
→ Library.workspace_id → Library.uid
|
||
↓
|
||
resolved_libraries: list[str]
|
||
↓
|
||
downstream tools
|
||
```
|
||
|
||
Fail-closed: if the resolution produces an empty list, the request
|
||
sees no Libraries. There is no "empty means everything" fallback.
|
||
|
||
#### 3.3.1 Retirement of the old three-branch scope clause
|
||
|
||
The pre-phase-2 search pipeline ran every Cypher query against a
|
||
`_WORKSPACE_SCOPE_CLAUSE` with three branches keyed on whether
|
||
`workspace_id` and/or `allowed_libraries` were set. Phase 2 removes
|
||
that clause entirely. Every authorization check collapses to:
|
||
|
||
```cypher
|
||
WHERE lib.uid IN $resolved_libraries
|
||
```
|
||
|
||
`Library.workspace_id` stays on the node as a Daedalus content-routing
|
||
attribute (used by the ingest API to find-or-create the per-workspace
|
||
Library, and by the workspace-lifecycle API to cascade-delete that
|
||
Library's contents). It is **not** an authorization axis and is not
|
||
consulted anywhere in the auth middleware, the MCP tool surface, or
|
||
the search service.
|
||
|
||
Admin-UI-initiated searches (Django staff logged into the Mnemosyne
|
||
admin / search page) materialize `resolved_libraries` explicitly as
|
||
"every Library UID the database contains" — the same mechanism used
|
||
today as a workaround, now the only code path.
|
||
|
||
---
|
||
|
||
## 4. Data model
|
||
|
||
### 4.1 Mnemosyne additions
|
||
|
||
#### `LibraryMembership` (new)
|
||
```python
|
||
class LibraryMembership(models.Model):
|
||
user = FK(User, related_name="library_memberships")
|
||
library_uid = CharField(max_length=64, db_index=True) # neo4j Library.uid
|
||
role = CharField(choices=[("owner","owner"),
|
||
("manager","manager"),
|
||
("reader","reader")])
|
||
created_at = DateTimeField(auto_now_add=True)
|
||
class Meta:
|
||
unique_together = ("user", "library_uid", "role")
|
||
```
|
||
- **owner** — full control: rename, delete, reassign ownership,
|
||
grant/revoke any role.
|
||
- **manager** — can grant `reader` and scope Library into MCPTokens
|
||
they own, but cannot delete the library or remove other owners.
|
||
- **reader** — can read via their own MCPTokens; cannot grant to other
|
||
users.
|
||
|
||
User can scope a Library into `MCPToken.allowed_libraries` iff they
|
||
have `owner` or `manager` role on it.
|
||
|
||
#### `MCPToken.allowed_libraries` (new field on existing model)
|
||
```python
|
||
# JSON list of Library.uid strings. A real M2M isn't possible because
|
||
# Library lives in Neo4j (neomodel StructuredNode), not Django's ORM.
|
||
# The admin/dashboard form materializes the picker by querying
|
||
# Library.nodes and filtering to libraries where the token's user has
|
||
# an ``owner`` or ``manager`` LibraryMembership.
|
||
allowed_libraries = models.JSONField(default=list, blank=True)
|
||
```
|
||
Fail-closed: empty → token grants access to zero libraries.
|
||
Admin form filters the picker by the current user's owned/managed
|
||
memberships.
|
||
|
||
#### `Team` (new)
|
||
```python
|
||
class Team(models.Model):
|
||
id = UUIDField(primary_key=True, editable=False)
|
||
# = Daedalus PallasInstance.id. Stays stable across redeploy /
|
||
# rehost of a given Pallas instance.
|
||
name = CharField(max_length=200) # display; mirrored from Daedalus
|
||
active = BooleanField(default=True)
|
||
active_jti = UUIDField(null=True) # current valid jti
|
||
created_at = DateTimeField(auto_now_add=True)
|
||
updated_at = DateTimeField(auto_now=True)
|
||
```
|
||
|
||
#### `TeamWorkspaceAssignment` (new)
|
||
```python
|
||
class TeamWorkspaceAssignment(models.Model):
|
||
team = FK(Team, related_name="workspace_assignments",
|
||
on_delete=CASCADE)
|
||
workspace_id = CharField(max_length=64) # matches Library.workspace_id
|
||
created_at = DateTimeField(auto_now_add=True)
|
||
class Meta:
|
||
unique_together = ("team", "workspace_id")
|
||
```
|
||
|
||
No library-level assignment for teams. Teams gain access to *all*
|
||
libraries of their assigned workspaces. If finer control is ever
|
||
needed later, it layers on without disturbing this model.
|
||
|
||
#### `MCPSigningKey` (existing, unchanged)
|
||
Re-used to sign team JWTs. The same signing key can back both
|
||
per-turn tokens (pre-retirement) and team tokens (long-lived).
|
||
|
||
### 4.2 Daedalus additions
|
||
|
||
#### `PallasInstance.team_jwt_encrypted` (new column, text, Fernet)
|
||
Stores the team JWT received from Mnemosyne at registration time.
|
||
Fernet-encrypted at rest using the same pattern as
|
||
`daedalus/llm_manager/encryption.py`. Displayed plaintext exactly once
|
||
in the admin detail page immediately after provisioning, so the
|
||
operator can copy it into `fastagent.secrets.yaml` on the Pallas
|
||
deployment.
|
||
|
||
#### `PallasInstance.pallas_team_mnemosyne_status` (new column)
|
||
`NULL | "pending" | "provisioned" | "failed"`. Drives the reconciler;
|
||
analogous to the existing `WorkspaceFile.mnemosyne_status`.
|
||
|
||
#### `workspace_pallas_assignments` (new M2M table)
|
||
```sql
|
||
CREATE TABLE workspace_pallas_assignments (
|
||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||
pallas_instance_id TEXT NOT NULL REFERENCES pallas_instances(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
PRIMARY KEY (workspace_id, pallas_instance_id)
|
||
);
|
||
```
|
||
|
||
Starts empty on workspace create. Operator explicitly attaches Pallas
|
||
instances (Teams) before any Mnemosyne-backed agent — including
|
||
Daedalus chat — can search that workspace.
|
||
|
||
### 4.3 Nothing changes in Pallas
|
||
After cleanup, `pallas/pallas/_fastagent_patch.py` either becomes an
|
||
empty placeholder or is removed entirely. `pallas/__init__.py` no
|
||
longer invokes `install()`. Pallas deployments configure stock
|
||
fast-agent with a static `Authorization: Bearer <team-jwt>`.
|
||
|
||
---
|
||
|
||
## 5. JWT claim shapes
|
||
|
||
### 5.1 Per-turn JWT (category 2 — legacy, retires in Phase 4)
|
||
```json
|
||
{
|
||
"iss": "daedalus",
|
||
"aud": "mnemosyne", // optional, not enforced
|
||
"sub": "daedalus-chat",
|
||
"iat": 1715000000,
|
||
"exp": 1715000600, // ≤ 10 minutes
|
||
"jti": "uuid4",
|
||
"ws": "ws_abc", // Daedalus workspace id
|
||
"libs": ["lib_xxx", "lib_yyy"] // user-managed libraries
|
||
}
|
||
```
|
||
Kept unchanged during Phase 2–3 so Daedalus chat continues to work
|
||
while we ship the team infrastructure.
|
||
|
||
### 5.2 Team JWT (category 3 — new)
|
||
```json
|
||
{
|
||
"iss": "mnemosyne",
|
||
"aud": "mnemosyne",
|
||
"sub": "team:<pallas_instance_uuid>", // UUID; Daedalus id
|
||
"typ": "team", // distinguishes from per-turn
|
||
"iat": 1715000000,
|
||
"exp": 1976000000, // +10 years
|
||
"jti": "uuid4"
|
||
}
|
||
```
|
||
No `ws`, no `libs`. Authorization is evaluated live against
|
||
`TeamWorkspaceAssignment` rows on every request.
|
||
|
||
### 5.3 Validator changes in `mcp_server/auth.py`
|
||
|
||
```python
|
||
# Accept both issuers; distinguish paths by typ.
|
||
_JWT_ISS = {"daedalus", "mnemosyne"}
|
||
|
||
def resolve_mcp_jwt(token_string: str) -> dict:
|
||
... # validate signature, iat/exp, required claims including sub
|
||
|
||
typ = claims.get("typ")
|
||
if typ == "team":
|
||
# No replay cache — team tokens are reused on every request.
|
||
# Validate sub=="team:<uuid>" shape; stash the uuid on claims.
|
||
pass
|
||
else:
|
||
if _remember_jti(jti, float(exp)):
|
||
raise MCPAuthError("Token replay detected.")
|
||
|
||
return claims
|
||
```
|
||
|
||
Middleware populates `STATE_KEY_RESOLVED_LIBRARIES` per request:
|
||
|
||
```python
|
||
# Opaque MCPToken
|
||
resolved_libraries = list(token.allowed_libraries or [])
|
||
|
||
# Per-turn JWT (legacy; retires phase 4)
|
||
resolved_libraries = list(claims.get("libs") or [])
|
||
|
||
# Team JWT
|
||
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
|
||
active=True,
|
||
active_jti=claims["jti"])
|
||
resolved_libraries = _libraries_for_team(team) # see below
|
||
```
|
||
|
||
`_libraries_for_team(team)` runs a single Cypher query against Neo4j:
|
||
|
||
```cypher
|
||
MATCH (l:Library)
|
||
WHERE l.workspace_id IN $workspace_ids
|
||
RETURN l.uid
|
||
```
|
||
|
||
where `$workspace_ids` is `list(team.workspace_assignments.values_list("workspace_id", flat=True))`.
|
||
|
||
---
|
||
|
||
## 6. Auth flow
|
||
|
||
### 6.1 Third-party MCP client with opaque `MCPToken`
|
||
1. Client sends `Authorization: Bearer <plaintext>`.
|
||
2. Middleware hashes → looks up `MCPToken` → validates active/expired.
|
||
3. `resolved_libraries = list(token.allowed_libraries or [])` — the
|
||
JSON list of Library UIDs the admin / dashboard granted at mint.
|
||
4. Fails closed if empty.
|
||
|
||
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
|
||
`iss=daedalus`, `typ` absent, `libs` carries the full library set
|
||
Daedalus pre-computed for that turn (the workspace's auto-Library
|
||
plus any user-managed extras), `ws` is present but no longer consulted
|
||
server-side. Middleware assigns `resolved_libraries = claims["libs"]`.
|
||
Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`.
|
||
|
||
### 6.3 Agent team (Kottos / Mentor / Iolaus / post-migration Daedalus-chat)
|
||
1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from
|
||
`fastagent.secrets.yaml`).
|
||
2. Middleware validates signature → detects `typ=team`.
|
||
3. Reads `Team` row by UUID from `sub`. Verifies `active=True` and
|
||
`jti == active_jti`. Rejects otherwise.
|
||
4. Expands to `resolved_libraries` via
|
||
`TeamWorkspaceAssignment` → `Library.workspace_id`.
|
||
5. Fails closed if the team has no workspaces attached.
|
||
|
||
### 6.4 Failure modes
|
||
|
||
| Condition | Response |
|
||
|---|---|
|
||
| JWT signature invalid | `PermissionError("Invalid MCP token.")` |
|
||
| `exp` past (+30s leeway) | `PermissionError("Token has expired.")` |
|
||
| `iss` not in `_JWT_ISS` | `PermissionError("Invalid token issuer.")` |
|
||
| `typ=team`, team not found | `PermissionError("Invalid MCP token.")` |
|
||
| `typ=team`, team not active | `PermissionError("Token has been deactivated.")` |
|
||
| `typ=team`, `jti` stale | `PermissionError("Invalid MCP token.")` |
|
||
| Opaque token not found | `PermissionError("Invalid MCP token.")` |
|
||
| Opaque token, inactive user | `PermissionError("User account is disabled.")` |
|
||
| Resolved library set empty | Tool call proceeds but returns empty — this is *authorization*, not *authentication*, and the caller is legitimately scoped to nothing. |
|
||
|
||
---
|
||
|
||
## 7. REST API — Mnemosyne team lifecycle
|
||
|
||
All endpoints live under `/mcp_server/api/teams/` and are authenticated
|
||
as the Mnemosyne user the team belongs to via a per-user DRF token
|
||
(`Authorization: Token <key>`, surfaced on `/profile/settings/`). Each
|
||
team has an `owner` FK; non-owners receive 404 (never 403) so a team's
|
||
existence isn't disclosed across users. `/library/api/workspaces/` and
|
||
`/library/api/ingest/` use the same per-user auth model.
|
||
|
||
### 7.1 `POST /mcp_server/api/teams/`
|
||
Create a team.
|
||
|
||
**Request**
|
||
```json
|
||
{
|
||
"id": "a3f1…", // UUID; mirrors Daedalus PallasInstance.id
|
||
"name": "Kottos"
|
||
}
|
||
```
|
||
|
||
**Response 201**
|
||
```json
|
||
{
|
||
"id": "a3f1…",
|
||
"name": "Kottos",
|
||
"jwt": "eyJhbGci…" // shown once; not recoverable later
|
||
}
|
||
```
|
||
|
||
On `id` collision: idempotent — returns existing team **without** the
|
||
JWT. Caller must call `/rotate` to get a new one.
|
||
|
||
### 7.2 `DELETE /mcp_server/api/teams/{id}/`
|
||
Soft-delete. Sets `active=False`. Old JWT invalid on next call.
|
||
|
||
### 7.3 `PUT /mcp_server/api/teams/{id}/workspaces/`
|
||
Replace the team's workspace assignment set. Idempotent.
|
||
|
||
**Request**
|
||
```json
|
||
{ "workspace_ids": ["ws_abc", "ws_def"] }
|
||
```
|
||
|
||
**Response 200**
|
||
```json
|
||
{ "workspace_ids": ["ws_abc", "ws_def"] }
|
||
```
|
||
|
||
Non-existent workspaces silently accepted (they become active if/when
|
||
a `Library` with that `workspace_id` is later created). Mirrors the
|
||
Daedalus source of truth.
|
||
|
||
### 7.4 `POST /mcp_server/api/teams/{id}/rotate/`
|
||
Generate a fresh `jti` and JWT, replace `active_jti`. Old JWT invalid
|
||
immediately.
|
||
|
||
**Response 200**
|
||
```json
|
||
{ "jwt": "eyJhbGci…" }
|
||
```
|
||
|
||
### 7.5 `GET /mcp_server/api/teams/{id}/`
|
||
Read-only team detail (no JWT). Used by Daedalus reconciler to
|
||
confirm state.
|
||
|
||
**Response 200**
|
||
```json
|
||
{
|
||
"id": "a3f1…",
|
||
"name": "Kottos",
|
||
"active": true,
|
||
"active_jti": "…", // for diagnostics, not a credential
|
||
"workspace_ids": ["ws_abc"]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Daedalus lifecycle hooks
|
||
|
||
Mirrors the pattern in `daedalus/backend/daedalus/mnemosyne/lifecycle.py`:
|
||
every hook is best-effort, logs errors without blocking the local
|
||
operation, and is retried by the reconciler.
|
||
|
||
### 8.1 `on_pallas_registered(instance)`
|
||
1. `POST /mcp_server/api/teams/` with `id=instance.id`, `name=instance.name`.
|
||
2. Encrypt JWT via Fernet; store on `instance.team_jwt_encrypted`;
|
||
set `pallas_team_mnemosyne_status="provisioned"`.
|
||
3. Log `pallas_team_provisioned`.
|
||
|
||
On failure: status `"failed"`, reconciler retries.
|
||
|
||
### 8.2 `on_pallas_deleted(instance_id)`
|
||
`DELETE /mcp_server/api/teams/{id}/`. Row cascade locally; Mnemosyne
|
||
soft-deletes. Best-effort.
|
||
|
||
### 8.3 `on_workspace_pallas_attached(workspace_id, instance_id)`
|
||
1. Read all current `workspace_pallas_assignments` where
|
||
`pallas_instance_id=instance_id`.
|
||
2. `PUT /mcp_server/api/teams/{id}/workspaces/` with the resulting
|
||
workspace_id list.
|
||
|
||
### 8.4 `on_workspace_pallas_detached(workspace_id, instance_id)`
|
||
Symmetric to 8.3.
|
||
|
||
### 8.5 `on_workspace_deleted(workspace_id)`
|
||
For every attached Pallas instance, recompute and push updated
|
||
workspace list (so teams lose the deleted workspace).
|
||
|
||
### 8.6 Reconciler extension
|
||
Extends
|
||
`daedalus/backend/daedalus/mnemosyne/reconciler.py`:
|
||
- Re-runs `POST /teams/` for instances with status NULL or `"failed"`.
|
||
- Re-syncs workspace assignments for all instances on every cycle
|
||
(cheap idempotent PUT; guards against silent drift).
|
||
|
||
---
|
||
|
||
## 9. Operator workflows
|
||
|
||
### 9.1 Register a new Pallas deployment
|
||
1. Operator adds entry in Daedalus admin: `POST /api/v1/pallas`
|
||
with the Pallas registry URL. Daedalus fetches the registry,
|
||
creates the `PallasInstance` row, then calls Mnemosyne
|
||
`POST /mcp_server/api/teams/`. The JWT lands in
|
||
`instance.team_jwt_encrypted`.
|
||
2. Daedalus admin detail page surfaces the JWT plaintext **once**
|
||
(decrypted client-side or via a one-shot "reveal" endpoint that
|
||
logs the access). Operator copies it.
|
||
3. On the Pallas deployment machine, operator pastes the JWT into
|
||
`fastagent.secrets.yaml`:
|
||
```yaml
|
||
mcp:
|
||
servers:
|
||
mnemosyne:
|
||
transport: http
|
||
url: https://mnemosyne.example.helu.ca/mcp/
|
||
headers:
|
||
Authorization: Bearer eyJhbGci…
|
||
```
|
||
Operator removes any stale `forward_inbound_auth: true` from the
|
||
corresponding entry in `fastagent.config.yaml`. Restart Pallas.
|
||
|
||
### 9.2 Attach a Pallas team to a workspace
|
||
1. Daedalus workspace settings → "Attached Teams" → multi-select
|
||
across registered Pallas instances → save.
|
||
2. Daedalus fires `on_workspace_pallas_attached`, Mnemosyne's
|
||
`TeamWorkspaceAssignment` updates.
|
||
3. Agent picker in chat immediately shows agents from that team for
|
||
this workspace.
|
||
|
||
### 9.3 Retire a Pallas deployment
|
||
1. Daedalus admin → delete PallasInstance.
|
||
2. Daedalus calls `DELETE /mcp_server/api/teams/{id}/`; row marked
|
||
inactive in Mnemosyne. JWT rejected on next call.
|
||
3. Operator shuts down the Pallas deployment.
|
||
|
||
### 9.4 Rotate a compromised team JWT
|
||
1. Daedalus admin → "Rotate team JWT" action on the PallasInstance.
|
||
2. Daedalus calls `POST /mcp_server/api/teams/{id}/rotate/`,
|
||
re-encrypts and stores the new JWT.
|
||
3. Operator copies the new JWT into the Pallas deployment's
|
||
`fastagent.secrets.yaml`, restarts.
|
||
|
||
### 9.5 Provision existing Pallas instances (one-time migration)
|
||
After Mnemosyne phase 2 deploys:
|
||
```
|
||
$ daedalus manage.py provision_teams
|
||
```
|
||
Walks all existing `PallasInstance` rows, calls
|
||
`POST /mcp_server/api/teams/` for each, stores + prints JWTs in a
|
||
table for the operator to distribute. Idempotent: rows already
|
||
`provisioned` are skipped.
|
||
|
||
### 9.6 Issue an MCPToken for a third-party MCP client
|
||
1. Mnemosyne admin → MCPTokens → add. Pick user. Library picker is
|
||
filtered to libraries where that user has `owner` or `manager`
|
||
membership.
|
||
2. Submit. Plaintext shown once on the response page.
|
||
3. Operator pastes the plaintext into the third-party client's
|
||
config (Claude Desktop, Cline, etc.).
|
||
|
||
---
|
||
|
||
## 10. UX changes in Daedalus
|
||
|
||
### 10.1 Workspace → attached Teams
|
||
|
||
Today: workspaces accumulate `AgentConnection` rows across every
|
||
registered Pallas instance; the agent picker is long, sub-agents
|
||
share names with parents (e.g. two distinct "Harper" agents), and it
|
||
is unclear which workspace grants which agents Mnemosyne access.
|
||
|
||
New:
|
||
- Workspace settings has a new section **Attached Teams**, a
|
||
multi-select over registered `PallasInstance` rows. Starts empty on
|
||
workspace create.
|
||
- Attaching/detaching a team triggers the lifecycle hook that updates
|
||
Mnemosyne's `TeamWorkspaceAssignment` for that team.
|
||
|
||
### 10.2 Agent picker in chat
|
||
|
||
New behavior:
|
||
- Lists only agents belonging to teams attached to the current
|
||
workspace.
|
||
- Displays each agent as **`TeamName › AgentTitle`** so sub-agent
|
||
name collisions resolve visually.
|
||
- Empty state: "No teams attached to this workspace. Go to workspace
|
||
settings → Attached Teams." (links inline).
|
||
|
||
### 10.3 Agent switching during chat
|
||
|
||
Unchanged mechanically: each chat turn may target a different
|
||
`agent_server_id`. New constraint: the target agent must belong to a
|
||
team currently attached to the workspace. The REST endpoint validates
|
||
this and returns 403 otherwise. The frontend picker already enforces
|
||
it as a UX affordance.
|
||
|
||
---
|
||
|
||
## 11. Migration
|
||
|
||
### 11.1 Current state (pre-migration)
|
||
|
||
* Mnemosyne is currently not in a released/working deployment; a
|
||
fresh rollout is possible.
|
||
* Daedalus has existing `PallasInstance` rows (registered via
|
||
`POST /api/v1/pallas`) but none have a team JWT.
|
||
* Kottos / Mentor / Iolaus each carry `forward_inbound_auth: true`
|
||
in `fastagent.config.yaml` and currently rely on the Pallas
|
||
forwarding patch to pass Daedalus's per-turn JWT to Mnemosyne.
|
||
|
||
### 11.2 Order of operations (must follow)
|
||
|
||
1. **Mnemosyne phase 2 deploys.** REST `/mcp_server/api/teams/` is
|
||
live; old per-turn JWT path still works. No consumers yet.
|
||
2. **Daedalus phase 4 deploys.** New columns + lifecycle hooks +
|
||
`provision_teams` command. On upgrade, migration creates columns
|
||
with default NULL status; existing PallasInstances remain
|
||
functional on the legacy (non-Mnemosyne) path.
|
||
3. **Operator runs `provision_teams`.** Every existing PallasInstance
|
||
gets a team in Mnemosyne and a stored JWT.
|
||
4. **Operator distributes JWTs** to each Pallas deployment
|
||
(Kottos / Mentor / Iolaus / Daedalus-chat). Each deployment
|
||
updates `fastagent.secrets.yaml`, removes
|
||
`forward_inbound_auth: true`, restarts.
|
||
5. **Pallas phase 3 cleanup deploys.** Forwarding infrastructure
|
||
removed from Pallas codebase. Safe only after all deployments
|
||
have switched to static JWTs.
|
||
6. **Daedalus per-turn token path retires.** `mnemosyne/tokens.py`
|
||
and its config (`MNEMOSYNE_SIGNING_SECRET`,
|
||
`MNEMOSYNE_SIGNING_KID`, `MNEMOSYNE_TOKEN_TTL_SECONDS`) are
|
||
removed after Daedalus chat's own team JWT is in place.
|
||
|
||
### 11.3 Rollback story
|
||
|
||
* **Mnemosyne phase 2**: safe to roll back — old per-turn JWT path
|
||
untouched; new endpoints simply disappear.
|
||
* **Daedalus phase 4**: safe to roll back until `provision_teams`
|
||
has run. After that, the JWTs are already distributed; rolling
|
||
back means the JWTs go unused but nothing breaks.
|
||
* **Pallas phase 3**: *not* safe to roll back independently — if any
|
||
deployment still has `forward_inbound_auth: true` pointing at code
|
||
that no longer exists, that deployment fails to start. Sequence
|
||
correctly.
|
||
|
||
---
|
||
|
||
## 12. Deprecated / removed
|
||
|
||
At end-of-migration (after Phase 6):
|
||
|
||
### Pallas (`pallas/pallas/_fastagent_patch.py`)
|
||
- `_DynamicBearerAuth` (httpx Auth subclass)
|
||
- `_CurrentBearer` ContextVar plumbing
|
||
- `_refresh_forward_servers()` YAML scanner
|
||
- `_prepare_headers_and_auth_with_forward` (the header-mutation monkey-patch)
|
||
- `_send_request_with_trace`, `_session_call_tool_with_trace`,
|
||
`_execute_on_server_with_trace` (diagnostic wrappers installed
|
||
because the forwarding path was opaque)
|
||
- `install()` function in `_fastagent_patch`; `pallas/__init__.py` no
|
||
longer auto-installs
|
||
|
||
### Agent team configs (`kottos/`, `mentor/`, `iolaus/`)
|
||
- `forward_inbound_auth: true` under any server stanza in
|
||
`fastagent.config.yaml`
|
||
|
||
### Daedalus (`daedalus/backend/daedalus/`)
|
||
- `mnemosyne/tokens.py` (per-turn JWT mint)
|
||
- Config settings: `MNEMOSYNE_SIGNING_SECRET`, `MNEMOSYNE_SIGNING_KID`,
|
||
`MNEMOSYNE_TOKEN_TTL_SECONDS`, `MNEMOSYNE_MCP_URL`
|
||
|
||
### Mnemosyne (`mnemosyne/mcp_server/`)
|
||
- `_JWT_ISS` constant-string (replaced by set containing `daedalus`
|
||
and `mnemosyne`; `daedalus` entry removed in a later version once
|
||
per-turn path is deleted)
|
||
- The `_JTI_CACHE` replay logic continues to exist for the per-turn
|
||
path until that path retires; team JWTs bypass it entirely
|
||
|
||
---
|
||
|
||
## 13. Security
|
||
|
||
### 13.1 Token lifetimes
|
||
* **Opaque MCPToken**: until revoked (admin). Rotation is manual.
|
||
* **Per-turn JWT**: ≤ 10 minutes (existing `MNEMOSYNE_TOKEN_TTL_SECONDS`).
|
||
Retires with Phase 4 completion.
|
||
* **Team JWT**: 10 years. Rationale: operator cannot tolerate a
|
||
silent expiry-induced outage in a year. Revocation is explicit via
|
||
`Team.active`, `Team.active_jti`, or key rotation.
|
||
|
||
### 13.2 Revocation levers, in order of granularity
|
||
1. `PUT /teams/{id}/workspaces/` with `[]` — team sees nothing, JWT
|
||
still validates. Useful for pausing a deployment without
|
||
redistributing tokens.
|
||
2. `DELETE /teams/{id}/` — team marked inactive. All its tokens
|
||
rejected. Restoring requires re-POST (new id) or admin DB edit.
|
||
3. `POST /teams/{id}/rotate/` — `active_jti` changes; the token that
|
||
leaked stops working; the new JWT must be distributed.
|
||
4. `MCPSigningKey.retire()` — nuclear option. All JWTs signed with
|
||
that kid stop validating. Re-key + re-issue every team token.
|
||
|
||
### 13.3 At-rest protection
|
||
* `MCPToken.token_hash`: SHA-256 of plaintext; plaintext never stored.
|
||
* `MCPSigningKey.secret_hex`: 256-bit hex secret stored in Mnemosyne
|
||
DB only (not distributed).
|
||
* `PallasInstance.team_jwt_encrypted`: Fernet-encrypted by Daedalus's
|
||
`SECRET_KEY` (or `MNEMOSYNE_FERNET_KEY` if configured);
|
||
ciphertext at rest.
|
||
|
||
### 13.4 Audit points
|
||
* Every auth failure increments `mcp_auth_failures_total{reason=…}`.
|
||
* Every team lifecycle action logs `pallas_team_{provisioned,
|
||
deleted, rotated, workspaces_updated}` in Daedalus with full
|
||
correlation IDs.
|
||
* Every bearer resolution logs the principal type + resolved
|
||
library count at DEBUG (INFO until shakedown stabilizes).
|
||
|
||
### 13.5 Isolation model
|
||
|
||
Separation of "work" vs "personal" agents composes from three
|
||
independent mechanisms:
|
||
|
||
1. **Per-token scope (Mnemosyne)**: each MCPToken carries its own
|
||
`allowed_libraries`. A personal token and a work token may
|
||
belong to the same user yet see disjoint Library sets.
|
||
2. **Per-workspace attachment (Daedalus)**: a Pallas instance only
|
||
sees workspaces explicitly attached to it. Work Pallas and
|
||
personal Pallas attach to disjoint workspaces.
|
||
3. **Per-Daedalus-instance (deployment)**: the strongest isolation
|
||
is two Daedalus deployments pointing at distinct Mnemosyne
|
||
accounts. Nothing the operator does in deployment A reaches any
|
||
data accessible to deployment B.
|
||
|
||
For typical operator isolation, (1) + (2) suffice. (3) is the
|
||
escape hatch for hard compartmentalization.
|
||
|
||
---
|
||
|
||
## 14. Testing
|
||
|
||
### 14.1 Mnemosyne test surface
|
||
* `resolve_mcp_jwt` accepts `iss in {daedalus, mnemosyne}`.
|
||
* `typ=team` branch: bypasses replay cache; resolves team + active_jti.
|
||
* `typ=team`: rejects if team missing, inactive, or `jti` stale.
|
||
* `LibraryMembership`: owner can grant; reader cannot; grant form
|
||
filters correctly.
|
||
* `MCPToken.allowed_libraries` empty → resolved library set empty.
|
||
* `TeamWorkspaceAssignment` PUT is idempotent and replaces, not
|
||
unions.
|
||
* `/mcp_server/api/teams/` endpoints: create, delete, rotate,
|
||
workspaces PUT, all authenticated with a per-user DRF token and
|
||
scoped to the team's `owner` (non-owner requests return 404).
|
||
|
||
### 14.2 Daedalus test surface
|
||
* `on_pallas_registered` populates `team_jwt_encrypted` and transitions
|
||
status to `provisioned`.
|
||
* `on_workspace_pallas_attached` triggers the correct PUT payload.
|
||
* Agent-picker endpoint filters `AgentConnection` by attached
|
||
Pallas instances.
|
||
* `provision_teams` is idempotent.
|
||
|
||
### 14.3 Integration
|
||
* End-to-end: third-party MCP client with MCPToken → Mnemosyne
|
||
search scoped to `allowed_libraries`.
|
||
* End-to-end: Pallas agent with team JWT → Mnemosyne search scoped
|
||
to team's attached workspaces.
|
||
* End-to-end: workspace detached from team → agent no longer sees
|
||
that workspace's libraries (on next request, not stale-cached).
|
||
|
||
---
|
||
|
||
## 15. Phased delivery
|
||
|
||
| # | Phase | Surface | Deployable independently? |
|
||
|---|---|---|---|
|
||
| 1 | Design doc | This file | Yes — this document |
|
||
| 2 | Mnemosyne | `LibraryMembership`, `MCPToken.allowed_libraries`, `Team`, `TeamWorkspaceAssignment`, unified `auth.py` resolver, `/mcp_server/api/teams/` REST, admin UIs, backfill, tests | Yes — old per-turn JWT path untouched |
|
||
| 3 | Pallas cleanup | Remove `_fastagent_patch.py` internals, docs | No — must wait until all deployments use static JWTs |
|
||
| 4 | Daedalus integration | `workspace_pallas_assignments`, `team_jwt_encrypted`, `pallas_team_mnemosyne_status`, lifecycle hooks, reconciler, `provision_teams`, admin API, agent-picker filter, register chat as team | Yes — new columns nullable, legacy path still works |
|
||
| 5 | Daedalus frontend | Workspace settings attached-teams picker, agent picker namespacing | Yes — backwards-compatible once phase 4 ships |
|
||
| 6 | Agent team cutovers | Kottos / Mentor / Iolaus paste JWT, remove `forward_inbound_auth`, restart | Yes — one at a time |
|
||
| 7 | Documentation | Mnemosyne README, Pallas README + `docs/auth.md`, Daedalus operator docs, cross-references to this file | Yes |
|
||
|
||
---
|
||
|
||
## 16. Open items (v1)
|
||
|
||
None — all decisions are closed in this revision. Future revisions
|
||
that add scope (e.g., team-level library scoping finer than
|
||
workspace granularity; OAuth 2.1 for external MCP clients;
|
||
per-library audit logs) will ship as `_v2.md` alongside this file.
|
||
|
||
---
|
||
|
||
## 17. Cross-references
|
||
|
||
* Existing Mnemosyne per-turn JWT implementation:
|
||
`mnemosyne/mnemosyne/mcp_server/auth.py`,
|
||
`mnemosyne/mnemosyne/mcp_server/models.py` (`MCPToken`,
|
||
`MCPSigningKey`).
|
||
* Existing Daedalus Mnemosyne integration:
|
||
`daedalus/backend/daedalus/mnemosyne/` (`client.py`,
|
||
`tokens.py`, `lifecycle.py`, `reconciler.py`),
|
||
`daedalus/backend/daedalus/api/v1/pallas.py`.
|
||
* Existing Pallas auth-forwarding patch (to be removed):
|
||
`pallas/pallas/_fastagent_patch.py`,
|
||
`pallas/pallas/__init__.py`.
|