# Sprint 18: Projects v1

> **Size**: L | **Owner**: Backend + Client | **Prerequisites**: Sprint 16
> **Phase**: 5 - Platform Evolution

---

## Open Issues & Prerequisites

> ✅ **Review Date**: 2025-12-31 — Implementation verified, blockers resolved.

### Blocking Issues (Resolved)

| ID | Issue | Status | Resolution |
|----|-------|--------|------------|
| **B1** | **WorkspaceSettings does not exist** | ✅ **Resolved** | **Option A selected**: Add `WorkspaceSettings` to this sprint (+M effort). See Domain Model section. |
| **B2** | **Active project tracking undefined** | ✅ **Resolved** | Added `SetActiveProject` and `GetActiveProject` RPCs (see proto section). |
| **B3** | **ProjectContext missing role** | ✅ **Resolved** | Updated `ProjectContext` dataclass with `role: ProjectRole` field (see Domain Model section). |

### Design Gaps to Address

| ID | Gap | Resolution |
|----|-----|------------|
| G1 | No system defaults layer — what if workspace AND project have no rules? | ✅ Implemented: `SYSTEM_DEFAULTS` in `src/noteflow/domain/entities/project.py` (see Rule Inheritance section). |
| G2 | `EffectiveRules` return type undefined | ✅ Implemented: `EffectiveRules` dataclass in `src/noteflow/domain/entities/project.py` (see Rule Inheritance section). |
| G3 | List clearing semantics incomplete (`[]` vs `None`) | ✅ Implemented: merge semantics in `ProjectService._merge_trigger_rules` with examples below. |
| G4 | Slug uniqueness constraint unspecified | ✅ Implemented: domain regex + DB constraint on `(workspace_id, slug)` in migration `o9p0q1r2s3t4_add_projects_schema.py`. |
| G5 | No `RestoreProject` RPC | ✅ Implemented: `RestoreProject` RPC + service handler in gRPC mixin. |
| G6 | Authorization enforcement location unspecified | ✅ Implemented: Authorization Model section + role resolution in `ProjectService.resolve_project_role`. |

### Prerequisite Verification

| Prerequisite | Status | Notes |
|--------------|--------|-------|
| Sprint 16 Identity Foundation | ✅ Complete | `WorkspaceRole`, `UserContext`, `WorkspaceContext` exist |
| `Workspace.settings` field | ✅ Implemented | `domain/identity/entities.py`, `identity_repo.py`, migration `o9p0q1r2s3t4_add_projects_schema.py` |
| `ExportFormat` enum | ✅ Implemented | Moved to `src/noteflow/domain/value_objects.py` |
| Active project storage | ✅ Implemented | `SetActiveProject`/`GetActiveProject`, stored in workspace metadata |

---

## Validation Status (2025-12-31)

### IMPLEMENTED

| Component | Status | Notes |
|-----------|--------|-------|
| ProjectModel | ✅ Implemented | `infrastructure/persistence/models/identity/identity.py` |
| Project domain entity | ✅ Implemented | `domain/entities/project.py` |
| ProjectRole | ✅ Implemented | `domain/identity/roles.py` |
| ProjectMembership | ✅ Implemented | `domain/identity/entities.py` |
| ProjectSettings | ✅ Implemented | Rule inheritance in `domain/entities/project.py` + `ProjectService` |
| Project RPCs | ✅ Implemented | `grpc/proto/noteflow.proto` + `grpc/_mixins/project.py` |
| Project UI | ✅ Implemented | `client/src/components/projects/` + `ProjectProvider` |

**Downstream impact**: Sprint 21 (MCP Config) and Sprint 22 (Rules) are now unblocked by project scoping.

---

## Objective

Introduce **Projects** as a first-class container above meetings/tasks, completing the **scope lattice** (workspace project resource). Projects enable:
- Grouping related meetings and artifacts
- Project-level configuration (export rules, trigger rules, templates)
- Fine-grained access control with ProjectRole
- Foundation for RAG Q&A scoped to project knowledge
- Primarily a grouping layer; workspace admin implies project admin

---

## Key Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Ownership** | Single Workspace | Projects scoped to one workspace; simpler ACL |
| **Roles** | Project-level + workspace admin implies project admin | Workspace OWNER/ADMIN always have ProjectRole.ADMIN |
| **Active Project** | Per-workspace active project | Tracks current activity; defaults to workspace default project |
| **Routing** | Default project when omitted | Create/list uses active project unless project_id specified |
| **Rules** | Merge/inherit with explicit override semantics | Presence-based overrides; list wrappers allow clear vs inherit |
| **Migration** | Default project | Each workspace gets 'General' project for unassigned meetings |

---

## What Already Exists

| Asset | Location | Implication |
|-------|----------|-------------|
| `workspace_id` on meetings | ORM models | Hierarchy partially modeled |
| `ProjectContext` dataclass | `domain/identity/context.py:36-45` | Placeholder exists, needs entity |
| `OperationContext.project_id` | `domain/identity/context.py` | Propagation ready |
| WorkspaceRole enum | `domain/identity/roles.py` | Pattern for ProjectRole |
| Meeting list/detail views | Client | UI patterns exist |

---

## Scope

