# 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`.
