# Sprint 0: Proto & Schema Foundation

> **Priority**: 0 | **Owner**: Backend | **Complexity**: Medium | **Prerequisite for all other sprints**

---

## Validation Status (2025-12-27)

### ✅ 100% COMPLETE

| Component | Location | Status |
|-----------|----------|--------|
| Proto file | `src/noteflow/grpc/proto/noteflow.proto` | ✅ Full schema with all RPCs |
| Proto stubs | `noteflow_pb2.py`, `noteflow_pb2_grpc.py`, `noteflow_pb2.pyi` | ✅ Generated |
| Feature flags | `config/settings.py:202-239` | ✅ All 5 flags (`templates_enabled`, `pdf_export_enabled`, `ner_enabled`, `calendar_enabled`, `webhooks_enabled`) |
| ORM models | `persistence/models/{core,entities,integrations,identity,organization}/` | ✅ Complete structure |
| Repositories | `persistence/repositories/` | ✅ 10+ repositories |
| gRPC mixins | `grpc/_mixins/` | ✅ Modular architecture |
| Alembic migrations | `persistence/migrations/versions/` | ✅ Migrations exist |
| PROTO_CHANGELOG.md | `docs/sprints/phase-0-foundation/PROTO_CHANGELOG.md` | ✅ Documented |
| Rust/TS stubs | `client/src-tauri/build.rs` generates at build time | ✅ Synced |

---

## Objective

Consolidate all protobuf schema changes and database migrations required by Sprints 1-6 into a single coordinated release. This prevents proto conflicts, ensures backward compatibility, and establishes the persistence foundation for all features.

---

## Rationale

Multiple sprints modify shared infrastructure:

| Sprint | Proto Changes | DB Changes |
|--------|---------------|------------|
| 1 (AI Templates) | `SummarizationOptions` message | None |
| 3 (PDF Export) | `EXPORT_FORMAT_PDF` enum | None |
| 4 (NER) | `ExtractEntities` RPC + messages | `named_entities` table |
| 5 (Calendar) | `ListCalendarEvents` RPC + messages | Uses existing tables |
| 6 (Webhooks) | None | `webhook_configs`, `webhook_deliveries` tables |

Without coordination:
- Proto regeneration conflicts between parallel sprints
- Migration ordering issues
- Client/server version mismatches

---

## Phased Implementation

Sprint 0 is split into four sub-increments to enable independent verification and reduce blast radius:

| Increment | Scope | Verification Gate |
|-----------|-------|-------------------|
| **0a** | Proto schema + stub regeneration | `python -c "from noteflow.grpc.proto import noteflow_pb2"` |
| **0b** | Database schema (schema.sql) | `psql -f docker/db/schema.sql` on fresh DB |
| **0c** | Alembic migrations | `alembic upgrade head && alembic downgrade -1` |
| **0d** | Dependencies + Docker + Feature flags | `pip install -e ".[all]" && pytest tests/` |

### Increment 0a: Proto Schema

**Files**: `noteflow.proto`, `*_pb2.py`, `*_pb2_grpc.py`, `*_pb2.pyi`

**Tasks**: Task 1, Task 7, Task 9

**Done when**:
- [ ] Proto compiles without errors
- [ ] Python stubs import cleanly
- [ ] Rust/TS stubs generate via `client/build.rs`
- [ ] PROTO_CHANGELOG.md committed

### Increment 0b: Database Schema

**Files**: `docker/db/schema.sql`

**Tasks**: Task 2

**Done when**:
- [ ] Schema applies to fresh PostgreSQL
- [ ] All tables have proper indexes
- [ ] Foreign key constraints validated
- [ ] Triggers for `updated_at` in place

### Increment 0c: Alembic Migrations

**Files**: `migrations/versions/001_*.py`, `migrations/versions/002_*.py`

**Tasks**: Task 3

**Done when**:
- [ ] Migrations apply to existing database
- [ ] Downgrade path works for each migration
- [ ] Schema matches schema.sql output

### Increment 0d: Dependencies and Docker

**Files**: `pyproject.toml`, `Dockerfile`, `docker-compose.yml`, `settings.py`, `cli/models.py`

**Tasks**: Task 4, Task 5, Task 6, Task 8