| Task | Effort | Notes |
|------|--------|-------|
| **Domain Layer** | | |
| `ExtensibleSettings` base class | S | Typed core + extensions pattern |
| `WorkspaceSettings` value object | S | Option A — extends ExtensibleSettings |
| Update `Workspace` entity with settings | S | Option A — add settings field |
| `Project` entity with settings | M | Core entity with ProjectSettings |
| `ProjectRole` enum with permissions | S | viewer/editor/admin |
| `ProjectMembership` entity | S | user_id, project_id, role |
| `ExportRules`, `TriggerRules` value objects | S | Shared by Workspace + Project |
| `RuleTypeRegistry` + base classes | M | Plugin system for rule types |
| `RuleMode`, `ConditionalRule`, `RuleSet` | S | Simple/conditional rule models |
| Project role resolution | S | Workspace ADMIN/OWNER ⇒ Project ADMIN |
| **Infrastructure Layer** | | |
| Add `settings` JSONB to WorkspaceModel | S | Option A — migration for workspace settings |
| Update workspace ORM converter | S | Option A — handle WorkspaceSettings |
| `ProjectModel` with relationships | M | FK to workspace, cascade rules |
| `ProjectMembershipModel` | S | User-project mapping |
| `project_id` column on MeetingModel | S | Nullable for migration |
| `ProjectRepository` port + impl | M | CRUD + workspace queries |
| `ProjectMembershipRepository` | S | Member management |
| Alembic migrations (5 scripts) | M | Tables + backfill + workspace settings |
| ORM converters | S | ORM ↔ domain for Project, WorkspaceSettings |
| **Application Layer** | | |
| `ProjectService` | M | Lifecycle + rule merging |
| `ProjectMembershipService` | S | Access control |
| `get_effective_rules()` | S | Merge workspace + project rules |
| Active project resolution | S | Default project per workspace |
| **gRPC Layer** | | |
| Proto messages (12 new) | M | Project, Role, Settings, Membership, ActiveProject |
| RPCs (11 endpoints) | M | CRUD + membership + active project |
| `ProjectMixin` for servicer | M | Handler implementation |
| Proto converters | S | Domain proto |
| Meeting scoping updates | S | Add project_id to Meeting + requests |
| Settings patch semantics | S | FieldMask / explicit presence for nested rules |
| **Client Layer (Rust)** | | |
| Project commands (6) | M | create, get, list, update, archive, switch |
| gRPC client methods | S | Invoke RPCs |
| State management | S | Active project tracking |
| Active project persistence | S | Store per-workspace default |
| **Client Layer (React)** | | |
| `ProjectSidebar` component | M | List + create + active indicator |
| `ProjectSwitcher` dropdown | S | Quick switch + search |
| `ProjectSettingsPanel` | M | Name, rules, members |
| `useProject`, `useProjects` hooks | S | State management |
| `useProjectMembers` hook | S | Membership queries |
| `useActiveProject` hook | S | Active context |
| `ProjectContext` provider | S | React context |
| Active project persistence | S | Store per-workspace default |
| Update MeetingContext | S | Scope by project |

**Total Effort**: XL (3-4 weeks) — includes Option A WorkspaceSettings + flexible rules infrastructure

---

## Domain Model

### Project Entity

```python
# src/noteflow/domain/entities/project.py
import re
from typing import Any

from noteflow.domain.errors import ValidationError, CannotArchiveDefaultProject

SLUG_PATTERN = re.compile(r"^[a-z0-9-]+$")


@dataclass
class Project:
    """A project for organizing meetings and artifacts within a workspace."""

    id: UUID
    workspace_id: UUID
    name: str
    slug: str | None = None  # Unique per-workspace when set; regex: [a-z0-9-]+
    description: str | None = None
    is_default: bool = False  # One default per workspace
    settings: ProjectSettings = field(default_factory=ProjectSettings)
    created_at: datetime = field(default_factory=utc_now)
    updated_at: datetime = field(default_factory=utc_now)
    archived_at: datetime | None = None
    metadata: dict[str, Any] = field(default_factory=dict)  # Fixed: Any instead of object

    def __post_init__(self) -> None:
        """Validate slug format if provided."""
        if self.slug is not None and not SLUG_PATTERN.match(self.slug):
            raise ValidationError(
                f"Slug must match pattern [a-z0-9-]+, got: {self.slug!r}"
            )

    def archive(self) -> None:
        """Mark project as archived. Default projects cannot be archived."""
        if self.is_default:
            raise CannotArchiveDefaultProject(self.id)
        self.archived_at = utc_now()
        self.updated_at = utc_now()

    def restore(self) -> None:
        """Restore an archived project."""
        self.archived_at = None
        self.updated_at = utc_now()
```

**Constraints** (resolves G4):

- `slug` is unique per-workspace when set (enforced via database UNIQUE constraint)
- `slug` must match regex `^[a-z0-9-]+$`
- Auto-generated from `name` if not provided: `slugify(name)`
- `is_default=True` projects cannot be archived

### ProjectRole Enum

```python
# src/noteflow/domain/identity/roles.py (extend)

class ProjectRole(Enum):
    """Role within a project."""

    VIEWER = "viewer"    # Read meetings, artifacts, run Q&A
    EDITOR = "editor"    # + Create/edit meetings, upload artifacts
    ADMIN = "admin"      # + Manage members, settings, rules

    def can_read(self) -> bool:
        return True  # All roles can read

    def can_write(self) -> bool:
        return self in (ProjectRole.EDITOR, ProjectRole.ADMIN)

    def can_admin(self) -> bool:
        return self == ProjectRole.ADMIN
```

### ProjectMembership Entity

```python
# src/noteflow/domain/identity/entities.py (extend)

@dataclass
class ProjectMembership:
    """A user's membership in a project."""

    project_id: UUID
    user_id: UUID
    role: ProjectRole
    joined_at: datetime = field(default_factory=utc_now)
```

### Updated ProjectContext (resolves B3)

```python
# src/noteflow/domain/identity/context.py (update existing)

@dataclass(frozen=True)
class ProjectContext:
    """Project context for an operation with role-based access."""

    project_id: UUID
    project_name: str
    role: ProjectRole  # <-- Added field


@dataclass(frozen=True)
class OperationContext:
    """Full context for an operation."""

    user: UserContext
    workspace: WorkspaceContext
    project: ProjectContext | None = None
    request_id: str | None = None

    # ... existing properties ...

    def can_write_project(self) -> bool:
        """Check if user can write in current project."""
        if self.workspace.role.can_admin():
            return True  # Workspace admin can write to any project
        return self.project.role.can_write() if self.project else False

    def can_admin_project(self) -> bool:
        """Check if user can admin current project."""
        if self.workspace.role.can_admin():
            return True  # Workspace admin is project admin
        return self.project.role.can_admin() if self.project else False
```

