# OpenContracts Permission System - Complete Guide

> **🔴 CRITICAL CHANGE**: Annotations and Relationships no longer have individual permissions. Both inherit permissions from document + corpus. This eliminates N+1 queries and simplifies the security model.

> **🔴 CRITICAL SECURITY**: Structural annotations and relationships are ALWAYS read-only except for superusers. Even owners with full CRUD permissions cannot modify structural items. This is enforced in `AnnotationManager.user_can` and `RelationshipManager.user_can` (`opencontractserver/shared/Managers.py`) — the structural-write-deny runs before any other permission branch.

> **🟠 AUTHORIZATION API**: The canonical single-object authorization check is `Model.objects.user_can(user, obj, permission)` (manager surface) / `obj.user_can(user, permission)` (instance surface). It is the read/check counterpart of the `Model.objects.visible_to_user(user)` queryset filter — the two are pinned to agree by the invariant suite in `opencontractserver/tests/permissioning/test_authorization_invariants.py`. New code MUST call `user_can`.

> **🔵 NEW FEATURE**: Annotations can now be marked as "created by" an analysis or extract using `created_by_analysis` and `created_by_extract` fields. These annotations are private to the source object and only visible to users with permission to that analysis/extract.

> **🟢 NEW FEATURE**: COMMENT permission added with special "open commenting" mode. When `corpus.allow_comments = True`, any user who can READ an annotation can COMMENT on it. Enables community feedback without explicit permission grants.

> **⚠️ DEPRECATION WARNING**: The `resolve_oc_model_queryset` function in `opencontractserver.shared.resolvers` was DEPRECATED and replaced with `Model.objects.visible_to_user(user)` calls.