**Done when**:
- [ ] All optional dependencies install
- [ ] Feature flags control availability
- [ ] Model download CLI works
- [ ] Docker build completes with NER support

---

## Target/Affected Code

### Files to Modify

| File | Change Type |
|------|-------------|
| `src/noteflow/grpc/proto/noteflow.proto` | All proto additions |
| `src/noteflow/grpc/proto/noteflow_pb2.py` | Regenerated |
| `src/noteflow/grpc/proto/noteflow_pb2_grpc.py` | Regenerated |
| `src/noteflow/grpc/proto/noteflow_pb2.pyi` | Regenerated |
| `docker/db/schema.sql` | All table additions |
| `pyproject.toml` | All new dependencies |
| `client/src-tauri/build.rs` | Proto path verification |

### Files to Create

| File | Purpose |
|------|---------|
| `src/noteflow/infrastructure/persistence/migrations/versions/001_add_named_entities.py` | NER tables |
| `src/noteflow/infrastructure/persistence/migrations/versions/002_add_webhooks.py` | Webhook tables |
| `docs/sprints/phase-0-foundation/PROTO_CHANGELOG.md` | Proto version history |

---

## Implementation Tasks

### Task 1: Proto Schema Consolidation

**File**: `src/noteflow/grpc/proto/noteflow.proto`

Add all new messages and RPCs in a single commit:

```protobuf
// =============================================================================
// Sprint 0: Consolidated Proto Changes
// Version: 2.0.0
// Date: 2025-XX-XX
// =============================================================================

// -----------------------------------------------------------------------------
// Sprint 1: AI Templates
// -----------------------------------------------------------------------------

// Summarization style options passed from frontend settings
message SummarizationOptions {
  // Tone: professional, casual, technical, friendly
  string tone = 1;

  // Format: bullet_points, narrative, structured, concise
  string format = 2;

  // Verbosity: minimal, balanced, detailed, comprehensive
  string verbosity = 3;
}

// Modify existing GenerateSummaryRequest (add field 3)
// message GenerateSummaryRequest {
//   string meeting_id = 1;
//   bool force_regenerate = 2;
//   SummarizationOptions options = 3;  // NEW
// }

// -----------------------------------------------------------------------------
// Sprint 3: PDF Export
// -----------------------------------------------------------------------------

// Add to existing ExportFormat enum
// enum ExportFormat {
//   EXPORT_FORMAT_UNSPECIFIED = 0;
//   EXPORT_FORMAT_MARKDOWN = 1;
//   EXPORT_FORMAT_HTML = 2;
//   EXPORT_FORMAT_PDF = 3;  // NEW
// }

// -----------------------------------------------------------------------------
// Sprint 4: Named Entity Extraction
// -----------------------------------------------------------------------------

// Add to service definition
// rpc ExtractEntities(ExtractEntitiesRequest) returns (ExtractEntitiesResponse);

message ExtractEntitiesRequest {
  string meeting_id = 1;
  bool force_refresh = 2;  // Re-extract even if entities exist
}

message ExtractedEntity {
  string id = 1;
  string text = 2;
  // Category: person, company, product, technical, acronym, location, date, other
  string category = 3;
  repeated int32 segment_ids = 4;
  float confidence = 5;
  bool is_pinned = 6;  // User-confirmed
}

message ExtractEntitiesResponse {
  repeated ExtractedEntity entities = 1;
  int32 total_count = 2;
  bool cached = 3;  // True if returning cached results
}

// -----------------------------------------------------------------------------
// Sprint 5: Calendar Sync
// -----------------------------------------------------------------------------

// Add to service definition
// rpc ListCalendarEvents(ListCalendarEventsRequest) returns (ListCalendarEventsResponse);
// rpc GetCalendarProviders(GetCalendarProvidersRequest) returns (GetCalendarProvidersResponse);
// rpc InitiateCalendarAuth(InitiateCalendarAuthRequest) returns (InitiateCalendarAuthResponse);
// rpc CompleteCalendarAuth(CompleteCalendarAuthRequest) returns (CompleteCalendarAuthResponse);

message CalendarEvent {
  string id = 1;
  string title = 2;
  int64 start_time = 3;   // Unix timestamp (seconds)
  int64 end_time = 4;     // Unix timestamp (seconds)
  repeated string attendees = 5;
  string location = 6;
  string description = 7;
  string meeting_url = 8;
  bool is_recurring = 9;
  string provider = 10;   // google, outlook
}

message ListCalendarEventsRequest {
  int32 hours_ahead = 1;    // How far ahead to look (default: 24)
  int32 limit = 2;          // Max events to return (default: 10)
  string provider = 3;      // Optional: specific provider name
}

message ListCalendarEventsResponse {
  repeated CalendarEvent events = 1;
  int32 total_count = 2;
}

message GetCalendarProvidersRequest {}

message CalendarProvider {
  string name = 1;
  bool is_authenticated = 2;
  string display_name = 3;  // "Google Calendar", "Microsoft Outlook"
}

message GetCalendarProvidersResponse {
  repeated CalendarProvider providers = 1;
}

// OAuth flow messages
message InitiateCalendarAuthRequest {
  string provider = 1;  // google, outlook
  string redirect_uri = 2;  // Where to redirect after auth
}

message InitiateCalendarAuthResponse {
  string auth_url = 1;  // URL to redirect user to
  string state = 2;     // CSRF token to verify callback
}

message CompleteCalendarAuthRequest {
  string provider = 1;
  string code = 2;      // Authorization code from OAuth callback
  string state = 3;     // CSRF token for verification
}

message CompleteCalendarAuthResponse {
  bool success = 1;
  string error_message = 2;
  string provider_email = 3;  // Email of authenticated account
}
```