### Authorization Model

**Enforcement Location**: Service layer via `ProjectService.resolve_project_role()`.

```python
# src/noteflow/application/services/project_service.py

def resolve_project_role(
    workspace_role: WorkspaceRole,
    membership: ProjectMembership | None,
) -> ProjectRole:
    """Resolve effective project role for a user.

    Workspace OWNER/ADMIN always have ProjectRole.ADMIN.
    Non-admin users default to VIEWER when no explicit membership is present.
    """
    if workspace_role.can_admin():
        return ProjectRole.ADMIN
    return membership.role if membership is not None else ProjectRole.VIEWER
```

**Authorization Notes**:

- Workspace OWNER/ADMIN implies ProjectRole.ADMIN for all projects in that workspace.
- Non-admin workspace members default to VIEWER when no ProjectMembership exists (tighten when identity enforcement is wired).
- Backfill migration adds all workspace members to default project.
- Default project cannot be archived (validation in domain entity).

### Flexible Settings Architecture

> **Design Goal**: Support an evolving roadmap without schema migrations for every new feature.
> Pattern: **Typed core + extensible metadata** with **plugin-based rule types**.

#### Core Principles

| Principle | Implementation |
|-----------|----------------|
| **Type safety for known fields** | Dataclass fields with explicit types for stable features |
| **Flexibility for experimental** | `extensions: dict[str, Any]` for incubating features |
| **Schema versioning** | `schema_version` field enables migration logic |
| **Plugin extensibility** | `RuleTypeRegistry` allows new rule types without schema changes |
| **Simple → Custom progression** | Static values for MVP, expressions for power users |

#### Settings Base Pattern

```python
# src/noteflow/domain/settings/base.py

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, TypeVar, Generic

T = TypeVar("T")


@dataclass
class ExtensibleSettings(ABC):
    """Base for all settings with typed core + extensible metadata."""

    # Experimental/future fields stored here until promoted to typed fields
    extensions: dict[str, Any] = field(default_factory=dict)

    # Schema version for forward-compatible migrations
    schema_version: int = 1

    def get_extension(self, key: str, default: T = None) -> T:
        """Get an experimental setting with type hint."""
        return self.extensions.get(key, default)

    def set_extension(self, key: str, value: Any) -> None:
        """Set an experimental setting."""
        self.extensions[key] = value
```

#### WorkspaceSettings (Option A — added to Sprint 18)

```python
# src/noteflow/domain/identity/entities.py

@dataclass
class WorkspaceSettings(ExtensibleSettings):
    """Workspace-level configuration defaults. Projects inherit from these.

    Typed fields are stable features. Use `extensions` for experimental features
    that may be promoted to typed fields in future sprints.
    """

    # === Typed Core (stable features) ===
    export_rules: ExportRules | None = None
    trigger_rules: TriggerRules | None = None
    rag_enabled: bool | None = None
    default_summarization_template: str | None = None

    # === Inherited from ExtensibleSettings ===
    # extensions: dict[str, Any]  - for experimental features
    # schema_version: int = 1     - for migrations

    # === Extension Examples (future sprints) ===
    # self.get_extension("retention_days", default=90)
    # self.get_extension("auto_summary_enabled", default=False)
    # self.get_extension("custom_rules", default=[])


@dataclass
class Workspace:
    """A workspace for organizing meetings and resources."""

    id: UUID
    name: str
    slug: str | None = None
    is_default: bool = False
    settings: WorkspaceSettings = field(default_factory=WorkspaceSettings)
    created_at: datetime = field(default_factory=utc_now)
    updated_at: datetime = field(default_factory=utc_now)
    metadata: dict[str, Any] = field(default_factory=dict)
```

#### Feature Lifecycle: Extension → Typed Field

```
Sprint N:   extensions["retention_days"] = 90        # Experimental
Sprint N+1: retention_days: int | None = None       # Promoted to typed
            (migration moves data from extensions → typed field)
```

**Infrastructure**: Store as JSONB column. The `extensions` dict and typed fields serialize together.

---

### Rule Type Plugin System

```python
# src/noteflow/domain/rules/registry.py

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, ClassVar, Protocol


class RuleEvaluator(Protocol):
    """Protocol for rule evaluation."""

    def evaluate(self, context: "RuleContext") -> "RuleResult": ...


@dataclass
class RuleContext:
    """Context available during rule evaluation."""

    meeting: "Meeting | None" = None
    calendar_event: "CalendarEvent | None" = None
    workspace: "Workspace | None" = None
    project: "Project | None" = None
    user: "User | None" = None
    # Extensible context
    extra: dict[str, Any] = field(default_factory=dict)


@dataclass
class RuleResult:
    """Result of rule evaluation."""

    matched: bool
    actions: list["RuleAction"] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)


class RuleType(ABC):
    """Base class for pluggable rule types."""

    name: ClassVar[str]
    version: ClassVar[int] = 1

    @abstractmethod
    def evaluate(self, rule_config: dict[str, Any], context: RuleContext) -> RuleResult:
        """Evaluate this rule type against the given context."""
        ...

    @abstractmethod
    def validate_config(self, config: dict[str, Any]) -> list[str]:
        """Validate rule configuration. Returns list of errors."""
        ...


class RuleTypeRegistry:
    """Registry for pluggable rule types. Enables new rules without schema changes."""

    _rule_types: ClassVar[dict[str, type[RuleType]]] = {}

    @classmethod
    def register(cls, rule_type: type[RuleType]) -> type[RuleType]:
        """Register a rule type. Can be used as decorator."""
        cls._rule_types[rule_type.name] = rule_type
        return rule_type

    @classmethod
    def get(cls, name: str) -> type[RuleType] | None:
        """Get a registered rule type by name."""
        return cls._rule_types.get(name)

    @classmethod
    def all(cls) -> dict[str, type[RuleType]]:
        """Get all registered rule types."""
        return cls._rule_types.copy()


# === Built-in Rule Types ===

@RuleTypeRegistry.register
class ExportRuleType(RuleType):
    """Rule type for export configuration."""

    name = "export"
    version = 1

    def evaluate(self, rule_config: dict[str, Any], context: RuleContext) -> RuleResult:
        # Simple mode: just return the static config
        return RuleResult(matched=True, actions=[], metadata=rule_config)

    def validate_config(self, config: dict[str, Any]) -> list[str]:
        errors = []
        if "default_format" in config:
            valid_formats = {"markdown", "html", "pdf"}
            if config["default_format"] not in valid_formats:
                errors.append(f"Invalid format: {config['default_format']}")
        return errors


@RuleTypeRegistry.register
class TriggerRuleType(RuleType):
    """Rule type for auto-start triggers."""

    name = "trigger"
    version = 1

    def evaluate(self, rule_config: dict[str, Any], context: RuleContext) -> RuleResult:
        # Will support conditional evaluation in future
        return RuleResult(matched=True, actions=[], metadata=rule_config)

    def validate_config(self, config: dict[str, Any]) -> list[str]:
        return []  # TODO: Add validation
```

