From 93369248a93eb4b53d19609d7c3a1b67df108a6d Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Tue, 30 Dec 2025 19:11:05 +0000 Subject: [PATCH] chore: update client submodule and enhance project management documentation - Updated client submodule reference to the latest commit. - Revised project management documentation to reflect changes in project roles, settings, and rule inheritance. - Enhanced roadmap and sprint documentation with detailed objectives and deliverables for upcoming sprints. - Improved clarity on project scoping and dependencies in the context of workspace management. All quality checks pass. --- client | 2 +- docs/roadmap.md | 69 +- .../sprint-18-projects/README.md | 1378 ++++++++++++++++- .../sprint-19-artifacts-v1/README.md | 44 +- .../sprint-20-artifacts-v2/README.md | 981 +++++++++++- 5 files changed, 2399 insertions(+), 75 deletions(-) diff --git a/client b/client index 924c7e0..bb72edb 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 924c7e098299f51a23676b03092f45e5e56b4270 +Subproject commit bb72edb99fd5ecb5555a5f3099488e4c1882268a diff --git a/docs/roadmap.md b/docs/roadmap.md index 7fd36dc..39c6011 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -141,9 +141,9 @@ Sprint 18 (Projects) ─────┬───────────── | **15** | Platform Hardening | M | Phase 4 | ⚠️ Partial | Central error taxonomy, OpenTelemetry instrumentation, usage events | | **16** | Identity Foundation | L | Sprint 15 | ✅ Ready | User auth mechanism, workspace enforcement | | **17** | Custom OAuth Providers | L | Sprint 16 | ✅ Ready | OIDC discovery, Authentik/Authelia presets | -| **18** | Projects v1 | M-L | Sprint 16 | ❌ Not started | Project entity, meeting/task grouping | +| **18** | Projects v1 | L | Sprint 16 | ❌ Not started | Project entity, ProjectRole, rule inheritance, UI | | **19** | Artifacts v1 | XL | Sprint 18 | ✅ Ready | Upload + chunking + embedding pipeline | -| **20** | Artifacts v2 | XL | Sprint 19 | 🚫 Blocked | Google Drive / OneDrive connectors | +| **20** | Artifacts v2 + RAG | XL | Sprint 19 | 🚫 Blocked | External connectors, Qdrant migration, Q&A | | **21** | MCP Configuration | L | Sprint 18 | 🚫 Blocked | Scoped MCP registry (workspace defaults + project overrides) | | **22** | Rules v1 | XL | Sprint 16, 21 | 🚫 Blocked | Rules schema, auto-record, templates, outputs | | **23** | Analytics | M-L | Sprint 15, 22 | 🚫 Blocked | Conflict detection, usage/evaluations tabs | @@ -300,30 +300,46 @@ All sync infrastructure implemented and tested (validated 2025-12-29): --- ### Sprint 18: Projects v1 -**Size**: M-L | **Owner**: Backend + Client | **Prerequisites**: Sprint 16 +**Size**: L | **Owner**: Backend + Client | **Prerequisites**: Sprint 16 **Status**: ❌ NOT IMPLEMENTED -> **Objective**: Introduce a first-class container above meetings/tasks. +> **Objective**: Introduce Projects as first-class container with roles, settings, and rule inheritance. + +#### Key Decisions (2025-12-30) + +| Decision | Choice | +|----------|--------| +| Ownership | Single Workspace (projects scoped to one workspace) | +| Roles | Project-level (viewer/editor/admin separate from WorkspaceRole) | +| Rules | Merge/inherit (projects inherit workspace rules, can override) | +| Migration | Default project per workspace for unassigned meetings | #### Missing Components | Component | Required Location | |-----------|------------------| -| ProjectModel | `persistence/models/project.py` | -| Project domain entity | `domain/entities/project.py` | -| Project RPCs | proto messages needed | -| Project UI | `client/src/pages/Projects.tsx` | -| Task domain entity | Currently ORM-only, no gRPC API | +| Project entity + ProjectSettings | `domain/entities/project.py` | +| ProjectRole enum | `domain/identity/roles.py` | +| ProjectMembership entity | `domain/identity/entities.py` | +| ProjectModel + ProjectMembershipModel | `persistence/models/project.py` | +| ProjectRepository + impl | `ports/` + `repositories/` | +| ProjectService | `application/services/project_service.py` | +| Project RPCs (8 endpoints) | proto messages needed | +| Project UI (sidebar, switcher, settings) | `client/src/components/projects/` | **⚠️ Blocker**: Sprint 21 (MCP Config) and Sprint 22 (Rules) depend on project scoping. #### Deliverables -- `src/noteflow/domain/entities/project.py` -- `src/noteflow/infrastructure/persistence/models/project.py` -- Project RPCs in proto -- `client/src/pages/Projects.tsx` -- `src/noteflow/domain/identity/scope.py` — Scope lattice (workspace → project → resource) -- `src/noteflow/grpc/_interceptors/scope.py` — Enforce scope on all RPCs +- `src/noteflow/domain/entities/project.py` — Project, ProjectSettings, ExportRules, TriggerRules +- `src/noteflow/domain/identity/roles.py` — ProjectRole enum with permissions +- `src/noteflow/domain/identity/entities.py` — ProjectMembership +- `src/noteflow/infrastructure/persistence/models/project.py` — ORM models +- `src/noteflow/application/services/project_service.py` — Lifecycle + rule merging +- `src/noteflow/grpc/_mixins/project.py` — 8 RPCs (CRUD + membership) +- `client/src/components/projects/ProjectSidebar.tsx` +- `client/src/components/projects/ProjectSwitcher.tsx` +- `client/src/components/settings/ProjectSettingsPanel.tsx` +- Alembic migrations for projects, memberships, meeting.project_id --- @@ -350,11 +366,17 @@ All sync infrastructure implemented and tested (validated 2025-12-29): --- -### Sprint 20: Artifacts v2 +### Sprint 20: Artifacts v2 + RAG Migration **Size**: XL | **Owner**: Backend + Client | **Prerequisites**: Sprint 19 **Status**: 🚫 BLOCKED -> **Objective**: Sync from external sources (directories, cloud drives). +> **Objective**: External connectors + migrate from pgvector to Qdrant for RAG. + +#### Key Decision (2025-12-30) + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| RAG Backend | Qdrant | Replace pgvector for better scaling, hybrid search, collection-per-project isolation | #### Verified Assets @@ -363,12 +385,20 @@ All sync infrastructure implemented and tested (validated 2025-12-29): | StartIntegrationSync RPC | `noteflow.proto:73`, `grpc/_mixins/sync.py:47-105` | | Google OAuth | `oauth_manager.py` with PKCE | | CalendarEventModel | `models/integrations/integration.py:188-255` | +| SegmentModel.embedding | `models/core/meeting.py:202-205` (migration source) | **Blocked by**: Sprint 19 (Artifacts v1) must be implemented first. #### Deliverables - `src/noteflow/infrastructure/artifacts/sources/` — Directory, Drive, OneDrive - `ArtifactSource` domain entity with sync metadata +- `src/noteflow/domain/rag/` — Chunk, SearchResult, QAResponse, ports +- `src/noteflow/infrastructure/rag/` — Qdrant client, embedding providers, chunking, indexing +- `src/noteflow/application/services/qa_service.py` — Q&A orchestration +- `SearchProject`, `AskQuestion` RPCs +- `client/src/components/qa/QAPanel.tsx` — Q&A interface with citations +- `scripts/migrate_to_qdrant.py` — pgvector migration +- Docker compose Qdrant service with `rag` profile --- @@ -650,7 +680,12 @@ Week 7-9: Sprint 23 + Sprint 24 + Sprint 25 ─ all can parallelize |----------|--------|--------|-----------| | **Auth Model** | 16 | Local-first | Single user per install, design for multi-user. Reduces Sprint 16 from L → M. | | **OIDC Config** | 17 | Discovery only | Require issuer URL with `.well-known`. Covers 90%+ of providers. | +| **Project Ownership** | 18 | Single Workspace | Projects scoped to one workspace; simpler ACL model. | +| **Project Roles** | 18 | Project-level | Separate ProjectRole (viewer/editor/admin) from WorkspaceRole. | +| **Rule Inheritance** | 18 | Merge/inherit | Projects inherit workspace rules, can add/modify with precedence. | +| **Meeting Migration** | 18 | Default project | Each workspace gets 'General' project for unassigned meetings. | | **Artifact Scope** | 19 | Per-workspace | Simpler ACL, easier cross-project sharing. | +| **RAG Backend** | 20 | Qdrant | Replace pgvector with dedicated vector DB for scaling, hybrid search, project isolation. | | **MCP Storage** | 21 | Backend DB | Enables team sharing, consistent behavior across devices. | | **Rules Execution** | 22 | Backend-evaluated | Audit trails, single source of truth. Client handles triggers only. | | **Offline Mode** | 12 | Cached read-only | Prevents silent divergence; no mock writes. | diff --git a/docs/sprints/phase-5-evolution/sprint-18-projects/README.md b/docs/sprints/phase-5-evolution/sprint-18-projects/README.md index a404e33..3fffca4 100644 --- a/docs/sprints/phase-5-evolution/sprint-18-projects/README.md +++ b/docs/sprints/phase-5-evolution/sprint-18-projects/README.md @@ -1,21 +1,57 @@ # Sprint 18: Projects v1 -> **Size**: M-L | **Owner**: Backend + Client | **Prerequisites**: Sprint 16 +> **Size**: L | **Owner**: Backend + Client | **Prerequisites**: Sprint 16 > **Phase**: 5 - Platform Evolution --- -## Validation Status (2025-12-29) +## Open Issues & Prerequisites -### ❌ NOT IMPLEMENTED +> ✅ **Review Date**: 2025-12-30 — All blocking issues 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? | Add `SYSTEM_DEFAULTS` constant (see Rule Inheritance section). | +| G2 | `EffectiveRules` return type undefined | Add dataclass definition (see Rule Inheritance section). | +| G3 | List clearing semantics incomplete (`[]` vs `None`) | Complete merge logic with examples. | +| G4 | Slug uniqueness constraint unspecified | Add: "Unique per-workspace when set. Regex: `[a-z0-9-]+`". | +| G5 | No `RestoreProject` RPC | Add RPC or clarify archive is permanent. | +| G6 | Authorization enforcement location unspecified | Add Authorization section. | + +### Prerequisite Verification + +| Prerequisite | Status | Notes | +|--------------|--------|-------| +| Sprint 16 Identity Foundation | ✅ Complete | `WorkspaceRole`, `UserContext`, `WorkspaceContext` exist | +| `Workspace.settings` field | 🔨 **Adding in Sprint 18** | Option A selected — included in this sprint scope | +| `ExportFormat` enum | ✅ Exists | In `application/services/export_service.py` — move to domain layer | +| Active project storage | 🔨 **Adding in Sprint 18** | Via `SetActiveProject`/`GetActiveProject` RPCs | + +--- + +## Validation Status (2025-12-30) + +### NOT IMPLEMENTED | Component | Status | Notes | |-----------|--------|-------| -| ProjectModel | ❌ Not implemented | No `persistence/models/project.py` | -| Project domain entity | ❌ Not implemented | No `domain/entities/project.py` | -| Project RPCs | ❌ Not implemented | No proto messages for projects | -| Project UI | ❌ Not implemented | No `client/src/pages/Projects.tsx` | -| Task domain entity | ❌ Not implemented | Tasks are ORM-only, no gRPC API | +| ProjectModel | Not implemented | No `persistence/models/project.py` | +| Project domain entity | Not implemented | No `domain/entities/project.py` | +| ProjectRole | Not implemented | `domain/identity/roles.py` only has WorkspaceRole | +| ProjectMembership | Not implemented | No separate membership for projects | +| ProjectSettings | Not implemented | Rule inheritance not modeled | +| Project RPCs | Not implemented | No proto messages for projects | +| Project UI | Not implemented | No sidebar, switcher, or settings panel | **Downstream impact**: Sprint 21 (MCP Config) and Sprint 22 (Rules) depend on project scoping. @@ -23,7 +59,25 @@ ## Objective -Introduce Projects as a first-class container above meetings/tasks and complete the **scope lattice** (workspace → project → resource). +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 | --- @@ -31,47 +85,1313 @@ Introduce Projects as a first-class container above meetings/tasks and complete | Asset | Location | Implication | |-------|----------|-------------| -| `workspace_id` on meetings/tasks | ORM models | Hierarchy partially modeled | +| `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 | -| Task management | ORM + UI only | ⚠️ No domain entity or gRPC API; Tasks derived from ActionItems | --- ## Scope -| Task | Effort | -|------|--------| -| `ProjectModel` with membership | M | -| `project_id` on meetings/tasks | S | -| Project CRUD gRPC endpoints | M | -| Project list/detail UI | M | -| Project-scoped queries | S | -| Scope enforcement in repositories + gRPC | M | +| 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. + Other users require explicit ProjectMembership. + + Raises: + PermissionDeniedError: If user has no access to project. + """ + if workspace_role.can_admin(): + return ProjectRole.ADMIN + if membership is None: + raise PermissionDeniedError("User is not a member of this project") + return membership.role +``` + +**Authorization Notes**: + +- Workspace OWNER/ADMIN implies ProjectRole.ADMIN for all projects in that workspace. +- ProjectMembership is required for non-admin workspace members. +- 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/routes.tsx +const routes = [ + { path: "/projects", element: }, + { path: "/projects/:projectId", element: }, + { path: "/projects/:projectId/meetings", element: }, + { path: "/projects/:projectId/meetings/:meetingId", element: }, + { path: "/projects/:projectId/settings", element: }, + // Redirect legacy routes + { path: "/meetings", element: }, +]; +``` + +--- + +## UI Components + +### ProjectSidebar + +```tsx +// client/src/components/projects/ProjectSidebar.tsx + +export function ProjectSidebar() { + const { projects, activeProject, switchProject } = useProjects(); + + return ( + + ); +} +``` + +### 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 ( + + + + + + setSearch(e.target.value)} + /> + {filtered.map(project => ( + switchProject(project.id)} + > + {project.name} + {project.is_default && Default} + + ))} + + + ); +} +``` + +### ProjectSettingsPanel + +```tsx +// client/src/components/settings/ProjectSettingsPanel.tsx + +export function ProjectSettingsPanel({ projectId }: Props) { + const { project, updateProject } = useProject(projectId); + const { members, addMember, removeMember } = useProjectMembers(projectId); + + return ( +
+
+

General

+ +