---

### Task 2: Database Schema Additions

**File**: `docker/db/schema.sql`

Add after existing tables (preserve insertion order for foreign keys):

```sql
--------------------------------------------------------------------------------
-- Sprint 4: Named Entities
--------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS noteflow.named_entities (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  meeting_id      uuid NOT NULL REFERENCES noteflow.meetings(id) ON DELETE CASCADE,
  text            text NOT NULL,
  normalized_text text NOT NULL,  -- Lowercase, trimmed for deduplication
  category        varchar(50) NOT NULL,  -- person, company, product, location, etc.
  segment_ids     integer[] NOT NULL DEFAULT '{}'::integer[],
  confidence      double precision NOT NULL DEFAULT 0.0,
  is_pinned       boolean NOT NULL DEFAULT false,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),

  -- Unique constraint for deduplication within a meeting
  CONSTRAINT uq_named_entities_meeting_text UNIQUE (meeting_id, normalized_text)
);

CREATE TRIGGER trg_named_entities_updated_at
BEFORE UPDATE ON noteflow.named_entities
FOR EACH ROW EXECUTE FUNCTION noteflow.set_updated_at();

CREATE INDEX IF NOT EXISTS idx_named_entities_meeting_id
  ON noteflow.named_entities(meeting_id);

CREATE INDEX IF NOT EXISTS idx_named_entities_category
  ON noteflow.named_entities(category);

--------------------------------------------------------------------------------
-- Sprint 6: Webhooks
--------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS noteflow.webhook_configs (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id    uuid NOT NULL REFERENCES noteflow.workspaces(id) ON DELETE CASCADE,
  name            varchar(255) NOT NULL DEFAULT 'Webhook',
  url             text NOT NULL,
  events          text[] NOT NULL DEFAULT '{}'::text[],
  secret          text NULL,  -- HMAC signing secret
  enabled         boolean NOT NULL DEFAULT true,
  timeout_ms      integer NOT NULL DEFAULT 10000,
  max_retries     integer NOT NULL DEFAULT 3,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),

  -- Validate URL format
  CONSTRAINT chk_webhook_url_format CHECK (url ~ '^https?://')
);

CREATE TRIGGER trg_webhook_configs_updated_at
BEFORE UPDATE ON noteflow.webhook_configs
FOR EACH ROW EXECUTE FUNCTION noteflow.set_updated_at();

CREATE INDEX IF NOT EXISTS idx_webhook_configs_workspace_id
  ON noteflow.webhook_configs(workspace_id);

CREATE TABLE IF NOT EXISTS noteflow.webhook_deliveries (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  webhook_id      uuid NOT NULL REFERENCES noteflow.webhook_configs(id) ON DELETE CASCADE,
  event_type      text NOT NULL,
  payload         jsonb NOT NULL DEFAULT '{}'::jsonb,
  status_code     integer NULL,
  response_body   text NULL,  -- First 1KB of response for debugging
  error_message   text NULL,
  attempt_count   integer NOT NULL DEFAULT 1,
  duration_ms     integer NULL,  -- Request duration for monitoring
  delivered_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id
  ON noteflow.webhook_deliveries(webhook_id, delivered_at DESC);

CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event_type
  ON noteflow.webhook_deliveries(event_type, delivered_at DESC);

-- Partition by month for large deployments (optional)
-- CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_delivered_at
--   ON noteflow.webhook_deliveries(delivered_at);
```