---

### Simple vs Custom Rule Interface

Support both simple static values AND conditional expressions, with a path to natural language:

```python
# src/noteflow/domain/rules/models.py

from dataclasses import dataclass, field
from enum import Enum
from typing import Any


class RuleMode(Enum):
    """How a rule is specified."""

    SIMPLE = "simple"      # Static key-value (MVP)
    CONDITIONAL = "conditional"  # If-then conditions
    EXPRESSION = "expression"    # Full expression language (future)
    NATURAL = "natural"    # Natural language (future, transforms to expression)


@dataclass
class RuleAction:
    """Action to take when a rule matches."""

    action_type: str  # e.g., "set_value", "notify", "auto_start"
    params: dict[str, Any] = field(default_factory=dict)


@dataclass
class ConditionalRule:
    """A rule with a condition and actions."""

    # Human-readable name
    name: str

    # Mode determines how `condition` is interpreted
    mode: RuleMode = RuleMode.SIMPLE

    # Condition (interpretation depends on mode)
    # SIMPLE: ignored (always matches)
    # CONDITIONAL: "meeting.title contains 'standup'"
    # EXPRESSION: "duration > 30m AND has_calendar_event"
    # NATURAL: "Start recording for any meeting about standups"
    condition: str | None = None

    # Actions to execute when condition matches
    actions: list[RuleAction] = field(default_factory=list)

    # Priority for conflict resolution (higher = evaluated first)
    priority: int = 0

    # Is this rule enabled?
    enabled: bool = True

    # Metadata for UI/debugging
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass
class RuleSet:
    """Collection of rules for a rule type."""

    rule_type: str  # e.g., "trigger", "export"
    rules: list[ConditionalRule] = field(default_factory=list)

    # Simple mode shortcut: if no conditional rules, use these defaults
    simple_defaults: dict[str, Any] = field(default_factory=dict)

    def is_simple_mode(self) -> bool:
        """Check if using simple static defaults only."""
        return len(self.rules) == 0 and len(self.simple_defaults) > 0
```

#### Usage Examples

```python
# === SIMPLE MODE (MVP) ===
# Just set static values, no conditions
trigger_rules = TriggerRules(
    auto_start_enabled=True,
    calendar_match_patterns=["*standup*"],
)

# === CONDITIONAL MODE (Sprint 22+) ===
# Rules with if-then logic
trigger_ruleset = RuleSet(
    rule_type="trigger",
    rules=[
        ConditionalRule(
            name="Auto-start standups",
            mode=RuleMode.CONDITIONAL,
            condition="meeting.title contains 'standup'",
            actions=[RuleAction("auto_start", {"notify": True})],
            priority=10,
        ),
        ConditionalRule(
            name="Auto-start long calendar meetings",
            mode=RuleMode.CONDITIONAL,
            condition="calendar.duration_minutes > 30 AND calendar.attendee_count > 1",
            actions=[RuleAction("auto_start", {"notify": False})],
            priority=5,
        ),
    ],
)

# === EXPRESSION MODE (Future) ===
# Full expression language with functions
ConditionalRule(
    name="Complex trigger",
    mode=RuleMode.EXPRESSION,
    condition="duration > 30m AND (has_calendar_event OR app_name IN ['Zoom', 'Meet'])",
    actions=[RuleAction("auto_start", {})],
)

# === NATURAL LANGUAGE MODE (Future) ===
# Transforms to expression via LLM
ConditionalRule(
    name="NL rule",
    mode=RuleMode.NATURAL,
    condition="Start recording whenever I join a video call with external participants",
    # System transforms to expression and stores in metadata["compiled_expression"]
    metadata={"compiled_expression": "app_name IN ['Zoom', 'Meet'] AND has_external_attendees"},
)
```

---

### Expression Language (Future Sprint)

Reserve grammar for future expression evaluation:

```
# Expression Grammar (EBNF-ish)
expr       = or_expr
or_expr    = and_expr ("OR" and_expr)*
and_expr   = not_expr ("AND" not_expr)*
not_expr   = "NOT" not_expr | comparison
comparison = value (comp_op value)?
comp_op    = ">" | "<" | ">=" | "<=" | "==" | "!=" | "contains" | "IN"
value      = field | literal | function_call
field      = identifier ("." identifier)*
literal    = string | number | boolean | list
function_call = identifier "(" (expr ("," expr)*)? ")"

# Examples
meeting.title contains "standup"
calendar.duration_minutes > 30
app_name IN ["Zoom", "Google Meet", "Teams"]
NOW() - meeting.created_at < 5m
```

**Implementation Note**: Start with a simple parser in Sprint 22. Can evolve to use a proper expression engine (e.g., `lark`, `pyparsing`) later.

---

