# 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 `. --- ## 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:", // 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:" 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 `. 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 protected by the existing `daedalus-service` HTTP Basic account (same auth as `/library/api/workspaces/` and `/library/api/ingest/`). ### 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 as `daedalus-service`. ### 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`.