---

### Task 3: Alembic Migrations

**File**: `src/noteflow/infrastructure/persistence/migrations/versions/001_add_named_entities.py`

```python
"""Add named_entities table.

Revision ID: 001_named_entities
Revises: <previous_revision>
Create Date: 2025-XX-XX
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = "001_named_entities"
down_revision = "<previous_revision>"
branch_labels = None
depends_on = None


def upgrade() -> None:
    """Create named_entities table."""
    op.create_table(
        "named_entities",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
        sa.Column(
            "meeting_id",
            postgresql.UUID(as_uuid=True),
            sa.ForeignKey("noteflow.meetings.id", ondelete="CASCADE"),
            nullable=False,
        ),
        sa.Column("text", sa.Text(), nullable=False),
        sa.Column("normalized_text", sa.Text(), nullable=False),
        sa.Column("category", sa.String(50), nullable=False),
        sa.Column(
            "segment_ids",
            postgresql.ARRAY(sa.Integer()),
            nullable=False,
            server_default="{}",
        ),
        sa.Column(
            "confidence",
            sa.Float(),
            nullable=False,
            server_default="0.0",
        ),
        sa.Column(
            "is_pinned",
            sa.Boolean(),
            nullable=False,
            server_default="false",
        ),
        sa.Column(
            "created_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.Column(
            "updated_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.UniqueConstraint(
            "meeting_id",
            "normalized_text",
            name="uq_named_entities_meeting_text",
        ),
        schema="noteflow",
    )

    op.create_index(
        "idx_named_entities_meeting_id",
        "named_entities",
        ["meeting_id"],
        schema="noteflow",
    )

    op.create_index(
        "idx_named_entities_category",
        "named_entities",
        ["category"],
        schema="noteflow",
    )


def downgrade() -> None:
    """Drop named_entities table."""
    op.drop_index("idx_named_entities_category", schema="noteflow")
    op.drop_index("idx_named_entities_meeting_id", schema="noteflow")
    op.drop_table("named_entities", schema="noteflow")
```

**File**: `src/noteflow/infrastructure/persistence/migrations/versions/002_add_webhooks.py`