### Roadmap: Rules Evolution

This flexible architecture enables feature evolution without schema migrations:

| Sprint | Feature | How It's Enabled |
|--------|---------|------------------|
| **18** | Simple static rules | `ExportRules`, `TriggerRules` with typed fields |
| **22** | Conditional rules | `RuleSet` with `ConditionalRule` stored in extensions |
| **22** | New rule type: Retention | `@RuleTypeRegistry.register` + `extensions["retention_rules"]` |
| **23+** | Expression evaluation | `RuleMode.EXPRESSION` + simple parser |
| **24+** | Natural language rules | `RuleMode.NATURAL` + LLM transformation to expression |
| **25+** | Rule marketplace | Third-party rule types via plugin system |

**Adding a New Rule Type (No Schema Migration)**:

```python
# Sprint 22: Add retention rules without schema change

@RuleTypeRegistry.register
class RetentionRuleType(RuleType):
    """Rule type for data retention policies."""
    name = "retention"
    version = 1
    # ... implementation

# Usage in settings (stored in extensions until promoted)
workspace.settings.set_extension("retention_rules", {
    "meeting_retention_days": 365,
    "audio_retention_days": 90,
})

# Sprint 23: Promote to typed field if stable
@dataclass
class WorkspaceSettings(ExtensibleSettings):
    retention_rules: RetentionRules | None = None  # Promoted!
    # Migration moves data from extensions → typed field
```

---

### ProjectSettings Value Object

```python
# src/noteflow/domain/entities/project.py

@dataclass(frozen=True)
class ExportRules:
    """Project-level export configuration (overrides workspace)."""

    default_format: ExportFormat | None = None  # Reuse existing export enum
    include_audio: bool | None = None
    include_timestamps: bool | None = None
    template_id: UUID | None = None

@dataclass(frozen=True)
class TriggerRules:
    """Project-level trigger configuration (overrides workspace)."""

    auto_start_enabled: bool | None = None
    calendar_match_patterns: list[str] | None = None  # None => inherit, [] => clear
    app_match_patterns: list[str] | None = None       # None => inherit, [] => clear

@dataclass
class ProjectSettings:
    """Merged settings for a project."""

    export_rules: ExportRules | None = None
    trigger_rules: TriggerRules | None = None
    rag_enabled: bool | None = None  # None => inherit
    default_summarization_template: SummarizationOptions | None = None
    # Reuse SummarizationOptions/AITemplate shape from existing settings
```

---

## Rule Inheritance Model

> ✅ **Prerequisite satisfied**: `WorkspaceSettings` added to `Workspace` entity (Option A).

Projects inherit rules from their parent workspace using **merge semantics** with explicit override
presence (unset = inherit, set empty = clear).

### EffectiveRules Type (resolves G2)

```python
# src/noteflow/domain/entities/project.py

@dataclass(frozen=True)
class EffectiveRules:
    """Resolved rules after merging system defaults → workspace → project."""

    export: ExportRules
    trigger: TriggerRules
    rag_enabled: bool
    default_summarization_template: str | None
```

### System Defaults (resolves G1)

```python
# src/noteflow/domain/entities/project.py

from noteflow.domain.value_objects import ExportFormat  # Move from application layer

SYSTEM_DEFAULTS = EffectiveRules(
    export=ExportRules(
        default_format=ExportFormat.MARKDOWN,
        include_audio=False,
        include_timestamps=True,
        template_id=None,
    ),
    trigger=TriggerRules(
        auto_start_enabled=False,
        calendar_match_patterns=[],
        app_match_patterns=[],
    ),
    rag_enabled=False,
    default_summarization_template=None,
)
```

### Complete Merge Logic (resolves G3)

```python
# src/noteflow/application/services/project_service.py

def _coalesce(project_val: T | None, workspace_val: T | None, default_val: T) -> T:
    """Return first non-None value in precedence order."""
    if project_val is not None:
        return project_val
    if workspace_val is not None:
        return workspace_val
    return default_val


def get_effective_rules(
    project: Project,
    workspace: Workspace,  # Requires Workspace.settings: WorkspaceSettings
) -> EffectiveRules:
    """Merge workspace defaults with project overrides.

    Precedence: project > workspace > system defaults
    - None means "inherit from parent level"
    - Empty list [] means "explicitly cleared" (overrides parent)
    - Non-None value means "use this value"
    """
    ws = workspace.settings or WorkspaceSettings()
    ps = project.settings or ProjectSettings()

    ws_export = ws.export_rules or ExportRules()
    ps_export = ps.export_rules or ExportRules()
    ws_trigger = ws.trigger_rules or TriggerRules()
    ps_trigger = ps.trigger_rules or TriggerRules()

    return EffectiveRules(
        export=ExportRules(
            default_format=_coalesce(
                ps_export.default_format,
                ws_export.default_format,
                SYSTEM_DEFAULTS.export.default_format,
            ),
            include_audio=_coalesce(
                ps_export.include_audio,
                ws_export.include_audio,
                SYSTEM_DEFAULTS.export.include_audio,
            ),
            include_timestamps=_coalesce(
                ps_export.include_timestamps,
                ws_export.include_timestamps,
                SYSTEM_DEFAULTS.export.include_timestamps,
            ),
            template_id=_coalesce(
                ps_export.template_id,
                ws_export.template_id,
                SYSTEM_DEFAULTS.export.template_id,
            ),
        ),
        trigger=TriggerRules(
            auto_start_enabled=_coalesce(
                ps_trigger.auto_start_enabled,
                ws_trigger.auto_start_enabled,
                SYSTEM_DEFAULTS.trigger.auto_start_enabled,
            ),
            # List semantics: None = inherit, [] = cleared, [...] = override
            calendar_match_patterns=_coalesce(
                ps_trigger.calendar_match_patterns,
                ws_trigger.calendar_match_patterns,
                SYSTEM_DEFAULTS.trigger.calendar_match_patterns,
            ),
            app_match_patterns=_coalesce(
                ps_trigger.app_match_patterns,
                ws_trigger.app_match_patterns,
                SYSTEM_DEFAULTS.trigger.app_match_patterns,
            ),
        ),
        rag_enabled=_coalesce(
            ps.rag_enabled,
            ws.rag_enabled,
            SYSTEM_DEFAULTS.rag_enabled,
        ),
        default_summarization_template=_coalesce(
            ps.default_summarization_template,
            ws.default_summarization_template,
            SYSTEM_DEFAULTS.default_summarization_template,
        ),
    )
```