> **🟡 ANONYMOUS USER SUPPORT**: Anonymous users can access public resources with read-only permissions. Document AND corpus must both be `is_public=True` for access. Documents in public corpora **automatically inherit `is_public=True`** at creation time (see [Public Corpus Document Propagation](#public-corpus-document-propagation)). Applies to documents, corpuses, conversations, analyses (public only), and annotations.

> **🟣 USER PROFILE PRIVACY**: User profiles have privacy controls via `is_profile_public`. Private profiles are visible only to users who share corpus membership with > READ permission. See `UserQueryOptimizer` in `opencontractserver/users/query_optimizer.py`.

> **🟣 BADGE VISIBILITY**: Badge awards follow the recipient's profile privacy rules. Badges are visible if the recipient's profile is visible, or for corpus-specific badges, if the user has access to that corpus. See `BadgeQueryOptimizer` in `opencontractserver/badges/query_optimizer.py`.

## Key Changes in Current Implementation

| Component | Old Model | New Model | Impact |
|-----------|-----------|-----------|---------|
| **Annotation Permissions** | Individual per-annotation | Inherited from document+corpus | No N+1 queries |
| **Relationship Permissions** | Individual per-relationship | Inherited from document+corpus | Same as annotations |
| **Structural Items** | Could be modified by owners | **READ-ONLY except for superusers** | Critical security |
| **Permission Priority** | Corpus > Document | Document > Corpus (most restrictive) | Better security |
| **Database Queries** | 1 per annotation/relationship | 2 total (doc + corpus) | Massive performance gain |
| **Permission Storage** | `annotationuserobjectpermission` table | None - computed at runtime | Simpler database |
| **Permission Uniformity** | Each annotation/relationship different | All same in document | Predictable behavior |
| **Analysis Privacy** | All annotations visible with doc+corpus perms | Annotations created by analysis are private | Enhanced privacy control |
| **Extract Privacy** | All annotations visible with doc+corpus perms | Annotations created by extract are private | Enhanced privacy control |
| **Anonymous Access** | Not supported | Read-only access to public resources | Public corpus support |
| **User Profile Privacy** | All users visible | Privacy via `is_profile_public` + corpus membership | Profile privacy control |
| **Badge Visibility** | All badges visible | Follows recipient's profile privacy | Badge privacy control |
| **Document Actions** | Inline permission checks | `DocumentActionsQueryOptimizer` | Centralized least-privilege |
| **Single-object check** | inline permission helper | `Manager.user_can()` / `obj.user_can()` | Check surface paired with `visible_to_user()` filter |

## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Permission Types](#permission-types)
4. [COMMENT Permission System](#comment-permission-system)
5. [Backend Implementation](#backend-implementation)
6. [Frontend Implementation](#frontend-implementation)
7. [Annotation Permission Inheritance](#annotation-permission-inheritance)
8. [User Profile and Badge Visibility](#user-profile-and-badge-visibility)
9. [Document Actions Permissions](#document-actions-permissions)
10. [Performance Optimizations](#performance-optimizations)
11. [Component Integration](#component-integration)
12. [Testing](#testing)
13. [Troubleshooting](#troubleshooting)
14. [Agent/LLM Permission Model](#agentllm-permission-model)
15. [The user_can Authorization API](#the-user_can-authorization-api)
16. [resolve_oc_model_queryset Deprecation](#resolve_oc_model_queryset-deprecation)

## Overview

OpenContracts implements a sophisticated hierarchical permission system with different rules for different object types:

### Permission Models

1. **Standard Objects (Corpus, Document, etc.)**
   - Direct permission model - permissions are checked on the object itself
   - Corpus-level permissions can provide additional context when viewing documents
   - **Anonymous users**: Read-only access if `is_public=True`

2. **CorpusFolder - INHERITS CORPUS PERMISSIONS**
   - **NO individual permissions** - CorpusFolder objects do NOT have their own permission records
   - Inherits ALL permissions from parent Corpus
   - **Write operations** (create, update, move, delete folders) require:
     - User is Corpus creator, OR
     - User has `PermissionTypes.UPDATE` permission on parent Corpus (with `include_group_permissions=True`)
   - **CRITICAL SECURITY**: `corpus.is_public=True` grants READ-ONLY access, NOT write access
   - Never check `corpus.is_public` for write permission authorization
   - Implementation: `config/graphql/corpus_folder_mutations.py`

3. **Annotations and Relationships - NO INDIVIDUAL PERMISSIONS**
   - **IMPORTANT: Annotations and Relationships do NOT have individual permissions**
   - Both annotations and relationships inherit permissions from their parent document and corpus
   - **Document permissions are PRIMARY** (most restrictive)
   - **Corpus permissions are SECONDARY** (additional restrictions)
   - Formula: `Effective Permission = MIN(document_permission, corpus_permission)`
   - This ensures annotations/relationships are never more permissive than their parent document
   - **Performance benefit**: Eliminates N+1 permission queries
   - **CRITICAL**: Structural annotations and relationships are ALWAYS read-only except for superusers
   - Relationships use the same permission inheritance model as annotations (implemented in `RelationshipManager.user_can` / `RelationshipQuerySet.visible_to_user`, `opencontractserver/shared/Managers.py`)
   - **Privacy fields**: Both Annotation and Relationship models have `created_by_analysis`, `created_by_extract`, `structural`, and `is_public` fields (see `opencontractserver/annotations/models.py`)

4. **Analyses and Extracts - HYBRID MODEL**
   - Have their own individual permissions (can be shared independently)
   - **Visibility requires THREE conditions**:
     1. Permission on the analysis/extract object itself
     2. READ permission on the corpus containing the analysis/extract
     3. READ permission on relevant documents for seeing content
   - **Access Formula**:
     - `Can See Analysis/Extract = HAS_OBJECT_PERMISSION AND CAN_READ_CORPUS`
     - `Can See Annotations Within = CAN_SEE_ANALYSIS AND CAN_READ_DOCUMENT`
   - **Key behaviors**:
     - Users WITHOUT analysis/extract permission see nothing (even if they have corpus+doc access)
     - Users WITH analysis/extract permission but missing corpus permission see nothing
     - Users WITH analysis/extract+corpus permission see the analysis/extract
     - Annotations/datacells within are filtered to only show those on documents user can read
   - This allows controlled sharing of analyses while maintaining document security boundaries

5. **CorpusCategory - GLOBALLY VISIBLE, ADMIN-PROVISIONED**
   - **NO individual permissions** - Categories are visible to ALL users (including anonymous)
   - Categories are admin-provisioned structural data managed via Django Admin only
   - Users cannot create, modify, or delete categories - only superusers can
   - **GraphQL Type**: Does NOT use `AnnotatePermissionsForReadMixin` (categories have no permissions)
   - **corpusCount field**: Dynamically computed based on user's visible corpuses
     - Anonymous users see count of public corpuses in each category
     - Authenticated users see count of corpuses they have access to
   - Categories are seeded via migration with a `system` user (inactive, unusable password)
   - Implementation: `config/graphql/graphene_types.py:1589` (CorpusCategoryType)
   - Query resolver: `config/graphql/queries.py:resolve_corpus_categories`

### Key Principles

1. **Document Security First**: For annotations, document permissions are the primary security boundary
2. **Most Restrictive Wins**: When multiple permission sources exist, the most restrictive applies
3. **Progressive Enhancement**: Features are enabled based on available permissions
4. **Fail Secure**: Default to most restrictive permissions when uncertain
5. **Server-Side Enforcement**: Client-side checks are for UX only; all security is enforced server-side
6. **Performance Optimized**: Query optimizer eliminates N+1 permission queries

## Architecture

```
Standard Permission Flow:
Route → Slug Resolution → Permission Loading → Component Evaluation → UI Rendering

Annotation Permission Flow (Optimized):
Document Request → Query Optimizer → Permission Computation (Once) → Apply to All Annotations → UI Rendering

Analysis/Extract Permission Flow:
Request → Check Object Permission → Check Corpus Permission → Filter Document Content → UI Rendering

Permission Sources:
1. Document Permissions (myPermissions on Document type)
2. Corpus Permissions (myPermissions on Corpus type)
3. Analysis/Extract Permissions (individual object permissions)

Evaluation Priority for Annotations:
1. Document permissions (MUST have at least READ)
2. Corpus permissions (further restricts if present)
3. Structural annotation override (always READ-ONLY if doc is readable)
4. Analysis visibility filter (additional restriction)

Evaluation Priority for Analyses/Extracts:
1. Analysis/Extract object permission (MUST have at least READ)
2. Corpus permission (MUST have at least READ)
3. Document permissions (filters visible content within)
```

## Example Scenario: Multi-User Permission Hierarchy

### Setup:
- **Corpus X**: Contains Doc Alpha, Doc Beta
- **Corpus Y**: Contains Doc Beta
- **User A**: Permissions on Doc Alpha, Doc Beta, Corpus X
- **User B**: Permissions on Doc Beta, Corpus X, Corpus Y
- **User C**: Permissions on Doc Alpha, Corpus Y

### Results:

| User | Corpus View | Documents Visible | Analyses/Extracts |
|------|------------|-------------------|-------------------|
| **User A** | Sees Corpus X | Alpha & Beta in X | Sees analyses/extracts on X if given permission |
| **User B** | Sees X & Y | Beta in X, Beta in Y | Sees analyses/extracts on X or Y if given permission |
| **User C** | Sees Corpus Y | Empty (Alpha not in Y) | Cannot see any analyses in Y (no docs visible) |

### Analysis Permission Example:

If an Analysis is created on Corpus X analyzing both Alpha and Beta:
- **User A with analysis permission**: Sees analysis, sees annotations on both Alpha & Beta
- **User B with analysis permission**: Sees analysis, sees annotations on Beta only
- **User C with analysis permission**: Cannot see analysis (no corpus X permission)
- **User A WITHOUT analysis permission**: Cannot see analysis (even with corpus+doc permissions)

### Annotation Privacy Example (NEW):

If the Analysis creates annotations with `created_by_analysis` field set:
- **User A with doc+corpus but NO analysis permission**: Cannot see these private annotations
- **User A with analysis permission**: Sees all analysis-created annotations on Alpha & Beta
- **User B with analysis permission**: Sees analysis-created annotations on Beta only (no Alpha access)
- **Structural annotations**: Always visible regardless of `created_by_analysis` field

## Key Behaviors Summary

### GraphQL Query Modes (CRITICAL)
The `allAnnotations` field operates in **two distinct modes**:

1. **Manual/User Mode** (NO `analysis_id` provided):
   - Returns ONLY annotations where `analysis` field is NULL
   - Even if you have permission to analyses, their annotations are excluded
   - Extract-based annotations are included (if `analysis` field is NULL and user has extract permission)

2. **Analysis-Specific Mode** (`analysis_id` provided):
   - Returns ONLY annotations from the specified analysis
   - User must have READ permission on the analysis object
   - Filters by the `analysis` foreign key field

**Why**: Prevents mixing manual work with analysis-generated results. Users explicitly choose which "view" they want.

### Standard Annotations (no `created_by_*` fields)
1. Visibility determined by document + corpus permissions
2. All annotations in a document share the same permissions
3. Most restrictive permission wins (document vs corpus)
4. **Query mode matters**: Manual mode excludes analysis-linked annotations even with permission

### Private Annotations (`created_by_analysis` or `created_by_extract` set)
1. **Invisible by default**: Not shown even with document+corpus permissions
2. **Require source permission**: Must have permission to the analysis/extract that created them
3. **Still respect document boundaries**: Even with analysis permission, only see annotations on documents you can access
4. **Structural exception**: Structural annotations are ALWAYS visible if document is readable
5. **Independent from query mode**: Privacy filtering applies in BOTH manual and analysis-specific query modes

### Permission Hierarchy
```
Query Mode Filtering (FIRST STEP - happens BEFORE permission checks):
IF analysis_id is NOT provided:
    Filter to: analysis__isnull=True (manual annotations only)
ELSE:
    Filter to: analysis_id=<specified> (specific analysis only)

Then apply permission checks:

For Standard Annotations:
Document Permission (PRIMARY) ∩ Corpus Permission (SECONDARY) = Effective Permission

For Private Annotations (created_by_analysis or created_by_extract):
Source Permission (REQUIRED) ∩ Document Permission ∩ Corpus Permission = Effective Permission

For Structural Annotations:
Document READ Permission = Always Visible (READ-ONLY)
(Privacy filtering skipped for structural items)

For COMMENT Permission (Special Case):
IF corpus.allow_comments == True:
    can_comment = can_read  # Readable = Commentable
ELSE:
    can_comment = doc_comment AND corpus_comment  # Standard MIN logic
```

## Permission Types

### Backend Enum (opencontractserver/types/enums.py)

```python
class PermissionTypes(str, enum.Enum):
    CREATE = "CREATE"
    READ = "READ"
    EDIT = "EDIT"         # Alias for UPDATE
    UPDATE = "UPDATE"
    DELETE = "DELETE"
    COMMENT = "COMMENT"   # NEW: Comment on annotations/relationships
    PERMISSION = "PERMISSION"
    PUBLISH = "PUBLISH"
    CRUD = "CRUD"         # Shorthand for CREATE+READ+UPDATE+DELETE
    ALL = "ALL"           # All permissions including COMMENT+PUBLISH+PERMISSION
```

### Frontend Enum (frontend/src/components/types.ts)

```typescript
export enum PermissionTypes {
  CAN_PERMISSION = "CAN_PERMISSION",
  CAN_PUBLISH = "CAN_PUBLISH",
  CAN_COMMENT = "CAN_COMMENT",
  CAN_CREATE = "CAN_CREATE",
  CAN_READ = "CAN_READ",
  CAN_UPDATE = "CAN_UPDATE",
  CAN_REMOVE = "CAN_REMOVE",
}
```

### Permission Translation

The GraphQL layer translates between backend Django Guardian format and frontend enum format:

```python
# Backend Django Guardian format (what's stored in database):
["create_document", "read_document", "update_document", "remove_document"]

# GraphQL myPermissions field returns (backend format):
["create_annotation", "read_annotation", "update_annotation", "remove_annotation"]

# Frontend transforms to (for UI logic):
["CAN_CREATE", "CAN_READ", "CAN_UPDATE", "CAN_REMOVE"]
```

**Note**: The GraphQL `myPermissions` field returns backend format (e.g., `read_annotation`) not frontend format (`CAN_READ`). Frontend components handle the transformation.

### Permission Capabilities

| Permission | Corpus Context | Document Context | Capabilities |
|------------|----------------|------------------|--------------|
| **CAN_READ** | View corpus, documents | View document | Basic viewing access |
| **CAN_CREATE** | Add documents, annotations | Create annotations | Content creation |
| **CAN_UPDATE** | Edit corpus, annotations | Edit document/annotations | Content modification |
| **CAN_REMOVE** | Delete corpus content | Delete document | Content deletion |
| **CAN_PUBLISH** | Make corpus public | Make document public | Public visibility |
| **CAN_PERMISSION** | Manage corpus access | Manage document access | Permission management |
| **CAN_COMMENT** | Add comments | Add comments | Comment functionality |

### Voting Permissions

Voting on messages and conversations/threads uses a **visibility-based permission model**:

**Rule: If you can see it, you can vote on it.**

This simple convention means:
- Users can upvote/downvote any message or thread they have READ access to
- Users CANNOT vote on their own messages or threads (enforced server-side)
- No explicit "VOTE" permission type exists - voting is implicitly allowed with READ access
- Vote counts are denormalized on ChatMessage and Conversation models for performance

**Implementation Details:**
- `MessageVote` model: Tracks votes on ChatMessage objects
- `ConversationVote` model: Tracks votes on Conversation/Thread objects
- One vote per user per object (enforced via database constraint)
- Users can change their vote type (upvote ↔ downvote)
- Vote mutations check visibility via `Conversation.objects.visible_to_user(user)`

**Mutations:**
- `voteMessage(messageId, voteType)` - Vote on a message
- `removeVote(messageId)` - Remove vote from a message
- `voteConversation(conversationId, voteType)` - Vote on a thread
- `removeConversationVote(conversationId)` - Remove vote from a thread

**GraphQL Fields:**
- `MessageType.userVote` - Current user's vote ("UPVOTE", "DOWNVOTE", or null)
- `ConversationType.userVote` - Current user's vote on the thread
- `upvoteCount` / `downvoteCount` - Denormalized vote counts on both types

## Permission Model Summary by Object Type

This section provides a comprehensive reference for how permissions work across different object types in the system.

For every object type below, permissions are reachable through two paired surfaces, both defined per model in `opencontractserver/shared/Managers.py`:

- **`Model.objects.visible_to_user(user)`** — the queryset *filter*: which rows the user may READ.
- **`Model.objects.user_can(user, obj, permission)`** / **`obj.user_can(user, permission)`** — the single-object *check*: may the user perform `permission` on `obj`.

The two are pinned to agree for READ by the invariant suite (`test_authorization_invariants.py`). Use the filter for list queries and the check for mutation/field-resolver gating.

### Permission Model Reference Table

| Object Type | Permission Model | Primary Permission Source | Secondary Checks | Special Rules |
|-------------|------------------|---------------------------|------------------|---------------|
| **Corpus** | Direct | Object permissions | `is_public` flag | Creator has full access |
| **Document** | Direct | Object permissions | `is_public` flag | Creator has full access |
| **DocumentRelationship** | Inherited (Doc+Corpus) | Source + Target doc permissions | Corpus permissions | `Effective = MIN(source_doc, target_doc, corpus)` |
| **CorpusFolder** | Inherited (Corpus) | Parent corpus permissions | None | No individual permissions; write requires UPDATE on corpus |
| **Annotation** | Inherited (Doc+Corpus) | Document permissions | Corpus permissions | `Effective = MIN(doc, corpus)`; Structural always READ-ONLY |
| **Relationship** | Inherited (Doc+Corpus) | Document permissions | Corpus permissions | `Effective = MIN(doc, corpus)`; Structural always READ-ONLY |
| **Metadata (Datacell)** | Corpus-primary | Corpus permissions | Document READ required | Corpus UPDATE + Doc READ = can edit; corpus-level feature |
| **Analysis** | Hybrid | Object permissions | Corpus READ required | Content filtered by doc permissions |
| **Extract** | Hybrid | Object permissions | Corpus READ required | Content filtered by doc permissions |
| **Conversation (CHAT)** | Restrictive | Creator + explicit permissions + public | `is_public` flag | Personal agent chats; NO context inheritance |
| **Conversation (THREAD)** | Context-based | Base rules + context inheritance | `is_public` flag | Collaborative discussions; inherits from corpus/document |
| **ChatMessage** | Inherited (Conversation) + Moderator | Parent conversation visibility | Moderator access | See [ChatMessage Visibility](#chatmessage-visibility-moderator-access) |
| **UserBadge** | Privacy-filtered | Recipient's profile privacy | Corpus membership | Follows recipient's `is_profile_public` |
| **User** | Privacy-controlled | `is_profile_public` | Corpus membership | Private users visible via shared corpus with > READ |

### Detailed Permission Formulas

#### Standard Objects (Corpus, Document)
```
Can Access = is_superuser OR is_creator OR has_object_permission OR (is_public AND READ)
```

#### Public Corpus Document Propagation

Documents in public corpora automatically inherit `is_public=True`, ensuring the two-flag rule is naturally satisfied without queryset-level overrides.

**Propagation triggers:**
1. **`Corpus.add_document()` / `import_document()`**: New documents created in a public corpus get `is_public=True` at creation time.
2. **`Corpus.save()` (is_public change)**: When a corpus becomes public, all its documents are updated to `is_public=True`. When a corpus becomes private, documents are set to `is_public=False` **only if** they are not in any other public corpus.

**Invariant**: `document.is_public = True` if the document is in at least one public corpus (or was explicitly marked public).

**Implementation**:
- `Corpus._propagate_public_status_to_documents()` in `opencontractserver/corpuses/models.py`
- `import_document()` in `opencontractserver/documents/versioning.py` sets `is_public=corpus.is_public`
- `Corpus.add_document()` sets `is_public=self.is_public or source_doc.is_public`

#### DocumentRelationship (Inherited Permissions)

DocumentRelationship objects inherit permissions from their source_document, target_document, and corpus (same model as annotation Relationships). User must have permission on BOTH documents AND corpus (if set).

```
Permission Formula:
  Effective Permission = MIN(source_doc_permission, target_doc_permission, corpus_permission)

READ Check:
  can_read = is_superuser
             OR (can_read_source_document AND can_read_target_document
                 AND (no_corpus OR can_read_corpus))

CREATE Check:
  can_create = has_CREATE_permission_on_source_document
               AND has_CREATE_permission_on_target_document
               AND (no corpus OR has_CREATE_permission_on_corpus)

UPDATE Check:
  can_update = has_UPDATE_permission_on_source_document
               AND has_UPDATE_permission_on_target_document
               AND (no corpus OR has_UPDATE_permission_on_corpus)

DELETE Check:
  can_delete = has_DELETE_permission_on_source_document
               AND has_DELETE_permission_on_target_document
               AND (no corpus OR has_DELETE_permission_on_corpus)
```

**Key characteristics:**
- DocumentRelationship connects two Document objects (not annotations)
- NO individual guardian permissions - inherits from source_doc + target_doc + corpus
- Types: `RELATIONSHIP` (labeled semantic link) or `NOTES` (free-form notes between docs)
- Permission model matches annotation Relationship for consistency
- **Anonymous users**: Read-only access if source doc, target doc, AND corpus are all `is_public=True`
- **No `@login_required`**: Query resolvers do NOT require authentication; permission filtering via `visible_to_user()` handles anonymous access to public resources

**Query Optimizer**: Use `DocumentRelationshipQueryOptimizer` for:
- IDOR-safe fetches with `get_relationship_by_id(user, id)`
- Filtered queries with `get_visible_relationships(user, ...)`
- Document-specific queries with `get_relationships_for_document(user, doc_id, ...)`
- Permission checks with `user_has_permission(user, doc_relationship, permission_type)`

#### Annotations & Relationships (including DocumentRelationship)
```
Effective Permission = MIN(document_permission, corpus_permission)
Structural Override = IF structural THEN READ-ONLY (except superuser)
Privacy Filter = IF created_by_analysis/extract THEN require source permission
```

**Note**: The Relationship model (for annotation-to-annotation relationships) has the same privacy fields as Annotation: `created_by_analysis`, `created_by_extract`, `structural`, and `is_public`. See model definition at `opencontractserver/annotations/models.py:155-376`.

##### Annotation Images (`/api/annotations/<id>/images/`)

Annotation **thumbnails / cropped image data** (extracted from PAWLs image tokens or `image_content_file`) follow **the same visibility rule as the annotation itself** — *if you can read the annotation, you can read its images*. The REST view is `AnnotationImagesView` at `opencontractserver/annotations/views.py`; the permission gate in `get_annotation_images_with_permission` (`opencontractserver/llms/tools/image_tools.py`) delegates directly to `AnnotationQuerySet.visible_to_user` so the rules cannot drift.

The endpoint always returns `200 OK` with `{"images": [], "count": 0}` for missing/unauthorized requests (IDOR protection); the response shape is identical whether the annotation does not exist, the user lacks permission, or the annotation simply has no image content.

#### Analyses & Extracts (Hybrid Model)
```
Can See Object = has_object_permission AND can_read_corpus
Can See Content = can_see_object AND can_read_document
```

#### Metadata (Datacell) - Corpus-Primary Model

Metadata values (Datacells) follow a **corpus-primary** permission model, which differs from annotations:

```
READ Check:
  can_read = can_read_document AND can_read_corpus

UPDATE Check:
  can_update = can_read_document AND has_UPDATE_permission_on_corpus

DELETE Check:
  can_delete = can_read_document AND has_DELETE_permission_on_corpus
```

**Why this differs from annotations:**
- Metadata schemas (columns) are defined at the **corpus level**, not document level
- Corpus owners/editors should be able to fill in metadata for any document they can see
- Explicit document UPDATE permissions aren't always assigned for corpus-scoped documents (performance optimization)
- This aligns with CorpusFolder's permission model (inherits from corpus)

**Key characteristics:**
- Requires only Document READ (not UPDATE) - user just needs to see the document
- Corpus permission determines write access - UPDATE to edit, DELETE to remove
- Anonymous users: READ-only access if both document and corpus are public (documents in public corpora inherit `is_public=True` automatically)
- Superusers: Full access to all metadata

**Implementation**: `MetadataQueryOptimizer.check_metadata_mutation_permission()` in `opencontractserver/extracts/query_optimizer.py`

#### Conversations - Bifurcated Permission Model

Conversations use a **bifurcated permission model** based on `conversation_type`:

##### CHAT Type (Restrictive - Personal Agent Chats)
```
Visibility Check:
  can_see = is_superuser
            OR is_creator
            OR has_explicit_guardian_permission (read_conversation)
            OR is_public

Note: CHAT type does NOT inherit visibility from corpus/document context.
      Even if a user can read the corpus, they cannot see another user's CHAT.
```

##### THREAD Type (Context-Based - Collaborative Discussions)
```
Visibility Check:
  can_see = CHAT_rules (creator OR explicit_permission OR is_public)
            OR context_inheritance (see below)

Context Inheritance (AND logic when both set):
  IF only chat_with_corpus set:
    can_see = user can READ corpus
  ELIF only chat_with_document set:
    can_see = user can READ document
  ELIF both chat_with_corpus AND chat_with_document set:
    can_see = user can READ corpus AND user can READ document
```

##### Moderation (Applies to Both Types)
```
Moderation Check:
  can_moderate = is_superuser
                 OR is_conversation_creator
                 OR corpus.creator == user
                 OR document.creator == user
                 OR user is CorpusModerator with permissions
```

##### Key Differences Summary

| Aspect | CHAT | THREAD |
|--------|------|--------|
| Purpose | Personal agent conversations | Collaborative discussions |
| Context Inheritance | NO | YES |
| Context Fields | Only ONE (corpus OR document) | BOTH allowed (doc-in-corpus) |
| Corpus Reader Visibility | Cannot see others' CHATs | CAN see corpus THREADs |

**Implementation**: `ConversationQuerySet.visible_to_user()` in `opencontractserver/conversations/models.py`

#### ChatMessage Visibility (Moderator Access)

ChatMessages inherit visibility from their parent conversation via `Conversation.objects.visible_to_user()`. This means messages automatically inherit the bifurcated CHAT/THREAD permission logic. Additionally, moderator access is provided for corpus/document owners.

```
Visibility Check (ChatMessage.visible_to_user):
  can_see_message = is_superuser
                    OR message is in VISIBLE conversation (inherits bifurcated logic)
                    OR user created the message
                    OR user has explicit permission on the message
                    OR user can moderate the conversation

Moderator Conditions (for visibility):
  can_moderate = conversation.creator == user
                 OR user owns corpus (chat_with_corpus.creator == user)
                 OR user owns document (chat_with_document.creator == user)
```

**Key Implementation Details:**
- Located in `ChatMessageQuerySet.visible_to_user()` (`opencontractserver/conversations/models.py`)
- **Primary visibility check**: Uses `Conversation.objects.visible_to_user()` to inherit bifurcated permissions
- Moderators can see ALL messages in conversations they moderate, even without explicit message permissions
- Mutations like UpdateMessage and DeleteMessage use this visibility check and additionally verify the user has edit/delete permissions (or is a moderator)

**Example - Bifurcated Behavior:**
```python
# Setup: Alice owns corpus, Bob has corpus READ permission
corpus = Corpus.objects.create(title="Legal Docs", creator=alice)
assign_perm("read_corpus", bob, corpus)

# Alice creates a CHAT (agent conversation) on the corpus
chat = Conversation.objects.create(
    chat_with_corpus=corpus, creator=alice, conversation_type="chat"
)
chat_msg = ChatMessage.objects.create(conversation=chat, creator=alice)

# Alice creates a THREAD (discussion) on the corpus
thread = Conversation.objects.create(
    chat_with_corpus=corpus, creator=alice, conversation_type="thread"
)
thread_msg = ChatMessage.objects.create(conversation=thread, creator=alice)

# Bob (corpus reader) can see THREAD messages but NOT CHAT messages
visible_to_bob = ChatMessage.objects.visible_to_user(bob)
assert thread_msg in visible_to_bob  # THREAD inherits corpus visibility
assert chat_msg not in visible_to_bob  # CHAT is private to creator

# Alice (corpus owner) can see ALL messages as moderator
visible_to_alice = ChatMessage.objects.visible_to_user(alice)
assert chat_msg in visible_to_alice  # Moderator access
assert thread_msg in visible_to_alice  # Moderator access
```

### Anonymous User Access Summary

| Object Type | Can Read? | Conditions |
|-------------|-----------|------------|
| Corpus | ✅ | `is_public=True` |
| Document | ✅ | `is_public=True` |
| Annotation | ✅ | Document AND Corpus both public |
| Relationship | ✅ | Document AND Corpus both public |
| DocumentRelationship | ✅ | Source doc, target doc, AND corpus all public |
| Analysis | ✅ | Analysis public AND Corpus public |
| Extract | ❌ | Never (always filtered out) |
| Conversation | ✅ | `is_public=True` |
| User Profile | ✅ | `is_profile_public=True` |

### Structural Item Protection Summary

| Item Type | Non-Superuser | Superuser |
|-----------|---------------|-----------|
| Structural Annotation | READ-ONLY | Full CRUD |
| Structural Relationship | READ-ONLY | Full CRUD |
| Non-Structural Annotation | Per doc+corpus permissions | Full CRUD |
| Non-Structural Relationship | Per doc+corpus permissions | Full CRUD |

**Enforcement Locations:**
- Annotations: `permissioning.py:297-303`
- Relationships: `permissioning.py:388-394`

### Discussion Thread Permissions

Discussions follow a **visibility-based participation model**: if you can READ a resource, you can participate in discussions about it.

| Action | Permission Required | Code Location |
|--------|---------------------|---------------|
| Create thread on corpus | READ on corpus | `conversation_mutations.py:122` |
| Create thread on document | READ on document | `conversation_mutations.py:142` |
| Create thread on both | READ on corpus AND document | `conversation_mutations.py:122,142` |
| Post message in thread | READ on conversation | `conversation_mutations.py:232` |
| Reply to message | READ on conversation | `conversation_mutations.py:330` |
| Vote on message/thread | READ on conversation | Visibility-based |
| Edit own message | Creator OR moderator | `conversation_mutations.py:488` |
| Delete own message | Creator OR moderator | `conversation_mutations.py:676` |
| Moderate thread (lock/pin/delete) | See below | `moderation_mutations.py` |

**Moderator Access:**
A user can moderate a thread if any of the following are true:
- User is a superuser
- User is the thread creator
- User owns the corpus (`chat_with_corpus.creator == user`)
- User owns the document (`chat_with_document.creator == user`)
- User has EDIT permission on the corpus
- User has EDIT permission on the document

**Rationale**: Discussions are meant to be collaborative. Anyone who can view a resource should be able to ask questions and participate in conversations about it. This encourages engagement and knowledge sharing while still maintaining moderation controls for resource owners.

**Anonymous Users**: Can only view threads on public resources (`is_public=True`). Cannot create threads or post messages (requires authentication).

## COMMENT Permission System

### Overview

The COMMENT permission allows users to add comments/feedback on annotations and relationships. It follows the same inheritance model as other permissions (READ, CREATE, UPDATE, DELETE) but includes a special "open commenting" mode via the `corpus.allow_comments` field.

### Permission Models

**Standard Mode** (`corpus.allow_comments = False`):
```
can_comment = MIN(doc_comment, corpus_comment)
```
- Requires explicit COMMENT permission on both document AND corpus
- Most restrictive permission wins
- Same behavior as READ, CREATE, UPDATE, DELETE

**Open Commenting Mode** (`corpus.allow_comments = True`):
```
can_comment = can_read
```
- Any user who can READ an annotation can COMMENT on it
- Enables community feedback and collaboration without permission overhead
- Still respects all READ boundaries (document, corpus, privacy)

### Key Rules

1. **READ is Required**: Cannot comment on what you cannot see
   - No document READ = no comment
   - No corpus READ = no comment
   - Private annotation (analysis/extract) not accessible = no comment

2. **Corpus Override**: `corpus.allow_comments` only applies when corpus context exists
   - Document-only views use document COMMENT permission
   - No corpus = standard permission check on document

3. **Privacy Respected**: Open commenting mode still respects all visibility boundaries
   - `created_by_analysis` annotations: need analysis permission
   - `created_by_extract` annotations: need extract permission
   - Different users may see different subsets of annotations

### Implementation

**In `AnnotationQueryOptimizer._compute_effective_permissions()`:**

```python
# Compute final read permission
final_read = doc_read and corpus_read

# BACON MODE: If corpus allows comments, readable = commentable
if corpus.allow_comments:
    final_comment = final_read  # Can see it? Can comment on it.
else:
    # Standard restrictive model
    final_comment = doc_comment and corpus_comment

return (final_read, can_create, can_update, can_delete, final_comment)
```

### Model Permissions

COMMENT permission must be defined in model Meta for:
- `Document` - `comment_document`
- `Corpus` - `comment_corpus`
- `Annotation` - `comment_annotation`
- `Relationship` - `comment_relationship`

### Use Cases

**Open Commenting Mode (`allow_comments=True`):**
- Public annotation projects with community feedback
- Collaborative document review where everyone can comment
- Educational corpuses where students can discuss annotations
- Beta testing environments with open feedback

**Standard Mode (`allow_comments=False`):**
- Confidential/sensitive documents with controlled access
- Professional environments requiring explicit permission grants
- Multi-tier access where some users can only view

### Examples

**Example 1: Open Commenting**
```python
# Setup
corpus.allow_comments = True
set_permissions(user, document, [READ])  # No COMMENT
set_permissions(user, corpus, [READ])    # No COMMENT

# Result
can_comment = True  # READ granted = COMMENT granted
```

**Example 2: Controlled Commenting**
```python
# Setup
corpus.allow_comments = False
set_permissions(user, document, [READ])         # No COMMENT
set_permissions(user, corpus, [READ, COMMENT])  # Has COMMENT

# Result
can_comment = False  # Document lacks COMMENT (most restrictive wins)
```

**Example 3: Respecting Boundaries**
```python
# Setup
corpus.allow_comments = True
set_permissions(user, corpus, [READ])     # Has corpus access
# NO document permissions

# Result
can_comment = False  # Cannot read document = cannot comment
```

## Backend Implementation

### Core Utilities (opencontractserver/utils/permissioning.py)

```python
def set_permissions_for_obj_to_user(
    user_val: int | str | type[User],
    instance: type[django.db.models.Model],
    permissions: list[PermissionTypes],
) -> None:
    """
    REPLACE current permissions with specified permissions.

    IMPORTANT: This function now correctly removes ALL existing
    permissions before adding new ones (fixed in recent update).
    """
    # 1. Remove all existing permissions for the user on this object
    # 2. Add requested permissions
    # This ensures true permission replacement, not accumulation

def get_users_permissions_for_obj(
    user: type[User],
    instance: type[django.db.models.Model],
    include_group_permissions: bool = False,
) -> set[str]:
    """Get all permissions a user has for a specific object."""
```

### GraphQL Integration

#### Permission Annotation Mixin

```python
class AnnotatePermissionsForReadMixin:
    my_permissions = GenericScalar()

    def resolve_my_permissions(self, info) -> list[PermissionTypes]:
        # Check for pre-computed permissions (annotations/relationships only)
        model_name = self._meta.model_name
        if model_name in ['annotation', 'relationship'] and hasattr(self, '_can_read'):
            # Use optimized pre-computed permissions from AnnotationQueryOptimizer
            # These are annotated as _can_read, _can_create, _can_update, _can_delete
            permissions = set()
            if getattr(self, '_can_read', False):
                permissions.add(f"read_{model_name}")
            if getattr(self, '_can_update', False):
                permissions.add(f"update_{model_name}")
            # ... etc
            return list(permissions)

        # Standard permission resolution for other models
        # Uses cached permission metadata from middleware or direct DB query
```

#### Middleware

```python
class PermissionAnnotatingMiddleware:
    def resolve(self, next, root, info, **kwargs):
        # Detects Django model type from GraphQL resolver
        # Caches permission metadata in info.context.permission_annotations
        # Avoids repeated database queries for same model types
```

## Annotation Permission Inheritance

### Critical Change: No More Annotation-Level Permissions

**⚠️ ARCHITECTURAL CHANGE**: Individual annotation-level permissions have been completely eliminated. This means:

1. **No per-annotation permission storage** - Annotations don't have their own permission records in the database
2. **No per-annotation permission checks** - We never check permissions on individual annotation objects
3. **Uniform permissions for all annotations** - All annotations in a document have the same permissions
4. **Computed once, applied to all** - Permissions are computed at query time based on document+corpus

### Why This Change?

1. **Performance**: Eliminated N+1 query problem (checking permissions for each annotation)
2. **Security**: Simpler, more predictable permission model
3. **Consistency**: All annotations in a document have uniform access control
4. **Maintainability**: Less complex permission logic to maintain

### The New Model (Implemented)

Annotations and relationships use a special permission inheritance model that prioritizes document security:

```python
# From opencontractserver/annotations/query_optimizer.py

class AnnotationQueryOptimizer:
    @classmethod
    def _compute_effective_permissions(
        cls,
        user,
        document_id: int,
        corpus_id: Optional[int] = None
    ) -> tuple[bool, bool, bool, bool]:
        """
        Compute effective permissions based on document and corpus.
        Document permissions are PRIMARY (most restrictive).

        Returns: (can_read, can_create, can_update, can_delete)
        """
        # Superusers have all permissions
        if user.is_superuser:
            return True, True, True, True

        # Anonymous users only have read access to public documents/corpuses
        if user.is_anonymous:
            doc_read = document.is_public
            if not doc_read:
                return False, False, False, False
            if corpus_id:
                corpus = Corpus.objects.get(id=corpus_id)
                if not corpus.is_public:
                    return False, False, False, False
            return True, False, False, False  # Read-only

        # Check document permissions (PRIMARY - must have these)
        doc = Document.objects.get(id=document_id)
        doc_read = user_has_permission(user, doc, READ)
        doc_create = user_has_permission(user, doc, CREATE)
        doc_update = user_has_permission(user, doc, UPDATE)
        doc_delete = user_has_permission(user, doc, DELETE)

        # No document read permission = no access at all
        if not doc_read:
            return False, False, False, False

        # If no corpus, use document permissions only
        if not corpus_id:
            return doc_read, doc_create, doc_update, doc_delete

        # Check corpus permissions and apply most restrictive
        corpus = Corpus.objects.get(id=corpus_id)
        corpus_read = user_has_permission(user, corpus, READ)
        corpus_create = user_has_permission(user, corpus, CREATE)
        corpus_update = user_has_permission(user, corpus, UPDATE)
        corpus_delete = user_has_permission(user, corpus, DELETE)

        # Return minimum permissions (most restrictive)
        return (
            doc_read and corpus_read,
            doc_create and corpus_create,
            doc_update and corpus_update,
            doc_delete and corpus_delete
        )
```

### Special Cases

1. **Structural Annotations** ⚠️ **CRITICAL SECURITY RULE**
   - **ALWAYS READ-ONLY except for superusers**
   - **Cannot be edited, updated, or deleted by ANY user (including owners with full CRUD permissions)**
   - Only superusers can modify or delete structural annotations
   - This protection is enforced in `AnnotationManager.user_can` (`opencontractserver/shared/Managers.py`), before any other permission branch
   - Structural annotations are ALWAYS visible regardless of `created_by_*` fields
   - Filtered automatically when no corpus context

2. **Structural Relationships** ⚠️ **CRITICAL SECURITY RULE**
   - **ALWAYS READ-ONLY except for superusers**
   - **Cannot be edited, updated, or deleted by ANY user (including owners with full CRUD permissions)**
   - Only superusers can modify or delete structural relationships
   - This protection is enforced in `RelationshipManager.user_can` (`opencontractserver/shared/Managers.py`), before any other permission branch
   - Relationships inherit permissions from document+corpus (just like annotations)
   - Structural protection is checked BEFORE any other permission logic

3. **Analysis-Created Annotations** (NEW)
   - Annotations with `created_by_analysis` field set are private to that analysis
   - Only visible to users who have permission to the analysis object
   - Even if user has document+corpus permissions, they cannot see these annotations without analysis permission
   - Structural annotations are exempt from this privacy rule

4. **Extract-Created Annotations** (NEW)
   - Annotations with `created_by_extract` field set are private to that extract
   - Only visible to users who have permission to the extract object
   - Even if user has document+corpus permissions, they cannot see these annotations without extract permission
   - Structural annotations are exempt from this privacy rule

5. **Superuser Access**
   - Superusers bypass all permission checks
   - Get full permissions automatically
   - Can see all annotations including private analysis/extract annotations
   - **Only superusers can modify or delete structural annotations/relationships**

6. **Anonymous Users** (NEW)
   - Can access resources where `is_public=True`
   - Get READ-ONLY permissions (no CREATE, UPDATE, DELETE, COMMENT)
   - For annotations: BOTH document AND corpus must be public
   - For analyses: Only see public analyses in public corpuses
   - For extracts: No access (always filtered out)
   - For conversations: Only see `is_public=True` conversations

## Annotation Privacy Model (NEW)

### Overview

The annotation privacy model allows annotations to be marked as "created by" a specific analysis or extract, making them private to that source object. This provides fine-grained privacy control for programmatically generated annotations.

### Centralized Permission Checking - THE Single Source of Truth

**CRITICAL**: The canonical single-object authorization check is `Model.objects.user_can(user, obj, permission)` (manager surface) / `obj.user_can(user, permission)` (instance surface), defined per model in `opencontractserver/shared/Managers.py`. Never bypass this API or implement custom permission logic.

All permission checks for annotations and relationships go through the per-model `user_can` implementation, which automatically handles:

1. **Superuser bypass** - Superusers always have full permissions (including structural items)
2. **Structural protection** - Structural annotations/relationships are ALWAYS read-only for non-superusers
3. **Privacy enforcement** - Checks source object permissions for private annotations
4. **Permission inheritance** - Requires SAME permission level on source object as requested
5. **Document+corpus computation** - Uses AnnotationQueryOptimizer for final permissions

**Implementation Details:**
- Structural annotation protection: `AnnotationManager.user_can` (`opencontractserver/shared/Managers.py`)
- Structural relationship protection: `RelationshipManager.user_can` (`opencontractserver/shared/Managers.py`)
- Both checks happen BEFORE any other permission logic
- `user_can` is the read/check counterpart of the `Model.objects.visible_to_user(user)` queryset filter; the two are pinned to agree by `opencontractserver/tests/permissioning/test_authorization_invariants.py`
- All mutations automatically respect this (RemoveAnnotation, UpdateAnnotation, RemoveRelationship, UpdateRelationship, etc.)

This means mutations don't need to understand the privacy model - they just call `obj.user_can(...)` and it handles everything.

**Important for Private Annotations**: Operations like DELETE require the matching permission on BOTH:
- The analysis/extract that created the annotation (DELETE permission)
- The document AND corpus (DELETE permission on both)
All requirements must be met or the operation is denied.

### Database Schema

```python
class Annotation(BaseOCModel):
    # Standard fields...

    # Privacy fields (NEW)
    created_by_analysis = ForeignKey(
        'analyzer.Analysis',
        null=True, blank=True,
        on_delete=SET_NULL,
        related_name='created_annotations',
        help_text='If set, this annotation is private to the analysis that created it'
    )

    created_by_extract = ForeignKey(
        'extracts.Extract',
        null=True, blank=True,
        on_delete=SET_NULL,
        related_name='created_annotations',
        help_text='If set, this annotation is private to the extract that created it'
    )

    class Meta:
        constraints = [
            CheckConstraint(
                check=Q(created_by_analysis__isnull=True) | Q(created_by_extract__isnull=True),
                name='annotation_created_by_only_one_source',
                violation_error_message='An annotation cannot be created by both an analysis and an extract'
            )
        ]
```

### Privacy Filtering in Query Optimizer

```python
# In AnnotationQueryOptimizer.get_document_annotations()

# Get analyses/extracts user can access
visible_analyses = Analysis.objects.filter(
    Q(is_public=True) | Q(creator=user) |
    Q(id__in=AnalysisUserObjectPermission.objects.filter(user=user).values_list('content_object_id'))
)

visible_extracts = Extract.objects.filter(
    Q(creator=user) |
    Q(id__in=ExtractUserObjectPermission.objects.filter(user=user).values_list('content_object_id'))
)

# Filter annotations: exclude private ones unless user has access
# BUT always include structural annotations (they're always visible)
qs = qs.exclude(
    # Exclude non-structural analysis-created annotations user can't see
    Q(created_by_analysis__isnull=False) &
    Q(structural=False) &  # Only apply privacy to non-structural
    ~Q(created_by_analysis__in=visible_analyses)
).exclude(
    # Exclude non-structural extract-created annotations user can't see
    Q(created_by_extract__isnull=False) &
    Q(structural=False) &  # Only apply privacy to non-structural
    ~Q(created_by_extract__in=visible_extracts)
)
```

### Import Process Updates

When importing annotations from an analysis, the system now automatically sets the `created_by_analysis` field:

```python
# In import_annotations_from_analysis()
annotation = Annotation.objects.create(
    annotation_label_id=label_id,
    document_id=doc_id,
    analysis_id=analysis_id,
    created_by_analysis_id=analysis_id,  # Mark as created by this analysis
    creator_id=creator_id,
    corpus=analysis.analyzed_corpus
)
```

### Mutation Integration

All annotation mutations now properly respect the privacy model through the centralized permission system:

```python
# Example from RemoveAnnotation mutation
def mutate(root, info, annotation_id):
    annotation = Annotation.objects.get(id=annotation_id)

    # Single call handles all privacy logic
    if not annotation.user_can(info.context.user, PermissionTypes.DELETE):
        return RemoveAnnotation(ok=False, message="Permission denied")

    annotation.delete()
    return RemoveAnnotation(ok=True)
```

The mutations that have been updated to use this pattern include:
- **RemoveAnnotation** - Checks DELETE permission with privacy model
- **UpdateAnnotation** - Uses user_can_edit which internally calls user_can
- **RejectAnnotation** - Checks visibility before rejection
- **ApproveAnnotation** - Checks visibility before approval
- **AddRelationship** - Checks both annotations are visible
- **RemoveRelationship** - Checks DELETE permission on relationship

### Migration Strategy

For existing systems, a data migration is provided that:
1. Identifies existing annotations linked to analyses
2. Sets `created_by_analysis` for non-structural analysis annotations
3. Preserves backward compatibility with the `analysis` field

```python
def migrate_existing_analysis_annotations(apps, schema_editor):
    Annotation = apps.get_model('annotations', 'Annotation')

    # Update annotations that are linked to an analysis and are not structural
    updated = Annotation.objects.filter(
        analysis__isnull=False,
        structural=False
    ).update(
        created_by_analysis_id=models.F('analysis_id')
    )
```

## User Profile and Badge Visibility

### Overview

User profiles and badge awards have privacy controls that follow a consistent visibility model. This ensures that private user information is only visible to appropriate audiences.

### User Profile Privacy

User profiles have a `is_profile_public` boolean field that controls visibility:

**Visibility Rules:**
1. **Own Profile**: Always visible regardless of privacy setting
2. **Public Profiles** (`is_profile_public=True`): Visible to all authenticated users
3. **Private Profiles** (`is_profile_public=False`): Only visible via corpus membership with > READ permission
4. **Inactive Users** (`is_active=False`): Never visible (except to superusers)
5. **Anonymous Users**: Can only see public profiles
6. **Superusers**: See all profiles, including private ones — `UserProfileManager.visible_to_user` (`opencontractserver/users/models.py`) has a superuser bypass that mirrors `BaseVisibilityManager`. This is intentional: it lets admin / moderation surfaces (e.g. badge awarding) route recipient lookups through `get_for_user_or_none` and still reach private-profile users. Do not remove it.

**Corpus Membership Visibility:**
Private profiles become visible to users who share a corpus where the private user has more than READ permission (i.e., CREATE, UPDATE, or DELETE). This ensures collaborators who are actively contributing to a corpus can see each other.

### Implementation: UserQueryOptimizer

The `UserQueryOptimizer` class in `opencontractserver/users/query_optimizer.py` provides centralized user visibility logic:

```python
from opencontractserver.users.query_optimizer import UserQueryOptimizer

# Get all users visible to the requesting user
visible_users = UserQueryOptimizer.get_visible_users(requesting_user)

# Check if a specific user is visible
is_visible = UserQueryOptimizer.check_user_visibility(requesting_user, target_user_id)

# Search for users (for @mention autocomplete)
results = UserQueryOptimizer.get_users_for_mention(requesting_user, search_text="alice")
```

**Key Methods:**

| Method | Description | Returns |
|--------|-------------|---------|
| `get_visible_users(user)` | All users visible to the requesting user | QuerySet |
| `check_user_visibility(user, target_id)` | Check if specific user is visible | bool |
| `get_users_for_mention(user, search_text)` | Search users for @mention (authenticated only) | QuerySet |

### Badge Visibility

Badge awards (`UserBadge` model) follow the recipient's profile privacy rules:

**Visibility Rules:**
1. **Own Badges**: Always visible regardless of recipient's profile privacy
2. **Badges of Public Users**: Visible to all authenticated users
3. **Badges of Private Users**: Visible only if recipient's profile is visible (via corpus membership)
4. **Corpus-Specific Badges**: Visible only to users with access to that corpus
5. **Anonymous Users**: Can only see badges of public users

### Implementation: BadgeQueryOptimizer

The `BadgeQueryOptimizer` class in `opencontractserver/badges/query_optimizer.py` provides centralized badge visibility logic:

```python
from opencontractserver.badges.query_optimizer import BadgeQueryOptimizer

# Get all badge awards visible to the requesting user
visible_badges = BadgeQueryOptimizer.get_visible_user_badges(requesting_user)

# Check visibility of a specific badge award (IDOR-safe)
has_permission, badge_obj = BadgeQueryOptimizer.check_user_badge_visibility(
    requesting_user, user_badge_id
)

# Get badges for a specific user (respects privacy)
user_badges = BadgeQueryOptimizer.get_badges_for_user(requesting_user, target_user_id)
```

**Key Methods:**

| Method | Description | Returns |
|--------|-------------|---------|
| `get_visible_user_badges(user)` | All badge awards visible to user | QuerySet |
| `check_user_badge_visibility(user, badge_id)` | Check specific badge visibility (IDOR-safe) | tuple(bool, UserBadge or None) |
| `get_badges_for_user(user, target_user_id)` | Get visible badges for a specific user | QuerySet |

### IDOR Protection

Both optimizers implement IDOR protection by returning the same response whether an object doesn't exist or the user lacks permission:

```python
# IDOR-safe check - same response for non-existent or inaccessible
has_permission, badge = BadgeQueryOptimizer.check_user_badge_visibility(user, badge_id)
if not has_permission:
    return None  # Same response whether badge doesn't exist or user can't see it
```

### GraphQL Resolver Integration

The following GraphQL resolvers use these optimizers:

| Resolver | Optimizer | Description |
|----------|-----------|-------------|
| `resolve_user_by_slug` | `UserQueryOptimizer` | Get user by slug with privacy check |
| `resolve_search_users_for_mention` | `UserQueryOptimizer` | Search users for @mention |
| `resolve_user_badges` | `BadgeQueryOptimizer` | List visible badge awards |
| `resolve_user_badge` | `BadgeQueryOptimizer` | Get single badge by ID |

### Testing

Comprehensive tests are available in:
- `opencontractserver/tests/permissioning/test_user_visibility.py` - 16 tests for user visibility
- `opencontractserver/tests/permissioning/test_badge_visibility.py` - 13 tests for badge visibility

---

## Document Actions Permissions

### Overview

Document actions (corpus actions, extracts, and analysis rows) follow the least-privilege model where effective permissions are the minimum of document and corpus permissions.

### Permission Model

**Formula:** `Effective Permission = MIN(document_permission, corpus_permission)`

This ensures:
- Users cannot access document-related data beyond their document permissions
- Corpus permissions provide additional restrictions, not expansions
- Consistent permission behavior across all document-related objects

### Implementation: DocumentActionsQueryOptimizer

The `DocumentActionsQueryOptimizer` class in `opencontractserver/documents/query_optimizer.py` provides centralized permission logic for document-related queries:

```python
from opencontractserver.documents.query_optimizer import DocumentActionsQueryOptimizer

# Get all actions/extracts/analyses for a document
result = DocumentActionsQueryOptimizer.get_document_actions(
    user=requesting_user,
    document_id=document_id,
    corpus_id=corpus_id  # Optional
)
# Returns: {"corpus_actions": [...], "extracts": [...], "analysis_rows": [...]}

# Get corpus actions for a corpus
corpus_actions = DocumentActionsQueryOptimizer.get_corpus_actions_for_corpus(
    user=requesting_user,
    corpus_id=corpus_id
)

# Get extracts that include a document
extracts = DocumentActionsQueryOptimizer.get_extracts_for_document(
    user=requesting_user,
    document_id=document_id,
    corpus_id=corpus_id  # Optional
)

# Get analysis rows for a document
analysis_rows = DocumentActionsQueryOptimizer.get_analysis_rows_for_document(
    user=requesting_user,
    document_id=document_id,
    corpus_id=corpus_id  # Optional
)
```

**Key Methods:**

| Method | Description | Returns |
|--------|-------------|---------|
| `get_document_actions(user, doc_id, corpus_id)` | All actions/extracts/analyses for document | dict |
| `get_corpus_actions_for_corpus(user, corpus_id)` | Corpus actions for a corpus | QuerySet |
| `get_extracts_for_document(user, doc_id, corpus_id)` | Extracts including a document | QuerySet |
| `get_analysis_rows_for_document(user, doc_id, corpus_id)` | Analysis rows for a document | QuerySet |

### Permission Checking

The optimizer includes internal permission checking methods:

```python
# Internal methods (called automatically)
_check_document_permission(user, document) -> bool
_check_corpus_permission(user, corpus) -> bool
```

**Access is granted if any of:**
- User is superuser
- Object is public (`is_public=True`)
- User is the creator
- User has explicit READ permission (via django-guardian)

### Integration with Other Optimizers

The `DocumentActionsQueryOptimizer` leverages other query optimizers for consistent permission filtering:

- **ExtractQueryOptimizer**: Used for filtering visible extracts
- **AnalysisQueryOptimizer**: Used for filtering visible analyses

```python
# Example: How get_document_actions uses other optimizers
def get_document_actions(cls, user, document_id, corpus_id=None):
    # 1. Check document permission
    if not cls._check_document_permission(user, document):
        return empty_result

    # 2. Check corpus permission (if provided)
    if corpus_id and not cls._check_corpus_permission(user, corpus):
        return empty_result

    # 3. Use ExtractQueryOptimizer for extracts
    visible_extracts = ExtractQueryOptimizer.get_visible_extracts(user, corpus_id)
    result["extracts"] = visible_extracts.filter(documents=document)

    # 4. Use AnalysisQueryOptimizer for analysis rows
    visible_analyses = AnalysisQueryOptimizer.get_visible_analyses(user, corpus_id)
    result["analysis_rows"] = document.rows.filter(analysis__in=visible_analyses)

    return result
```

### GraphQL Resolver Integration

The `resolve_document_corpus_actions` resolver uses this optimizer:

```python
def resolve_document_corpus_actions(self, info, **kwargs):
    user = info.context.user
    result = DocumentActionsQueryOptimizer.get_document_actions(
        user=user,
        document_id=decode_id(self.id),
        corpus_id=decode_id(kwargs.get("corpus_id")) if kwargs.get("corpus_id") else None
    )
    return result
```

### Testing

Comprehensive tests are available in:
- `opencontractserver/tests/permissioning/test_document_actions_permissions.py` - 11 tests

Key test scenarios:
- Document owner can see their document's actions
- Users without document permission get empty results
- Corpus permission filtering works correctly
- Anonymous user handling
- Superuser access to all documents

---

## Performance Optimizations

### Query Optimizer

The system uses a query optimizer to eliminate N+1 permission queries that plagued the old individual annotation permission model:

```python
# OLD MODEL (ELIMINATED):
# Each annotation had its own permission records in the database
for annotation in annotations:
    # This would query annotationuserobjectpermission table for EACH annotation!
    check_permission(user, annotation)  # N database queries!

# NEW MODEL:
# No annotation permissions in database - compute from document+corpus
permissions = compute_permissions(user, document, corpus)  # Just 2 queries total
# Apply same permissions to ALL annotations
queryset.annotate(
    _can_read=Value(permissions.can_read),
    _can_update=Value(permissions.can_update),
    # ...
)
```

### Database Impact

The elimination of annotation-level permissions means:
- No `annotationuserobjectpermission` table queries
- No `annotationgroupobjectpermission` table queries
- Just 2 permission checks total (document + corpus) regardless of annotation count

### Benefits

1. **Eliminated N+1 Queries**: From O(n) to O(1) permission checks
2. **Reduced Database Load**: 2 permission queries total instead of 1 per annotation
3. **Consistent Performance**: Scales with any number of annotations
4. **Backwards Compatible**: GraphQL API unchanged

### Implementation Details

The optimization is transparent to the GraphQL layer:

```python
# In resolve_annotations (config/graphql/queries.py)
if document_id:
    # Use optimized path
    queryset = AnnotationQueryOptimizer.get_document_annotations(
        document_id=doc_id,
        user=info.context.user,
        corpus_id=corpus_id
    )
    # Queryset already has permissions annotated
```

## GraphQL Query Patterns

### Querying Annotations - CRITICAL Requirements

**IMPORTANT**: When querying annotations through GraphQL, you MUST:
1. Use the `allAnnotations` field (NOT `annotations`)
2. Include the `corpusId` parameter for proper permission filtering
3. Understand the `analysis_id` parameter behavior (optional but important)

#### The `analysis` Field vs `created_by_analysis` Field

There are TWO separate fields that control annotation visibility:

1. **`analysis` field** (ForeignKey): Links an annotation to an analysis for organizational purposes
2. **`created_by_analysis` field** (ForeignKey): Marks an annotation as PRIVATE to an analysis

These serve different purposes and are filtered differently!

#### Query Modes: Manual vs Analysis-Specific

The `allAnnotations` field has **two distinct query modes** based on the `analysis_id` parameter:

**Mode 1: Manual/User Annotations Only** (NO `analysis_id` provided):
```graphql
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID") {
            # Returns ONLY annotations where analysis field is NULL
            # Even if you have permission to see analysis-linked annotations,
            # they will be excluded unless you specify analysis_id
            id
            rawText
        }
    }
}
```

**Mode 2: Specific Analysis Annotations** (`analysis_id` provided):
```graphql
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID", analysisId: "AnalysisID") {
            # Returns ONLY annotations from this specific analysis
            # User must have permission to the analysis object
            id
            rawText
        }
    }
}
```

#### Why This Design?

This separation allows users to:
- View their "manual" work without mixing in analysis-generated annotations
- View specific analysis results by querying with that `analysis_id`
- Avoid confusion when multiple analyses create annotations on the same document

#### Privacy Filtering (Separate from Query Mode)

The `created_by_analysis` and `created_by_extract` fields add an ADDITIONAL privacy layer:
- Annotations marked as `created_by_analysis` are ONLY visible if you have permission to that analysis
- Annotations marked as `created_by_extract` are ONLY visible if you have permission to that extract
- This applies REGARDLESS of which query mode you're using

#### Complete Examples

**Example 1: User's Manual Annotations**
```graphql
# Query without analysis_id - sees manual annotations only
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID") {
            id
            rawText
            # Will NOT include analysis-linked annotations
            # even if you created them or have permission
        }
    }
}
```

**Example 2: Specific Analysis Results**
```graphql
# Query with analysis_id - sees that analysis's annotations
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID", analysisId: "AnalysisID123") {
            id
            rawText
            # Will ONLY include annotations from AnalysisID123
            # Requires READ permission on the analysis object
        }
    }
}
```

**Example 3: Extract-Based Annotations**
```graphql
# Extract annotations appear in manual mode if:
# 1. They have NO analysis field set
# 2. User has permission to the extract
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID") {
            id
            rawText
            # Includes extract annotations (if analysis field is null)
        }
    }
}
```

#### Common Query Mistakes

```graphql
# WRONG - Will return empty or incorrect results
query {
    document(id: "DocumentID") {
        annotations {  # Wrong field name!
            ...
        }
    }
}

# WRONG - Missing corpusId parameter
query {
    document(id: "DocumentID") {
        allAnnotations {  # Missing corpusId!
            ...
        }
    }
}

# POTENTIAL CONFUSION - This won't show analysis annotations
query {
    document(id: "DocumentID") {
        allAnnotations(corpusId: "CorpusID") {
            # Missing analysis_id means MANUAL ONLY
            # Analysis-linked annotations will be excluded
        }
    }
}
```

**Why corpusId is Required**: The permission system needs the corpus context to properly compute annotation visibility, including filtering private annotations based on analysis/extract permissions.

## Frontend Implementation

### State Management (Jotai Atoms)

```typescript
// Document permissions
const documentPermissionsAtom = atom<string[]>([]);

// Corpus state (includes permissions)
const corpusStateAtom = atom({
  canUpdateCorpus: false,
  myPermissions: []
});
```

### Permission Hooks

```typescript
// Document permissions
export const useDocumentPermissions = () => {
  const [permissions, setPermissions] = useAtom(documentPermissionsAtom);
  return { permissions, setPermissions };
};

// Corpus state
export const useCorpusState = () => {
  const corpusState = useAtomValue(corpusStateAtom);
  return {
    canUpdateCorpus: corpusState.canUpdateCorpus,
    myPermissions: corpusState.myPermissions
  };
};
```

### Permission Evaluation Logic

For standard document viewing (corpus context optional):
```typescript
// From DocumentKnowledgeBase.tsx
const canEdit = React.useMemo(() => {
  // Explicit readOnly prop overrides all
  if (readOnly) return false;

  // No corpus = limited editing capabilities
  if (!corpusId) return false;

  // Corpus permissions can enable editing
  if (canUpdateCorpus) return true;

  // Fallback to document permissions
  return permissions.includes(PermissionTypes.CAN_UPDATE);
}, [readOnly, corpusId, permissions, canUpdateCorpus]);
```

Note: For annotations specifically, the backend handles the document+corpus permission logic.

## Component Integration

### Core Components

#### DocumentKnowledgeBase
- Evaluates permissions from both document and corpus sources
- Passes `read_only` prop to child components
- Annotations receive permissions from backend query optimizer

#### PDF Component
```typescript
<PDF
  read_only={!canEdit}
  createAnnotationHandler={canEdit ? handleCreate : undefined}
/>
```

#### TxtAnnotator
```typescript
<TxtAnnotatorWrapper
  readOnly={!canEdit}
  allowInput={canEdit}
/>
```

### Component Patterns

#### Pattern 1: Conditional Rendering
```typescript
{canEdit && (
  <Button onClick={handleEdit}>Edit</Button>
)}
```

#### Pattern 2: Prop Passing
```typescript
<ChildComponent
  readOnly={!canEdit}
  onEdit={canEdit ? handleEdit : undefined}
/>
```

#### Pattern 3: Feature Gating
```typescript
const { isFeatureAvailable } = useFeatureAvailability(corpusId);

if (!isFeatureAvailable('ANNOTATIONS')) {
  return <EmptyState>Add to corpus to enable annotations</EmptyState>;
}
```

### Read-Only Mode Support

Components that properly support read-only mode:

- ✅ **PDF Component**: Prevents annotation creation
- ✅ **TxtAnnotatorWrapper**: Disables input
- ✅ **SelectionLayer**: Shows read-only messages
- ✅ **AnnotationMenu**: Shows only copy option
- ✅ **FloatingControls**: Hides edit actions
- ✅ **Content Feed**: Passes readOnly to children

## Testing

### Comprehensive Test Coverage

The permission system is thoroughly tested in:
- `opencontractserver/tests/permissioning/test_annotation_privacy_scoping.py` - Proves privacy scoping works
- `opencontractserver/tests/permissioning/test_annotation_permission_inheritance.py` - Validates inheritance model
- `opencontractserver/tests/permissioning/test_analysis_extract_hybrid_permissions.py` - Tests hybrid permission model
- `opencontractserver/tests/test_structural_protection.py` - **Tests structural annotation/relationship protection**
- `opencontractserver/tests/test_relationship_mutation_permissions.py` - Tests relationship permission inheritance

These tests definitively prove that:
1. Private annotations are properly scoped to analyses/extracts
2. Multiple teams can work on shared corpuses without seeing each other's private annotations
3. Permission changes take effect immediately
4. Mutations properly respect the privacy model
5. **Structural annotations/relationships CANNOT be modified by non-superusers (even owners with full CRUD)**
6. Relationships inherit permissions from document+corpus exactly like annotations

### Backend Tests

#### Permission Setting Tests
```python
def test_permission_replacement():
    # Give user all permissions
    set_permissions_for_obj_to_user(
        user_val=user,
        instance=document,
        permissions=[PermissionTypes.ALL]
    )

    # Replace with just READ
    set_permissions_for_obj_to_user(
        user_val=user,
        instance=document,
        permissions=[PermissionTypes.READ]
    )

    # Should ONLY have READ (not ALL permissions)
    perms = get_users_permissions_for_obj(user, document, include_group_permissions=True)
    assert perms == {'read_document'}
```

**Important Testing Note**: The test suite uses GraphQL clients with mock contexts to test permission inheritance through the full stack, ensuring that the optimization layer and GraphQL resolvers work correctly together.

#### Annotation Permission Inheritance Tests
```python
def test_document_primary_permissions():
    # Document: READ only
    # Corpus: UPDATE allowed
    # Result: Annotation should be READ-ONLY (most restrictive)

    set_permissions_for_obj_to_user(user, document, [PermissionTypes.READ])
    set_permissions_for_obj_to_user(user, corpus, [PermissionTypes.UPDATE])

    annotations = AnnotationQueryOptimizer.get_document_annotations(
        document_id=document.id,
        user=user,
        corpus_id=corpus.id
    )

    # Annotations should be read-only despite corpus having update
    for ann in annotations:
        assert ann._can_read == True
        assert ann._can_update == False  # Document restriction applies
```

#### Annotation Privacy Tests (NEW)
```python
def test_analysis_created_annotation_privacy():
    # Create annotation marked as created by analysis
    private_annotation = Annotation.objects.create(
        annotation_label=label,
        document=doc,
        corpus=corpus,
        analysis=analysis,
        created_by_analysis=analysis,  # Mark as private to analysis
        creator=owner
    )

    # User with doc+corpus but NO analysis permission
    set_permissions_for_obj_to_user(viewer, doc, [PermissionTypes.READ])
    set_permissions_for_obj_to_user(viewer, corpus, [PermissionTypes.READ])

    # Should NOT see the private annotation
    visible = AnnotationQueryOptimizer.get_document_annotations(
        document_id=doc.id,
        user=viewer,
        corpus_id=corpus.id
    )
    assert private_annotation not in visible

    # Grant analysis permission
    set_permissions_for_obj_to_user(viewer, analysis, [PermissionTypes.READ])

    # Now should see the annotation
    visible = AnnotationQueryOptimizer.get_document_annotations(
        document_id=doc.id,
        user=viewer,
        corpus_id=corpus.id
    )
    assert private_annotation in visible

def test_structural_annotations_always_visible():
    # Structural annotations bypass privacy rules
    structural = Annotation.objects.create(
        annotation_label=label,
        document=doc,
        corpus=corpus,
        analysis=analysis,
        created_by_analysis=analysis,  # Private to analysis
        structural=True,  # BUT structural overrides privacy
        creator=owner
    )

    # User WITHOUT analysis permission
    visible = AnnotationQueryOptimizer.get_document_annotations(
        document_id=doc.id,
        user=viewer,
        corpus_id=corpus.id,
        structural=True
    )
    assert structural in visible  # Still visible because structural
```

#### Structural Protection Tests (CRITICAL)
```python
def test_owner_cannot_update_structural_annotation():
    """Owner CANNOT UPDATE structural annotations even with full permissions."""
    # Create structural annotation
    structural_annotation = Annotation.objects.create(
        annotation_label=token_label,
        document=doc,
        corpus=corpus,
        creator=owner,
        structural=True,
    )

    # Grant owner FULL permissions on document and corpus
    set_permissions_for_obj_to_user(owner, doc, [PermissionTypes.CRUD])
    set_permissions_for_obj_to_user(owner, corpus, [PermissionTypes.CRUD])

    # Owner STILL cannot update structural annotation
    assert not structural_annotation.user_can(owner, PermissionTypes.UPDATE)

def test_superuser_can_update_structural_annotation():
    """Superuser CAN UPDATE structural annotations."""
    superuser = User.objects.create_superuser(username="super", password="test")

    # Superuser can modify structural items
    assert structural_annotation.user_can(superuser, PermissionTypes.UPDATE)

def test_owner_cannot_delete_structural_relationship():
    """Owner CANNOT DELETE structural relationships even with full permissions."""
    structural_rel = Relationship.objects.create(
        relationship_label=relationship_label,
        document=doc,
        corpus=corpus,
        creator=owner,
        structural=True,
    )

    # Owner has full permissions
    set_permissions_for_obj_to_user(owner, doc, [PermissionTypes.CRUD])
    set_permissions_for_obj_to_user(owner, corpus, [PermissionTypes.CRUD])

    # But STILL cannot delete structural relationship
    assert not structural_rel.user_can(owner, PermissionTypes.DELETE)
```

**Key Validation Points:**
- File: `opencontractserver/tests/test_structural_protection.py`
- 12 comprehensive tests covering annotations and relationships
- Tests verify non-superusers CANNOT modify structural items even with CRUD
- Tests verify superusers CAN modify structural items
- Tests verify non-structural items work normally

### Frontend Tests

```typescript
describe('Permission Flow', () => {
  it('should handle annotation permissions from backend', async () => {
    const mocks = [
      createAnnotationQueryMock({
        annotations: [{
          id: '1',
          myPermissions: ['read_annotation']  // Backend computed
        }]
      })
    ];

    render(
      <MockedProvider mocks={mocks}>
        <DocumentKnowledgeBase documentId="123" corpusId="456" />
      </MockedProvider>
    );

    // Annotations should be read-only as determined by backend
    await waitFor(() => {
      expect(screen.getByTestId('annotation-1')).toHaveAttribute('data-readonly', 'true');
    });
  });
});
```

## Troubleshooting

### Common Issues

#### Annotations appear editable when document is read-only
- **Check**: Backend query optimizer is being used for annotation queries
- **Check**: Document permissions are being checked first in `_compute_effective_permissions`
- **Check**: Frontend is respecting the `myPermissions` from annotations

#### Private annotations appearing when they shouldn't (NEW)
- **Check**: `created_by_analysis` or `created_by_extract` fields are properly set
- **Check**: User does NOT have permission to the analysis/extract object
- **Check**: Query optimizer is filtering based on visible_analyses/visible_extracts
- **Note**: Structural annotations bypass privacy and are always visible

#### Permission changes not taking effect
- **Issue**: Old permissions weren't being removed
- **Fix**: `set_permissions_for_obj_to_user` now removes all permissions before adding new ones
- **Verify**: Check database directly to ensure old permissions are removed

#### N+1 Query Performance Issues
- **Check**: Annotation queries use `AnnotationQueryOptimizer`
- **Check**: `_can_*` attributes are present on annotation querysets
- **Check**: `AnnotationType.get_queryset()` detects and preserves pre-computed permissions

#### Mutual exclusivity constraint violations (NEW)
- **Error**: "An annotation cannot be created by both an analysis and an extract"
- **Check**: Never set both `created_by_analysis` AND `created_by_extract`
- **Fix**: Choose one source of creation per annotation
- **Database**: Enforced by CheckConstraint at database level

### Debug Steps

1. **Check Query Optimizer**: Verify annotation queries go through optimizer
2. **Inspect Permissions**: Check `_can_*` attributes on annotation objects
3. **Review Database**: Directly query permission tables to verify state
4. **GraphQL Responses**: Check `myPermissions` in network tab
5. **Add Logging**: Use logger in `_compute_effective_permissions` for debugging

### Performance Monitoring

- **Query Count**: Monitor Django Debug Toolbar for permission query count
- **Optimizer Usage**: Log when query optimizer is used vs. fallback
- **Cache Hit Rate**: Track permission metadata cache effectiveness
- **Response Time**: Measure annotation query response times

## Security Considerations

1. **Document-First Security**: Annotations never exceed document permissions
2. **Server-Side Enforcement**: All mutations validate permissions on backend
3. **No Client Trust**: Frontend permissions are UX hints only
4. **Fail-Safe Defaults**: Default to most restrictive permissions on errors
5. **Audit Trail**: Permission changes are logged for security auditing

## Migration Guide

### For Existing Systems

#### From Individual Annotation Permissions

If migrating from a system with individual annotation permissions:

1. **Database Cleanup**: Remove any `annotationuserobjectpermission` and `annotationgroupobjectpermission` records
2. **Code Updates**: Remove any code that sets permissions on individual annotations
3. **Permission Strategy**: Ensure document and corpus permissions are properly set
4. **User Education**: Inform users that all annotations in a document now share the same permissions

#### From Corpus-Override Model

If migrating from the old permission model where corpus overrode documents:

1. **Review Permission Logic**: Document permissions are now primary for annotations
2. **Update Tests**: Tests assuming corpus override need updating
3. **User Communication**: Inform users that annotation permissions now follow document security
4. **Data Audit**: Review existing permission sets for consistency

### Breaking Changes

- ❌ **Cannot set permissions on individual annotations** - Use document/corpus permissions instead
- ❌ **Cannot have different permissions for different annotations in same document** - All share same permissions
- ❌ **Corpus permissions no longer override document permissions** - Most restrictive wins

## Implementation Notes for Analyses/Extracts

### Query Pattern for Analyses/Extracts

```python
def get_visible_analyses(user, corpus_id=None):
    """
    Get analyses visible to user based on:
    1. User has READ permission on analysis
    2. User has READ permission on corpus
    3. Filter annotations to only those on readable documents
    """
    # Step 1: Get analyses user has permission to read
    analyses = Analysis.objects.filter(
        # User has explicit permission OR analysis is public
        Q(analysisuserobjectpermission__user=user) | Q(is_public=True)
    )

    # Step 2: Filter by corpus permission
    if corpus_id:
        analyses = analyses.filter(
            analyzed_corpus_id=corpus_id,
            analyzed_corpus__in=Corpus.objects.visible_to_user(user)
        )

    # Step 3: When fetching annotations, filter by document permissions
    # This happens in the annotation resolver using existing optimizer

    return analyses
```

### GraphQL Resolver Pattern

```python
def resolve_analysis_annotations(analysis, info):
    """
    Resolve annotations within an analysis, filtered by document permissions.
    """
    # Use existing AnnotationQueryOptimizer
    user = info.context.user

    # Get all annotation IDs from this analysis
    annotation_ids = analysis.annotations.values_list('id', flat=True)

    # Filter to only those on documents user can read
    visible_annotations = []
    for doc_id in analysis.analyzed_documents.values_list('id', flat=True):
        if doc.user_can(user, PermissionTypes.READ):
            visible_annotations.extend(
                annotation_ids.filter(document_id=doc_id)
            )

    return Annotation.objects.filter(id__in=visible_annotations)
```

## Common Pitfalls and Solutions

### Pitfall 1: Forgetting corpusId in GraphQL queries
**Problem**: Querying `allAnnotations` without `corpusId` returns empty results
**Solution**: ALWAYS include `corpusId` parameter in annotation queries

### Pitfall 2: Not understanding analysis_id parameter behavior
**Problem**: Expecting to see all annotations (manual + analysis) when querying without `analysis_id`
**Reality**: Querying without `analysis_id` returns ONLY manual annotations (where `analysis` field is NULL)
**Solution**:
- Use NO `analysis_id` parameter to get manual/user annotations
- Use specific `analysis_id` to get annotations from that analysis only
- If you need to see annotations from multiple sources, make separate queries
**Example**:
```graphql
# This will NOT show analysis annotations even if you have permission
query {
    document(id: "Doc123") {
        allAnnotations(corpusId: "Corpus456") {
            id  # Only manual annotations
        }
    }
}

# To see analysis annotations, provide analysis_id
query {
    document(id: "Doc123") {
        allAnnotations(corpusId: "Corpus456", analysisId: "Analysis789") {
            id  # Only annotations from Analysis789
        }
    }
}
```

### Pitfall 3: Assuming analysis permission alone is enough for mutations
**Problem**: Trying to delete private annotations with only analysis permission fails
**Solution**: Ensure user has matching permission level on document AND corpus too

### Pitfall 4: Trying to modify structural annotations
**Problem**: Structural annotations are ALWAYS read-only, updates will fail
**Solution**: Check `annotation.structural` before attempting modifications

### Pitfall 5: Using wrong field names in GraphQL
**Problem**: Using `annotations` instead of `allAnnotations` in queries
**Solution**: Always use `allAnnotations` field name for querying document annotations

### Pitfall 6: Using a raw permission check for corpus-scoped visibility
**Problem**: A raw single-object check doesn't account for corpus context
**Solution**: For corpus-scoped objects (documents in corpus, metadata), use the `visible_to_user()` pattern:
```python
# WRONG - misses corpus context
has_read = document.user_can(user, PermissionTypes.READ)

# CORRECT - handles full visibility model
is_visible = Document.objects.visible_to_user(user).filter(id=doc_id).exists()
```

**When to use each:**
- `user_can`: single-object authorization checks (write permissions, top-level objects)
- `Model.objects.visible_to_user()`: READ/visibility checks for corpus-scoped objects

## @ Mention Permissions (NEW)

### Overview

The @ mention system allows users to reference corpuses and documents in discussions using patterns like `@corpus:slug`, `@document:slug`, or `@corpus:slug/document:slug`. Mention permissions follow a **write-permission-required** model to prevent information leakage and ensure users only mention resources in collaborative contexts.

**See detailed specification:** `docs/permissioning/mention_permissioning_spec.md`

### Core Principles

1. **Write Permission Required (Private Resources)**: Users must have CREATE, UPDATE, or DELETE permission to mention private corpuses/documents
2. **Read Permission Sufficient (Public Resources)**: Public resources can be mentioned by anyone with READ access
3. **IDOR Protection**: Autocomplete searches never reveal existence of inaccessible resources
4. **Viewer-Filtered Rendering**: Mention chips only render for viewers with appropriate permissions

### Permission Rules

#### Corpus Mentions (`@corpus:slug`)

Users can autocomplete/mention a corpus if they have **at least one of**:
- **Creator**: User created the corpus
- **Write Permission**: User has `create_corpus`, `update_corpus`, or `delete_corpus` permission
- **Public Corpus**: Corpus is marked `is_public=True`

**Rationale**: Mentioning implies collaborative context; read-only viewers shouldn't draw attention to resources they can't contribute to.

#### Document Mentions (`@document:slug` or `@corpus:slug/document:slug`)

Users can autocomplete/mention a document if they have **at least one of**:
- **Creator**: User created the document
- **Write Permission on Document**: User has `create_document`, `update_document`, or `delete_document` on document
- **Write Permission on Parent Corpus**: Document is in a corpus where user has write permission
- **Public Document in Accessible Context**: Document is `is_public=True` AND (no corpus OR public corpus OR user has READ access to corpus)

**Rationale**: Similar to corpuses, but public documents are included for open forum discussions.

### Backend Implementation

#### Autocomplete Filtering

```python
# In config/graphql/queries.py

def resolve_search_corpuses_for_mention(self, info, text_search=None, **kwargs):
    """Only returns corpuses where user can meaningfully contribute."""
    from guardian.shortcuts import get_objects_for_user

    user = info.context.user

    if user.is_anonymous:
        return Corpus.objects.none()

    if user.is_superuser:
        return Corpus.objects.all()

    # Get corpuses user has write permission to
    writable_corpuses = get_objects_for_user(
        user,
        ["corpuses.create_corpus", "corpuses.update_corpus", "corpuses.delete_corpus"],
        klass=Corpus,
        any_perm=True
    )

    # Combine: creator OR writable OR public
    qs = Corpus.objects.filter(
        Q(creator=user) | Q(id__in=writable_corpuses) | Q(is_public=True)
    ).distinct()

    return qs
```

#### Mention Rendering with Viewer Filtering

The `mentionedResources` field on `MessageType` resolves mentioned resources **per viewer**:

```python
def resolve_mentioned_resources(self, info):
    """
    Parse message content and resolve mentioned resources.
    SECURITY: Only returns resources visible to requesting user.
    """
    user = info.context.user
    content = self.content or ""

    # Parse mention patterns (regex)
    resources = []

    for corpus_slug in parse_corpus_mentions(content):
        try:
            corpus = Corpus.objects.get(slug=corpus_slug)
            if corpus.user_can(user, PermissionTypes.READ):
                resources.append({
                    'type': 'CORPUS',
                    'slug': corpus_slug,
                    'title': corpus.title,
                    # ...
                })
        except Corpus.DoesNotExist:
            # Resource doesn't exist or user can't see it - skip silently (IDOR protection)
            pass

    return resources
```

### Frontend Implementation

The frontend **trusts backend filtering** and has no client-side permission logic:

#### Autocomplete
```typescript
// useResourceMentionSearch hook
export function useResourceMentionSearch(query: string) {
  // Query backend with search text
  const [searchCorpuses] = useLazyQuery(SEARCH_CORPUSES_FOR_MENTION);
  const [searchDocuments] = useLazyQuery(SEARCH_DOCUMENTS_FOR_MENTION);

  // Backend has already filtered - frontend displays results as-is
  // No client-side permission checks
}
```

#### Rendering
```typescript
// parseMentionsInContent function
export function parseMentionsInContent(
  content: string,
  mentionedResources: MentionedResource[]  // Already viewer-filtered by backend
): React.ReactNode {
  const mentionMap = new Map();
  mentionedResources.forEach(resource => {
    mentionMap.set(getMentionPattern(resource), resource);
  });

  // Parse content for mention patterns
  // If resource in mentionMap: render chip
  // If not in mentionMap: render plain text (IDOR protection)
}
```

### IDOR Protection Strategy

1. **Autocomplete**: Only shows resources user has write permission to (or public resources)
2. **Mention Parsing**: Backend filters `mentionedResources` per viewer
3. **Chip Rendering**: Inaccessible mentions render as plain text, not chips
4. **Error Messages**: Same message whether resource doesn't exist or user lacks permission
5. **No Timing Attacks**: All resource lookups use same execution path

### Example Scenarios

#### Scenario 1: Private Corpus Mention
```python
# Setup
owner = create_user("owner")
viewer = create_user("viewer")

private_corpus = Corpus.objects.create(
    title="Private Legal Corpus",
    creator=owner,
    is_public=False
)

# Give viewer READ permission (not write)
set_permissions_for_obj_to_user(viewer, private_corpus, [PermissionTypes.READ])

# Autocomplete test
owner_results = search_corpuses_for_mention(owner, "Legal")
assert private_corpus in owner_results  # Owner can mention

viewer_results = search_corpuses_for_mention(viewer, "Legal")
assert private_corpus not in viewer_results  # Viewer cannot mention (read-only)
```

#### Scenario 2: Public Document Mention
```python
# Setup
public_doc = Document.objects.create(
    title="Public Contract Template",
    is_public=True,
    creator=owner
)

# Public documents are mentionable by anyone (even read-only users)
viewer_results = search_documents_for_mention(viewer, "Contract")
assert public_doc in viewer_results  # Viewer can mention public document
```

#### Scenario 3: Mention Rendering with Different Viewers
```python
# Message content
content = "Check @corpus:private-legal for details"

# Owner viewing message
owner_resources = message.mentioned_resources(owner)
assert len(owner_resources) == 1  # Owner sees mention

# Viewer viewing same message
viewer_resources = message.mentioned_resources(viewer)
assert len(viewer_resources) == 0  # Viewer doesn't see mention

# Frontend renders:
# - For owner: Clickable chip "Private Legal Corpus"
# - For viewer: Plain text "@corpus:private-legal" (no chip)
```

### Testing Requirements

**Backend Tests** (`opencontractserver/tests/test_mention_permissions.py`):
- [ ] Corpus autocomplete respects write permissions
- [ ] Document autocomplete respects write + corpus permissions
- [ ] Public resources are mentionable with read-only access
- [ ] Anonymous users cannot mention anything
- [ ] `mentionedResources` filters by viewer permissions
- [ ] IDOR protection: same error for non-existent vs. inaccessible

**Frontend Tests** (`frontend/tests/MentionPermissions.test.tsx`):
- [ ] Autocomplete displays backend-filtered results
- [ ] Inaccessible mentions render as plain text
- [ ] Accessible mentions render as clickable chips
- [ ] No client-side permission filtering

### Migration Notes

When deploying this feature:
1. **No data migration needed** - permission checks are query-time only
2. **Existing mentions gracefully degrade** - inaccessible mentions become plain text
3. **Permission changes take effect immediately** - no caching of mention visibility

### Security Audit Checklist

- [x] Autocomplete uses write permission filtering (not just read)
- [x] Backend filters `mentionedResources` per viewer
- [x] Frontend trusts backend, no client-side permission logic
- [x] Inaccessible mentions render as plain text (IDOR protection)
- [ ] Backend tests cover permission edge cases
- [ ] Frontend tests verify rendering behavior
- [ ] Anonymous user handling tested
- [ ] Public vs. private resource scenarios tested

---

## Agent/LLM Permission Model

### Overview

The Agent/LLM system implements a defense-in-depth permission model where agents inherit the calling user's permissions and can never escalate beyond them. This ensures that agents operate as proxies for users, not as privileged entities.

**Core Principle**: Agents execute with the **minimum** of the user's permissions, never exceeding what the user themselves could do directly.

### Architecture Layers

The permission system operates at three layers:

1. **WebSocket Consumer Layer** - Initial connection authorization
2. **Tool Filtering Layer** - Removes unauthorized tools before agent initialization
3. **Runtime Validation Layer** - Defense-in-depth checks before each tool execution

### 1. WebSocket Consumer Layer

**Location**: `config/websocket/consumers/unified_agent_conversation.py`

The `UnifiedAgentConsumer` validates user permissions **before** accepting the WebSocket connection:

```python
# Lines 131-187 in unified_agent_conversation.py

# For corpus context
if self.corpus_id:
    self.corpus = await Corpus.objects.aget(id=self.corpus_id)
    if is_authenticated:
        has_perm = await database_sync_to_async(
            self.corpus.user_can
        )(user, PermissionTypes.READ)
        if not has_perm:
            await self.close(code=4003)
            return
    elif not self.corpus.is_public:
        await self.close(code=4003)
        return

# For document context
if self.document_id:
    self.document = await Document.objects.aget(id=self.document_id)
    if is_authenticated:
        has_perm = await database_sync_to_async(
            self.document.user_can
        )(user, PermissionTypes.READ)
        if not has_perm:
            await self.close(code=4003)
            return
    elif not self.document.is_public:
        await self.close(code=4003)
        return
```

**Key Rules:**
- **Authenticated users**: Must have READ permission on corpus/document
- **Anonymous users**: Only allowed if resource is `is_public=True`
- **Connection rejection**: Closes with code 4003 (Forbidden) if unauthorized
- **Early validation**: Prevents agent initialization for unauthorized users

### 2. Tool Filtering Layer

**Location**: `opencontractserver/llms/agents/agent_factory.py`

Before agent initialization, tools are filtered based on user permissions. This happens in `UnifiedAgentFactory.create_document_agent()` and `UnifiedAgentFactory.create_corpus_agent()`.

#### Tool Filtering Logic

```python
# Lines 178-210 in agent_factory.py

# Check user's write permission on document
has_write_permission = await _user_has_write_permission(user_id, doc_obj)

filtered_tools = []
for t in tools:
    # Filter approval-required tools in public contexts
    if public_context and isinstance(t, CoreTool) and t.requires_approval:
        logger.warning("Skipping approval-required tool '%s' for public context", t.name)
        continue

    # Filter corpus-dependent tools when no corpus provided
    if corpus is None and isinstance(t, CoreTool) and t.requires_corpus:
        logger.info("Skipping corpus-required tool '%s' - no corpus provided", t.name)
        continue

    # Filter write tools if user lacks write permission
    if (
        not has_write_permission
        and isinstance(t, CoreTool)
        and t.requires_write_permission
    ):
        logger.info(
            "Skipping write tool '%s' - user %s lacks WRITE permission on document %s",
            t.name, user_id, doc_obj.id
        )
        continue

    filtered_tools.append(t)
```

#### Tool Flags

Each `CoreTool` has three permission-related flags:

| Flag | Purpose | Filter Condition |
|------|---------|------------------|
| `requires_approval` | Tool needs user confirmation | Filtered if `public_context=True` |
| `requires_corpus` | Tool needs corpus context | Filtered if `corpus=None` |
| `requires_write_permission` | Tool performs write operations | Filtered if user lacks CRUD permission |

**Examples:**
- **Create Annotation Tool**: `requires_write_permission=True` - Only available to users with CREATE/UPDATE/DELETE on document
- **Vector Search Tool**: `requires_write_permission=False` - Available to all users with READ access
- **Corpus Summary Tool**: `requires_corpus=True` - Filtered out for standalone document agents

### 3. Runtime Validation Layer

**Location**: `opencontractserver/llms/tools/pydantic_ai_tools.py`

The `_check_user_permissions()` function runs **before every tool execution** as a defense-in-depth measure:

```python
# Lines 20-127 in pydantic_ai_tools.py

async def _check_user_permissions(ctx: RunContext[PydanticAIDependencies]) -> None:
    """
    Validate that the user in context has permission to access the resources.

    This is a defense-in-depth check that runs BEFORE any tool execution to
    ensure an agent cannot escalate beyond the calling user's permissions.
    Even if the consumer layer has a bug, tools won't leak data.
    """
    deps = ctx.deps
    user_id = deps.user_id
    document_id = deps.document_id
    corpus_id = deps.corpus_id

    if user_id is None:
        # Anonymous user - only allow if resources are public
        if document_id:
            doc = await Document.objects.aget(pk=document_id)
            if not doc.is_public:
                raise PermissionError("Anonymous access denied to private document")
        if corpus_id:
            corpus = await Corpus.objects.aget(pk=corpus_id)
            if not corpus.is_public:
                raise PermissionError("Anonymous access denied to private corpus")
        return

    # Authenticated user - uses visible_to_user() which properly handles
    # creator access, public status, and guardian permissions
    user = await User.objects.aget(pk=user_id)

    if document_id:
        has_perm = await database_sync_to_async(
            lambda: Document.objects.visible_to_user(user)
            .filter(pk=document_id).exists()
        )()
        if not has_perm:
            raise PermissionError(f"User {user_id} lacks READ permission on document")

    if corpus_id:
        has_perm = await database_sync_to_async(
            lambda: Corpus.objects.visible_to_user(user)
            .filter(pk=corpus_id).exists()
        )()
        if not has_perm:
            raise PermissionError(f"User {user_id} lacks READ permission on corpus")
```

**Injection Point**: This check is automatically injected into every tool wrapper at lines 264 and 299 in `pydantic_ai_tools.py`:

```python
async def async_wrapper(ctx: RunContext[PydanticAIDependencies], *args, **kwargs):
    # Defense-in-depth: validate user permissions BEFORE any tool execution
    _check_user_permissions(ctx)

    # Then execute tool...
    return await original_func(*args, **kwargs)
```

#### Writing permission checks inside a tool

Tool authors who need a check *beyond* the wrapper's READ gate (e.g. a write tool confirming the caller may UPDATE a specific object) MUST use the `user_can` API:

```python
# Inside an agent tool — confirm the caller may edit this object.
if not obj.user_can(user, PermissionTypes.UPDATE):
    raise PermissionError("User lacks UPDATE permission")
```

Notes:
- `obj.user_can(user, perm)` honours creator status uniformly (Phase A): a creator who never received an explicit guardian grant still passes, matching the read-side `visible_to_user` filter. Migrated tool sites (`image_tools.py`, `extracts_and_analyzers.py`, `memory.py`, `caml_article.py`) rely on this — do not re-add bare `creator_id == user.pk` OR-branches.
- `user_can` defaults to `include_group_permissions=True`; users holding a permission via a group pass the gate.
- Agent tools run without a GraphQL request in scope, so omit the `request=` kwarg — they degrade gracefully to Tier-1 instance caching (see [the two-tier cache](#two-tier-permission-cache)).
- All production agent tools are async; call `database_sync_to_async(obj.user_can)(user, perm)` from async tool bodies.

### 4. Vector Search Permission Layer

**Locations**:
- GraphQL: `config/graphql/queries.py:resolve_semantic_search`
- Vector Store: `opencontractserver/llms/vector_stores/core_vector_stores.py`

The vector search system implements its own permission layer to ensure users can only search annotations they have access to.

#### GraphQL `semantic_search` Query

The `semantic_search` query validates document/corpus access before creating the vector store:

```python
# Defense-in-depth check at GraphQL layer
if document_pk:
    if not Document.objects.visible_to_user(user).filter(id=document_pk).exists():
        return []  # IDOR-safe: same response for not found vs. no permission

if corpus_pk:
    if not Corpus.objects.visible_to_user(user).filter(id=corpus_pk).exists():
        return []  # IDOR-safe: same response for not found vs. no permission
```

#### CoreAnnotationVectorStore Permission Check

The `CoreAnnotationVectorStore` class performs its own permission verification in `_build_base_queryset()`:

```python
# From core_vector_stores.py:_build_base_queryset()

# Verify user has access to document
if self.document_id is not None:
    has_access = Document.objects.visible_to_user(user).filter(id=self.document_id).exists()
    if not has_access:
        return Annotation.objects.none()  # IDOR-safe

# Verify user has access to corpus
if self.corpus_id is not None:
    has_access = Corpus.objects.visible_to_user(user).filter(id=self.corpus_id).exists()
    if not has_access:
        return Annotation.objects.none()  # IDOR-safe
```

#### Global Search Method

The `global_search` class method uses the canonical `visible_to_user()` pattern:

```python
# Get all documents visible to user
accessible_doc_ids = Document.objects.visible_to_user(user).filter(is_current=True).values_list("id", flat=True)

# Filter annotations to accessible documents
queryset = Annotation.objects.filter(
    Q(document_id__in=accessible_doc_ids) |
    Q(structural=True, structural_set__documents__in=accessible_doc_ids)
)
```

#### Key Security Properties

| Property | Implementation |
|----------|----------------|
| **IDOR Protection** | Returns empty results for both non-existent and inaccessible resources |
| **Defense-in-depth** | Checks at GraphQL layer AND vector store layer |
| **Visibility Pattern** | Uses `visible_to_user()` for all permission checks |
| **Anonymous Support** | Anonymous users can search public documents/corpuses only |
| **Structural Annotations** | Visible if user can access the document (always READ-only) |

### Permission Inheritance Model

Agents inherit permissions from the **calling user**, not from the agent creator or any other privileged entity.

**Formula**: `Effective Tool Permission = MIN(caller_permission, tool_requirement)`

#### Example Scenarios

**Scenario 1: Read-Only User with Document Agent**

```python
# Setup
user = create_user("viewer")
document = create_document("Contract.pdf", is_public=False)
set_permissions_for_obj_to_user(user, document, [PermissionTypes.READ])

# Agent initialization
agent = await create_document_agent(document=document, user_id=user.id)

# Available tools:
# - vector_search (read-only) ✅
# - extract_entities (read-only) ✅
# - create_annotation (write) ❌ FILTERED OUT

# If somehow a write tool wasn't filtered, runtime check would block:
# _check_user_permissions() → PermissionError
```

**Scenario 2: Anonymous User with Public Corpus**

```python
# Setup
corpus = create_corpus("Public Legal Docs", is_public=True)

# Agent initialization
agent = await create_corpus_agent(corpus=corpus, user_id=None)  # Anonymous

# Available tools:
# - vector_search ✅
# - document_search ✅
# - create_annotation ❌ FILTERED OUT (anonymous = no write)
# - export_data ❌ FILTERED OUT (anonymous = no write)

# Runtime validation:
# _check_user_permissions() checks corpus.is_public → allows read tools
```

**Scenario 3: Owner with Full Permissions**

```python
# Setup
owner = create_user("owner")
document = create_document("Contract.pdf", creator=owner)
corpus = create_corpus("Legal Docs", creator=owner)

# Agent initialization (owner has CRUD by default)
agent = await create_document_agent(
    document=document,
    corpus=corpus,
    user_id=owner.id
)

# Available tools:
# - vector_search ✅
# - create_annotation ✅
# - update_annotation ✅
# - delete_annotation ✅
# - All tools available (owner has full permissions)
```

### Legacy Consumers (Deprecated)

The following WebSocket consumers have been **removed/deprecated** in favor of `UnifiedAgentConsumer`:

- ❌ `DocumentQueryConsumer` - Use `UnifiedAgentConsumer` with `document_id` param
- ❌ `CorpusQueryConsumer` - Use `UnifiedAgentConsumer` with `corpus_id` param
- ❌ `StandaloneDocumentQueryConsumer` - Use `UnifiedAgentConsumer` with `document_id` only

**Migration Example:**

```javascript
// OLD (deprecated)
const ws = new WebSocket(`/ws/corpus_query/${corpusId}/`);

// NEW (unified)
const ws = new WebSocket(`/ws/agent/?corpus_id=${corpusId}`);
```

### Key Security Properties

1. **No Privilege Escalation**: Agents cannot access resources the user cannot access
2. **Defense in Depth**: Three layers of permission checks (consumer, factory, runtime)
3. **Fail-Safe**: Permission checks default to denial on errors
4. **Audit Trail**: All permission failures are logged with user/resource context
5. **Anonymous Safety**: Anonymous users limited to public resources, read-only tools

### Cross-Reference: Full LLM Architecture

For complete details on the LLM/Agent system architecture, see:
- **`docs/architecture/llms/README.md`** - Full LLM framework documentation
- **`docs/architecture/llms/agent_lifecycle.md`** - Agent lifecycle and conversation management
- **`docs/architecture/llms/tool_system.md`** - Tool registration and execution

### Testing

Comprehensive tests for the agent permission model are located in:
- `opencontractserver/tests/llms/test_agent_permissions.py` - Agent factory permission filtering
- `opencontractserver/tests/websocket/test_unified_agent_consumer_permissions.py` - WebSocket layer validation
- `opencontractserver/tests/llms/test_tool_permission_enforcement.py` - Runtime permission checks

Key test scenarios:
- Anonymous users can only access public resources
- Read-only users cannot execute write tools
- Write tools are filtered for users without CRUD permissions
- Runtime checks block tool execution even if filtering fails
- Permission changes take effect immediately (no caching)

---

## The `user_can` Authorization API

`Model.objects.user_can(user, obj, permission)` (manager surface) and `obj.user_can(user, permission)` (instance surface) are the canonical single-object authorization check. They are the read/check counterpart of the `Model.objects.visible_to_user(user)` queryset filter. Both live per model in `opencontractserver/shared/Managers.py`; the instance surface is provided by `InstanceUserCanMixin` (`opencontractserver/shared/user_can_mixin.py`), which delegates to `type(obj)._default_manager.user_can`.

**Filter/check parity is an invariant.** For READ, `user_can(u, obj, READ)` must equal `visible_to_user(u).filter(pk=obj.pk).exists()` for every user. This is pinned by the suite in `opencontractserver/tests/permissioning/test_authorization_invariants.py` — if a manager's `user_can` and its queryset's `visible_to_user` ever drift, that suite fails by design.

### Extending `user_can` when introducing a new visibility-managed model

When you add a new model whose rows must be permission-filtered, work through this checklist:

1. **Manager + queryset.** Give the model a manager inheriting `BaseVisibilityManager` (and a queryset inheriting `PermissionQuerySet`) so it gets `user_can` and `visible_to_user` for free with the default creator / `is_public` / guardian rules.
2. **Instance surface.** Make the model inherit `InstanceUserCanMixin` so callers can use `obj.user_can(...)`. The mixin raises a clear `TypeError` if the default manager has no `user_can` — so step 1 must be done first.
3. **Override in lockstep.** If the model has non-standard visibility (permission inheritance, privacy recursion, structural locking, type bifurcation), override **both** `Manager.user_can` and `QuerySet.visible_to_user`. Never change one without the other — the parity invariant will fail.
4. **Resolve the user uniformly.** Use `resolve_user_for_user_can(user)` at the top of a custom `user_can` so `int` / `str` ids, `None`, `AnonymousUser`, and invalid strings all resolve consistently (it returns `None` for anything unresolvable; deny on `None`).
5. **Honour creator status uniformly.** Per Phase A, a creator passes `user_can` without an explicit guardian grant, matching `visible_to_user`'s `Q(creator=user)` predicate. Do not gate creators behind guardian rows.
6. **Add an invariant test class.** In `test_authorization_invariants.py`, subclass `_UserCanInvariantsMixin` + `TransactionTestCase`, and in `setUp` populate `model_cls`, `_superuser`, `_matrix_users`, and `_matrix_instances`. The mixin's equivalence / surface-agreement / superuser tests then come for free.
7. **Cover the fixture matrix.** `_matrix_users` must include: superuser, creator, a non-creator with an explicit guardian grant, an unshared stranger, a public-only viewer (stranger × a public instance), and `AnonymousUser()`.
8. **Pin model-specific invariants.** Add a focused test for any special rule: structural locking, recursive privacy, `is_public` write asymmetry, or CHAT/THREAD-style bifurcation.

### Two-Tier Permission Cache

`user_can` permission resolution is memoized at two tiers (introduced in PR #1665). Both are transparent — they never change the answer, only avoid recomputation.

- **Tier 1 — per-instance memoization.** Always on. Results are cached on the in-memory model instance under `INSTANCE_PERMS_CACHE_ATTR`. The cache lives as long as the instance object does; two separate `Document` instances for the same row do not share it. This is what Celery tasks, agent tools, websocket consumers, and fixtures rely on.
- **Tier 2 — request-scoped optimizer.** Opt-in. Activated by threading a GraphQL request through the `request=` kwarg: `obj.user_can(user, perm, request=info.context)`. All `user_can` calls in that request then share one `PermissionQueryOptimizer` (`get_request_optimizer(request)`), so repeated checks across many rows / resolvers in a single GraphQL request collapse to far fewer queries.

**When to pass `request=`:**

- **GraphQL resolvers and mutations** — pass `request=info.context`. This is where Tier-2 pays off (list resolvers check permissions per row).
- **Celery tasks, agent tools, websocket consumers, import services, fixtures, tests** — omit `request=`. They have no GraphQL request in scope; they degrade gracefully to Tier-1 instance caching. Passing a non-request object is unsafe.

**Cache invalidation.** `set_permissions_for_obj_to_user(user, obj, perms, request=info.context)` invalidates both tiers for that `(user, obj)` pair after the grant lands, so later `user_can` checks in the same request see the new state. Group-permission changes (`user.groups.add(...)`, `assign_perm(perm, group, obj)`) do **not** flow through that helper — a caller mutating group membership mid-request must invalidate manually (`delattr(instance, INSTANCE_PERMS_CACHE_ATTR)` and/or `get_request_optimizer(request).invalidate(user_id=user.id)`).

### Service Layer: `CorpusObjsService` and `DocumentService`

Authorization checks answer "may this user do X to this object." *Fetching* the right objects for a user is the job of the service layer (PR #1685), and request-context code should go through it rather than composing `visible_to_user` filters by hand:

- **`DocumentService`** (`opencontractserver/documents/document_service.py`) — the single source of truth for document-level operations: creation, quota, lifecycle, document-level permissions, and standalone single-document lookup. Use it when the document is the noun and corpus context is incidental.
- **`CorpusObjsService`** (`opencontractserver/corpuses/corpus_objs_service.py`) — the single source of truth for corpus-scoped reads: "give me X inside corpus Y for user Z" (`get_corpus_document_by_slug`, `get_corpus_document_by_id`, `is_document_in_corpus`, folder lookups, CAML articles).

**IDOR safety:** never fuse `corpus.get_documents().values_list("id", flat=True)` with `Document.objects.visible_to_user(user)` by hand — that copy-paste pattern is IDOR-prone. `CorpusObjsService` is the canonical entry point and returns an empty queryset (not a leak, not an error) on permission denial. `corpus.get_documents()` itself now emits a `DeprecationWarning`; internal/task code without a user must call `corpus._get_active_documents()` explicitly.

---

## resolve_oc_model_queryset Deprecation

> The old `resolve_oc_model_queryset` function was duplicative and not uniformly implemented. Applicable logic was moved to custom base manager with a visible_to_user(user) function.