```python
"""Add webhook tables.

Revision ID: 002_webhooks
Revises: 001_named_entities
Create Date: 2025-XX-XX
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = "002_webhooks"
down_revision = "001_named_entities"
branch_labels = None
depends_on = None


def upgrade() -> None:
    """Create webhook tables."""
    # webhook_configs
    op.create_table(
        "webhook_configs",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
        sa.Column(
            "workspace_id",
            postgresql.UUID(as_uuid=True),
            sa.ForeignKey("noteflow.workspaces.id", ondelete="CASCADE"),
            nullable=False,
        ),
        sa.Column("name", sa.String(255), nullable=False, server_default="Webhook"),
        sa.Column("url", sa.Text(), nullable=False),
        sa.Column(
            "events",
            postgresql.ARRAY(sa.Text()),
            nullable=False,
            server_default="{}",
        ),
        sa.Column("secret", sa.Text(), nullable=True),
        sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
        sa.Column("timeout_ms", sa.Integer(), nullable=False, server_default="10000"),
        sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
        sa.Column(
            "created_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.Column(
            "updated_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.CheckConstraint("url ~ '^https?://'", name="chk_webhook_url_format"),
        schema="noteflow",
    )

    op.create_index(
        "idx_webhook_configs_workspace_id",
        "webhook_configs",
        ["workspace_id"],
        schema="noteflow",
    )

    # webhook_deliveries
    op.create_table(
        "webhook_deliveries",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
        sa.Column(
            "webhook_id",
            postgresql.UUID(as_uuid=True),
            sa.ForeignKey("noteflow.webhook_configs.id", ondelete="CASCADE"),
            nullable=False,
        ),
        sa.Column("event_type", sa.Text(), nullable=False),
        sa.Column(
            "payload",
            postgresql.JSONB(),
            nullable=False,
            server_default="{}",
        ),
        sa.Column("status_code", sa.Integer(), nullable=True),
        sa.Column("response_body", sa.Text(), nullable=True),
        sa.Column("error_message", sa.Text(), nullable=True),
        sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="1"),
        sa.Column("duration_ms", sa.Integer(), nullable=True),
        sa.Column(
            "delivered_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        schema="noteflow",
    )

    op.create_index(
        "idx_webhook_deliveries_webhook_id",
        "webhook_deliveries",
        ["webhook_id", "delivered_at"],
        schema="noteflow",
    )

    op.create_index(
        "idx_webhook_deliveries_event_type",
        "webhook_deliveries",
        ["event_type", "delivered_at"],
        schema="noteflow",
    )


def downgrade() -> None:
    """Drop webhook tables."""
    op.drop_index("idx_webhook_deliveries_event_type", schema="noteflow")
    op.drop_index("idx_webhook_deliveries_webhook_id", schema="noteflow")
    op.drop_table("webhook_deliveries", schema="noteflow")

    op.drop_index("idx_webhook_configs_workspace_id", schema="noteflow")
    op.drop_table("webhook_configs", schema="noteflow")
```

---

### Task 4: Dependency Consolidation

**File**: `pyproject.toml`

Add all new dependencies in a single update:

```toml
[project]
dependencies = [
    # ... existing dependencies ...

    # Sprint 0: Consolidated new dependencies
    "httpx>=0.27",           # HTTP client (webhooks, future integrations)
]

[project.optional-dependencies]
# PDF Export (Sprint 3)
pdf = [
    "weasyprint>=62.0",
]

# Named Entity Recognition (Sprint 4)
ner = [
    "spacy>=3.7",
]

# Calendar Integration (Sprint 5)
calendar = [
    "google-api-python-client>=2.100",
    "google-auth>=2.23",
    "google-auth-oauthlib>=1.1",
    # Outlook support (future)
    # "msal>=1.24",
]

# All optional features
all = [
    "noteflow[pdf,ner,calendar]",
]

[project.scripts]
# Model download helper
noteflow-download-models = "noteflow.cli.models:download_all"
```

---

### Task 5: Model Download CLI

**File**: `src/noteflow/cli/models.py`

```python
"""CLI for downloading ML models."""

from __future__ import annotations

import subprocess
import sys


def download_spacy_model(model: str = "en_core_web_sm") -> None:
    """Download spaCy model.

    Args:
        model: Model name to download.
    """
    print(f"Downloading spaCy model: {model}")
    subprocess.run(
        [sys.executable, "-m", "spacy", "download", model],
        check=True,
    )
    print(f"Successfully downloaded: {model}")


def download_all() -> None:
    """Download all required ML models."""
    print("Downloading all NoteFlow ML models...")

    try:
        download_spacy_model("en_core_web_sm")
    except subprocess.CalledProcessError as e:
        print(f"Failed to download spaCy model: {e}")
        sys.exit(1)

    print("\nAll models downloaded successfully!")
    print("You can now use NER features.")


if __name__ == "__main__":
    download_all()
```

---

### Task 6: Docker Integration

**File**: `Dockerfile` (additions)

```dockerfile
# Stage: Download ML models (optional, for NER support)
FROM python:3.12-slim AS models

# Install spacy and download model
RUN pip install spacy>=3.7 && \
    python -m spacy download en_core_web_sm

# Stage: Runtime with models
FROM noteflow-base AS runtime-with-ner

# Copy spaCy model from models stage
COPY --from=models /usr/local/lib/python3.12/site-packages/en_core_web_sm \
    /usr/local/lib/python3.12/site-packages/en_core_web_sm

# Verify model is available
RUN python -c "import spacy; spacy.load('en_core_web_sm')"
```

**File**: `docker-compose.yml` (additions)