### List Clearing Semantics

| Project Value | Workspace Value | Effective Value |
|---------------|-----------------|-----------------|
| `None` | `["*standup*"]` | `["*standup*"]` (inherit) |
| `[]` | `["*standup*"]` | `[]` (explicitly cleared) |
| `["*retro*"]` | `["*standup*"]` | `["*retro*"]` (override) |
| `None` | `None` | `[]` (system default) |

---

## Proto Schema Additions

```protobuf
// noteflow.proto additions
// Required import (verify not already present):
import "google/protobuf/timestamp.proto";

enum ProjectRole {
  PROJECT_ROLE_UNSPECIFIED = 0;
  PROJECT_ROLE_VIEWER = 1;
  PROJECT_ROLE_EDITOR = 2;
  PROJECT_ROLE_ADMIN = 3;
}

message ProjectSettings {
  optional ExportRules export_rules = 1;
  optional TriggerRules trigger_rules = 2;
  optional bool rag_enabled = 3;
  optional SummarizationOptions default_summarization_template = 4;
}

message ExportRules {
  optional ExportFormat default_format = 1;
  optional bool include_audio = 2;
  optional bool include_timestamps = 3;
  optional string template_id = 4;
}

message StringList {
  repeated string values = 1;
}

message TriggerRules {
  optional bool auto_start_enabled = 1;
  optional StringList calendar_match_patterns = 2; // unset=inherit, empty=clear
  optional StringList app_match_patterns = 3;      // unset=inherit, empty=clear
}

message Project {
  string id = 1;
  string workspace_id = 2;
  string name = 3;
  optional string slug = 4;
  optional string description = 5;
  bool is_default = 6;
  ProjectSettings settings = 7;
  google.protobuf.Timestamp created_at = 8;
  google.protobuf.Timestamp updated_at = 9;
  optional google.protobuf.Timestamp archived_at = 10;
}

message ProjectMembership {
  string project_id = 1;
  string user_id = 2;
  ProjectRole role = 3;
  google.protobuf.Timestamp joined_at = 4;
}

// Meeting scoping updates (project defaults apply when omitted)
message Meeting {
  // ...
  string project_id = 11;
}

message CreateMeetingRequest {
  // ...
  optional string project_id = 3;
}

message ListMeetingsRequest {
  // ...
  optional string project_id = 5;
}

// RPCs
message CreateProjectRequest {
  string workspace_id = 1;
  string name = 2;
  optional string description = 3;
  optional ProjectSettings settings = 4;
}

message CreateProjectResponse {
  Project project = 1;
}

message GetProjectRequest {
  string project_id = 1;
}

message GetProjectResponse {
  Project project = 1;
}

message ListProjectsRequest {
  string workspace_id = 1;
  bool include_archived = 2;
}

message ListProjectsResponse {
  repeated Project projects = 1;
}

message UpdateProjectRequest {
  string project_id = 1;
  optional string name = 2;
  optional string description = 3;
  optional ProjectSettings settings = 4;
}

message UpdateProjectResponse {
  Project project = 1;
}

message ArchiveProjectRequest {
  string project_id = 1;
}

message ArchiveProjectResponse {}

message RestoreProjectRequest {
  string project_id = 1;
}

message RestoreProjectResponse {
  Project project = 1;
}

// Active project management (resolves B2)
message SetActiveProjectRequest {
  string workspace_id = 1;
  string project_id = 2;  // Empty string to clear (use default)
}

message SetActiveProjectResponse {}

message GetActiveProjectRequest {
  string workspace_id = 1;
}

message GetActiveProjectResponse {
  optional string project_id = 1;  // None if using workspace default
  Project project = 2;             // Resolved project (default if not set)
}

message AddProjectMemberRequest {
  string project_id = 1;
  string user_id = 2;
  ProjectRole role = 3;
}

message AddProjectMemberResponse {}

message RemoveProjectMemberRequest {
  string project_id = 1;
  string user_id = 2;
}

message RemoveProjectMemberResponse {}

message ListProjectMembersRequest {
  string project_id = 1;
}

message ListProjectMembersResponse {
  repeated ProjectMembership members = 1;
}

// Service additions (11 new RPCs)
service NoteFlow {
  // ... existing RPCs ...

  // Project management (6 RPCs)
  rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
  rpc GetProject(GetProjectRequest) returns (GetProjectResponse);
  rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
  rpc UpdateProject(UpdateProjectRequest) returns (UpdateProjectResponse);
  rpc ArchiveProject(ArchiveProjectRequest) returns (ArchiveProjectResponse);
  rpc RestoreProject(RestoreProjectRequest) returns (RestoreProjectResponse);

  // Active project (2 RPCs) — resolves B2
  rpc SetActiveProject(SetActiveProjectRequest) returns (SetActiveProjectResponse);
  rpc GetActiveProject(GetActiveProjectRequest) returns (GetActiveProjectResponse);

  // Project membership (3 RPCs)
  rpc AddProjectMember(AddProjectMemberRequest) returns (AddProjectMemberResponse);
  rpc RemoveProjectMember(RemoveProjectMemberRequest) returns (RemoveProjectMemberResponse);
  rpc ListProjectMembers(ListProjectMembersRequest) returns (ListProjectMembersResponse);
}
```

---

## Migration Strategy

### Phase 1: Schema
1. Create `projects` table with UNIQUE constraint on `(workspace_id, slug)`
2. Create `project_memberships` table with UNIQUE constraint on `(project_id, user_id)`
3. Add `project_id` (nullable) to `meetings` table with FK to `projects.id`
4. Add `project_id` (nullable) to `tasks` table (if exists)

### Phase 2: Backfill
1. For each workspace, create default 'General' project with `is_default=true`, `slug='general'`
2. Update all meetings without `project_id` to use workspace's default project
3. Add all workspace members to default project:
   - `WorkspaceRole.OWNER/ADMIN` → `ProjectRole.ADMIN`
   - `WorkspaceRole.MEMBER` → `ProjectRole.EDITOR`
   - `WorkspaceRole.VIEWER` → `ProjectRole.VIEWER`

### Phase 3: Constraint (Sprint 22)
1. Add NOT NULL constraint on `meetings.project_id`
2. Enforce project requirement in gRPC handlers (reject requests without project context)

> **Note**: Phase 3 is scheduled for Sprint 22 (Rules v1) after rule inheritance is verified working.

### Migration Risks

| Risk | Mitigation |
|------|------------|
| Concurrent writes during backfill | Run during maintenance window OR add CHECK constraint first to require `project_id` on new rows |
| Orphaned meetings if workspace has no members | Backfill script must handle empty workspaces gracefully |
| Cascade delete risk | Projects use RESTRICT delete; require manual meeting reassignment before deletion |

---

## Shared Types & Reuse Notes

- **Move `ExportFormat`** from `application/services/export_service.py` to `domain/value_objects.py` for domain layer access.
- Reuse `SummarizationOptions` / `AITemplate` shape for project defaults (create domain dataclass if needed).
- Prefer existing scoped settings storage (e.g., `settings` table) if it fits project-scoped config.
- Active project stored via new `SetActiveProject` RPC (see proto section).

---

## URL Routing

Client routing updates to include project context:

| Current Route | New Route | Notes |
|---------------|-----------|-------|
| `/meetings` | `/projects/:projectId/meetings` | Project-scoped meeting list |
| `/meetings/:id` | `/projects/:projectId/meetings/:id` | Meeting detail |
| `/settings` | `/settings` | Workspace settings (unchanged) |
| — | `/projects` | Project list (new) |
| — | `/projects/:projectId/settings` | Project settings (new) |

**Fallback**: If `:projectId` is omitted, use active project for workspace.

```tsx
// client/src/App.tsx
<Routes>
  <Route path="/projects" element={<ProjectsPage />} />
  <Route path="/projects/:projectId/settings" element={<ProjectSettingsPage />} />
  <Route path="/projects/:projectId/meetings" element={<MeetingsPage />} />
  <Route path="/projects/:projectId/meetings/:id" element={<MeetingDetailPage />} />
  <Route path="/meetings" element={<MeetingsRedirect />} />
  <Route path="/meetings/:id" element={<MeetingDetailRedirect />} />
</Routes>
```

---

## UI Components

### ProjectSidebar

```tsx
// client/src/components/projects/ProjectSidebar.tsx

export function ProjectSidebar() {
  const { projects, activeProject, switchProject } = useProjects();

  return (
    <aside className="project-sidebar">
      <div className="sidebar-header">
        <h2>Projects</h2>
        <CreateProjectButton />
      </div>
      <ul className="project-list">
        {projects.map(project => (
          <ProjectListItem
            key={project.id}
            project={project}
            isActive={project.id === activeProject?.id}
            onClick={() => switchProject(project.id)}
          />
        ))}
      </ul>
    </aside>
  );
}
```

### ProjectSwitcher

```tsx
// client/src/components/projects/ProjectSwitcher.tsx

export function ProjectSwitcher() {
  const { projects, activeProject, switchProject } = useProjects();
  const [search, setSearch] = useState("");

  const filtered = projects.filter(p =>
    p.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">
          {activeProject?.name || "Select Project"}
          <ChevronDown className="ml-2 h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <Input
          placeholder="Search projects..."
          value={search}
          onChange={e => setSearch(e.target.value)}
        />
        {filtered.map(project => (
          <DropdownMenuItem
            key={project.id}
            onClick={() => switchProject(project.id)}
          >
            {project.name}
            {project.is_default && <Badge>Default</Badge>}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
```

### ProjectSettingsPanel

```tsx
// client/src/components/projects/ProjectSettingsPanel.tsx

export function ProjectSettingsPanel({ projectId }: Props) {
  const { project, updateProject } = useProject(projectId);
  const { members, addMember, removeMember } = useProjectMembers(projectId);

  return (
    <div className="project-settings">
      <section>
        <h3>General</h3>
        <Input label="Name" value={project.name} onChange={...} />
        <Textarea label="Description" value={project.description} onChange={...} />
      </section>

      <section>
        <h3>Export Rules</h3>
        <Select label="Default Format" options={["markdown", "html", "pdf"]} ... />
        <Checkbox label="Include Audio" ... />
        <Checkbox label="Include Timestamps" ... />
      </section>

      <section>
        <h3>Trigger Rules</h3>
        <Checkbox label="Auto-start Recording" ... />
        <TagInput label="Calendar Patterns" ... />
        <TagInput label="App Patterns" ... />
      </section>

      <section>
        <h3>Members</h3>
        <MemberList members={members} onRemove={removeMember} />
        <AddMemberDialog onAdd={addMember} />
      </section>
    </div>
  );
}
```

---

## Deliverables

### Backend

**Domain Layer — Settings & Rules Infrastructure**:
- [x] `src/noteflow/domain/settings/base.py` — ExtensibleSettings base class with extensions dict
- [x] `src/noteflow/domain/rules/registry.py` — RuleTypeRegistry, RuleType base, RuleContext, RuleResult
- [x] `src/noteflow/domain/rules/models.py` — RuleMode, RuleAction, ConditionalRule, RuleSet
- [x] `src/noteflow/domain/rules/builtin.py` — ExportRuleType, TriggerRuleType (built-in plugins)