```yaml
services:
  noteflow:
    build:
      context: .
      target: runtime-with-ner  # Use runtime-with-ner for NER support
    environment:
      # Feature flags
      NOTEFLOW_FEATURE_NER_ENABLED: "true"
      NOTEFLOW_FEATURE_CALENDAR_ENABLED: "true"
      NOTEFLOW_FEATURE_WEBHOOKS_ENABLED: "true"
```

---

### Task 7: Proto Regeneration Script

**File**: `scripts/regenerate_proto.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail

PROTO_DIR="src/noteflow/grpc/proto"
PROTO_FILE="$PROTO_DIR/noteflow.proto"

echo "Regenerating protobuf stubs..."

python -m grpc_tools.protoc \
    -I "$PROTO_DIR" \
    --python_out="$PROTO_DIR" \
    --grpc_python_out="$PROTO_DIR" \
    --pyi_out="$PROTO_DIR" \
    "$PROTO_FILE"

echo "Fixing imports for Python 3.12+ compatibility..."
# Fix relative imports in generated files
sed -i '' 's/^import noteflow_pb2/from . import noteflow_pb2/' "$PROTO_DIR/noteflow_pb2_grpc.py" 2>/dev/null || \
    sed -i 's/^import noteflow_pb2/from . import noteflow_pb2/' "$PROTO_DIR/noteflow_pb2_grpc.py"

echo "Proto stubs regenerated successfully!"
echo ""
echo "Files updated:"
echo "  - $PROTO_DIR/noteflow_pb2.py"
echo "  - $PROTO_DIR/noteflow_pb2_grpc.py"
echo "  - $PROTO_DIR/noteflow_pb2.pyi"
echo ""
echo "Next steps:"
echo "  1. Run 'cd client && npm run build:proto' to update Rust/TS stubs"
echo "  2. Run tests: pytest tests/grpc/"
echo "  3. Commit all generated files together"
```

---

### Task 8: Feature Flags

**File**: `src/noteflow/config/settings.py` (additions)

```python
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class FeatureFlags(BaseSettings):
    """Feature flag settings for gradual rollout."""

    model_config = SettingsConfigDict(env_prefix="NOTEFLOW_FEATURE_")

    # Sprint 1: AI Templates
    templates_enabled: bool = Field(
        default=True,
        description="Enable summarization template options",
    )

    # Sprint 3: PDF Export
    pdf_export_enabled: bool = Field(
        default=True,
        description="Enable PDF export format",
    )

    # Sprint 4: NER
    ner_enabled: bool = Field(
        default=False,  # Disabled by default (requires model download)
        description="Enable named entity extraction",
    )

    # Sprint 5: Calendar
    calendar_enabled: bool = Field(
        default=False,  # Disabled by default (requires OAuth setup)
        description="Enable calendar integration",
    )

    # Sprint 6: Webhooks
    webhooks_enabled: bool = Field(
        default=True,
        description="Enable webhook notifications",
    )


class Settings(BaseSettings):
    """Main application settings."""

    # ... existing fields ...

    features: FeatureFlags = Field(default_factory=FeatureFlags)


@lru_cache
def get_feature_flags() -> FeatureFlags:
    """Get cached feature flags."""
    return get_settings().features
```

---

### Task 9: Proto Changelog

**File**: `docs/sprints/phase-0-foundation/PROTO_CHANGELOG.md`

```markdown
# Proto Changelog

All notable changes to `noteflow.proto` are documented here.

## [2.0.0] - 2025-XX-XX

### Added

#### Messages
- `SummarizationOptions` - AI template preferences (tone, format, verbosity)
- `ExtractEntitiesRequest` / `ExtractEntitiesResponse` - NER extraction
- `ExtractedEntity` - Named entity with category, segments, confidence
- `CalendarEvent` - Calendar event representation
- `ListCalendarEventsRequest` / `ListCalendarEventsResponse` - Calendar listing
- `CalendarProvider` - Provider info with auth status
- `GetCalendarProvidersRequest` / `GetCalendarProvidersResponse` - Provider listing
- `InitiateCalendarAuthRequest` / `InitiateCalendarAuthResponse` - OAuth initiation
- `CompleteCalendarAuthRequest` / `CompleteCalendarAuthResponse` - OAuth completion

#### RPCs
- `ExtractEntities` - Extract named entities from meeting
- `ListCalendarEvents` - List upcoming calendar events
- `GetCalendarProviders` - Get available calendar providers
- `InitiateCalendarAuth` - Start OAuth flow
- `CompleteCalendarAuth` - Complete OAuth flow

#### Enums
- `ExportFormat.EXPORT_FORMAT_PDF` - PDF export support

### Modified

#### Messages
- `GenerateSummaryRequest` - Added optional `options` field (field 3)

### Compatibility Notes

- All new fields are optional or have defaults
- Existing clients will continue to work without changes
- New features require updated clients to access

## [1.x.x] - Previous Versions

See git history for earlier changes.
```