**Domain Layer — Entities**:
- [x] `src/noteflow/domain/entities/project.py` — Project, ProjectSettings, ExportRules, TriggerRules, EffectiveRules, SYSTEM_DEFAULTS
- [x] `src/noteflow/domain/value_objects.py` — Move ExportFormat from application layer
- [x] `src/noteflow/domain/errors.py` — Add CannotArchiveDefaultProject, PermissionDeniedError
- [x] `src/noteflow/domain/identity/roles.py` — Add ProjectRole enum
- [x] `src/noteflow/domain/identity/entities.py` — Add WorkspaceSettings (extends ExtensibleSettings), ProjectMembership; update Workspace
- [x] `src/noteflow/domain/identity/context.py` — Update ProjectContext with role field, add can_write_project/can_admin_project
- [x] `src/noteflow/domain/ports/repositories/identity.py` — Add ProjectRepository, ProjectMembershipRepository

**Application Layer**:
- [x] `src/noteflow/application/services/identity_service.py` — Default workspace + default project creation (active project resolved via ProjectService)
- [x] `src/noteflow/application/services/project_service.py` — get_effective_rules, resolve_project_role, active project helpers

**Infrastructure Layer**:
- [x] `src/noteflow/infrastructure/persistence/models/identity/identity.py` — Workspace settings JSONB + Project/ProjectMembership models
- [x] `src/noteflow/infrastructure/persistence/repositories/identity_repo.py` — ProjectRepository + ProjectMembershipRepository
- [x] `src/noteflow/infrastructure/persistence/repositories/identity_repo.py` — ORM ↔ domain for Project + WorkspaceSettings

**gRPC Layer**:
- [x] `src/noteflow/grpc/proto/noteflow.proto` — Proto messages (12) and RPCs (11)
- [x] `src/noteflow/grpc/_mixins/project.py` — ProjectMixin

**Migrations** (5 scripts):
- [x] `o9p0q1r2s3t4_add_projects_schema.py` — Add settings JSONB to workspaces table (Option A)
- [x] `o9p0q1r2s3t4_add_projects_schema.py` — Create projects table with constraints
- [x] `o9p0q1r2s3t4_add_projects_schema.py` — Create project_memberships table
- [x] `o9p0q1r2s3t4_add_projects_schema.py` — Add nullable `project_id` FK to meetings
- [x] `o9p0q1r2s3t4_add_projects_schema.py` — Backfill default projects and migrate meetings

### Client (Rust)

- [x] `client/src-tauri/src/commands/projects.rs` — Tauri commands (create, get, list, update, archive, restore, set_active, get_active)
- [x] `client/src-tauri/src/grpc/client/projects.rs` — gRPC client methods (11 RPCs)
- [ ] `client/src-tauri/src/state/project.rs` — Optional (active project state handled in React `ProjectProvider`)
- [x] `client/src-tauri/src/grpc/types/projects.rs` — Rust types for proto messages

### Client (React)

- [x] `client/src/api/types/projects.ts` — TypeScript types (Project, ProjectRole, ProjectSettings, etc.)
- [x] `client/src/api/tauri-adapter.ts` — Extend adapter with 11 project methods
- [x] `client/src/contexts/project-context.tsx` — React context for active project
- [x] `client/src/hooks/use-project.ts` — useProject, useProjects, useActiveProject hooks
- [x] `client/src/hooks/use-project-members.ts` — useProjectMembers hook
- [x] `client/src/App.tsx` — Project-scoped routes + redirects for legacy URLs
- [x] `client/src/components/projects/ProjectSidebar.tsx`
- [x] `client/src/components/projects/ProjectSwitcher.tsx`
- [x] `client/src/components/projects/ProjectList.tsx`
- [x] `client/src/components/projects/ProjectSettingsPanel.tsx`

---

## Test Strategy

### Fixtures to extend or create

- `tests/conftest.py`: add `workspace_id`, `project_id`, `default_project`, and extend `mock_uow` with `projects` + `project_memberships` repos.
- `tests/grpc/conftest.py`: add `mock_project_repo`, `mock_project_membership_repo`, and sample `Project` / `ProjectMembership` fixtures.
- New `tests/domain/conftest.py` (if needed): reusable `ProjectSettings`, `ExportRules`, `TriggerRules` defaults.
- `tests/integration/conftest.py`: add `active_project_id` fixture for end-to-end scoping checks.

### Parameterized tests

- Role mapping: WorkspaceRole → ProjectRole (owner/admin/member/viewer).
- Rule inheritance: `None` vs explicit values; list clearing vs inherit.
- Project CRUD: create/update/archive; default project constraints.
- Membership operations: add/remove/list across role permutations.

### Core test cases (behavior + robustness)

- **Domain**: Project entity validation; default project uniqueness per workspace; slug uniqueness.
- **Role resolution**: Workspace OWNER/ADMIN always treated as ProjectRole.ADMIN.
- **Rule merging**: Effective rules merge correctly; `rag_enabled` and list overrides respect presence.
- **gRPC**: Project RPCs enforce workspace membership; `project_id` defaulting for meeting list/create.
- **Migration**: Backfill creates default project, assigns members, and updates meetings/tasks.
- **Client**: Active project persistence per workspace; switcher updates scoped meeting lists.

---

## Quality Gates

- [ ] `pytest tests/domain/test_project.py` passes
- [ ] `pytest tests/grpc/test_projects.py` passes
- [ ] `pytest tests/infrastructure/test_project_repository.py` passes
- [ ] `npm run test` passes for frontend
- [ ] Meets `docs/sprints/QUALITY_STANDARDS.md` (lint + test quality thresholds)
- [ ] No `# type: ignore` without justification
- [ ] All public functions have docstrings

---

## Post-Sprint

- [ ] Project templates (customizable default settings)
- [ ] Project archiving with meeting preservation
- [ ] Cross-project search
- [ ] Project cloning
- [ ] Project activity feed