---

## Acceptance Criteria

### Functional

- [ ] All proto messages compile without errors
- [ ] Proto stubs regenerate cleanly
- [ ] Alembic migrations apply to fresh database
- [ ] Alembic migrations apply to existing database (upgrade path)
- [ ] Feature flags control feature availability
- [ ] Model download CLI works correctly

### Technical

- [ ] Proto backward compatible (existing clients work)
- [ ] No breaking changes to existing RPCs
- [ ] All new tables have proper indexes
- [ ] Foreign key constraints correct
- [ ] Triggers for `updated_at` in place

### Quality Gates

- [ ] `pytest tests/quality/` passes
- [ ] `ruff check src/noteflow` clean
- [ ] `mypy src/noteflow` clean
- [ ] `alembic upgrade head` succeeds on fresh DB
- [ ] `alembic downgrade -1` succeeds for each migration
- [ ] Proto regeneration produces identical output (idempotent)

---

## Test Plan

### Migration Tests

**File**: `tests/infrastructure/persistence/test_migrations.py`

```python
import pytest
from alembic import command
from alembic.config import Config


@pytest.fixture
def alembic_config(tmp_path) -> Config:
    """Create Alembic config for testing."""
    config = Config()
    config.set_main_option("script_location", "src/noteflow/infrastructure/persistence/migrations")
    config.set_main_option("sqlalchemy.url", f"sqlite:///{tmp_path}/test.db")
    return config


def test_migrations_upgrade_downgrade(alembic_config: Config) -> None:
    """All migrations can upgrade and downgrade."""
    # Upgrade to head
    command.upgrade(alembic_config, "head")

    # Downgrade each migration
    command.downgrade(alembic_config, "-1")
    command.downgrade(alembic_config, "-1")

    # Upgrade again
    command.upgrade(alembic_config, "head")
```

### Proto Tests

**File**: `tests/grpc/test_proto_compilation.py`

```python
def test_proto_imports() -> None:
    """Proto stubs import without errors."""
    from noteflow.grpc.proto import noteflow_pb2, noteflow_pb2_grpc

    # Verify new messages exist
    assert hasattr(noteflow_pb2, "SummarizationOptions")
    assert hasattr(noteflow_pb2, "ExtractEntitiesRequest")
    assert hasattr(noteflow_pb2, "CalendarEvent")

    # Verify new enum values
    assert noteflow_pb2.EXPORT_FORMAT_PDF == 3


def test_proto_message_defaults() -> None:
    """New messages have correct defaults."""
    from noteflow.grpc.proto import noteflow_pb2

    # SummarizationOptions defaults
    opts = noteflow_pb2.SummarizationOptions()
    assert opts.tone == ""
    assert opts.format == ""
    assert opts.verbosity == ""

    # ExtractedEntity defaults
    entity = noteflow_pb2.ExtractedEntity()
    assert entity.confidence == 0.0
    assert entity.is_pinned is False
```

---

## Definition of Done

- [ ] All proto changes committed in single commit
- [ ] All migrations committed and tested
- [ ] Proto regeneration script works
- [ ] Feature flags documented
- [ ] PROTO_CHANGELOG.md updated
- [ ] Client proto sync verified (`cd client && npm run build:proto`)
- [ ] Integration tests pass with new schema
- [ ] CLAUDE.md updated with new proto messages
- [ ] README updated with new optional dependencies

---

## Dependencies

- None (this is the foundation sprint)

## Blocks

- All other sprints depend on Sprint 0

## Post-Sprint

- Monitor for proto compatibility issues
- Consider proto versioning strategy for future breaking changes
- Document migration rollback procedures
