// Prisma schema for Curriculum Project Hub. // // Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the // legacy teaching-material-host-service schema, each deliberate: // // - AgentSession is provider/model-bound. Provider runtime cursors such as // Claude SDK `session_id` live in `metadata`, so switching model still means // a new Hub session while same-session runs can resume provider context. // - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice), // distinct from platform UserRole (admin/teacher) — legacy conflated them. // - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed // user/chat targets into one binding table. // - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy. // - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum // completeness OPEN — add states without a schema migration war). generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // --- Platform identity --------------------------------------------------- /// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one /// organization; platform operator access remains outside project roles. model Organization { id String @id @default(cuid()) slug String @unique name String status OrganizationStatus @default(ACTIVE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt memberships OrganizationMembership[] projects Project[] teams Team[] externalDirectoryConnections ExternalDirectoryConnection[] @@index([status]) } enum OrganizationStatus { ACTIVE SUSPENDED ARCHIVED } /// Org-scoped platform role. Distinct from project PermissionRole and from /// global PlatformRoleAssignment, which is reserved for SaaS/platform control. model OrganizationMembership { id String @id @default(cuid()) organizationId String userId String role OrganizationMemberRole @default(MEMBER) createdAt DateTime @default(now()) revokedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([organizationId, userId, revokedAt]) @@index([organizationId, revokedAt]) @@index([userId, revokedAt]) } enum OrganizationMemberRole { OWNER ADMIN MEMBER } /// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004). model User { id String @id @default(cuid()) feishuOpenId String @unique displayName String avatarUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt platformRoles PlatformRoleAssignment[] organizationMemberships OrganizationMembership[] createdProjects Project[] @relation("projectCreator") requestedRuns AgentRun[] @relation("runRequester") heldLocks ProjectAgentLock[] @relation("lockHolder") feishuBindings ProjectGroupBinding[] @relation("bindingCreator") teamMemberships TeamMembership[] externalPrincipalMemberships ExternalPrincipalMembership[] permissionGrants PermissionGrant[] @relation("grantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") auditEntries AuditEntry[] @relation("auditActor") } /// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole. /// `admin` is the only override path for force-release (spec RequiresAdmin). model PlatformRoleAssignment { id String @id @default(cuid()) userId String role PlatformRole createdAt DateTime @default(now()) revokedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, revokedAt]) @@index([role, revokedAt]) } enum PlatformRole { ADMIN TEACHER } /// ADR-0019: typed principals for permission grants and actor resolution. /// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external /// principals by their Feishu ids. enum PrincipalType { USER TEAM FEISHU_CHAT FEISHU_DEPARTMENT FEISHU_USER_GROUP APP } /// Hub-managed teacher team. A team is a first-class permission principal. model Team { id String @id @default(cuid()) organizationId String slug String name String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) memberships TeamMembership[] externalBindings TeamExternalBinding[] @@index([organizationId, archivedAt]) @@index([organizationId, slug, archivedAt]) } /// Direct Hub team membership. External Feishu groups can also map to teams via /// TeamExternalBinding; both sources are resolved at authorization time. model TeamMembership { id String @id @default(cuid()) teamId String userId String createdAt DateTime @default(now()) revokedAt DateTime? team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([teamId, userId, revokedAt]) @@index([teamId, revokedAt]) @@index([userId, revokedAt]) } /// Bind a Hub team to an external Feishu principal. When an actor resolves to /// the external principal, the actor also resolves to this team. model TeamExternalBinding { id String @id @default(cuid()) teamId String principalType PrincipalType principalId String createdAt DateTime @default(now()) revokedAt DateTime? team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([teamId, principalType, principalId, revokedAt]) @@index([principalType, principalId, revokedAt]) @@index([teamId, revokedAt]) } /// Locally synchronized Feishu external principal membership. model ExternalDirectoryConnection { id String @id @default(cuid()) organizationId String provider String providerTenantId String? source String status String @default("ACTIVE") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt revokedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) memberships ExternalPrincipalMembership[] @@unique([organizationId, provider, source]) @@index([organizationId, revokedAt]) @@index([provider, providerTenantId]) } /// Locally synchronized Feishu external principal membership, scoped through /// an organization-owned ExternalDirectoryConnection. model ExternalPrincipalMembership { id String @id @default(cuid()) userId String connectionId String principalType PrincipalType principalId String syncedAt DateTime @default(now()) revokedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) @@unique([userId, principalType, principalId, connectionId, revokedAt]) @@index([userId, revokedAt]) @@index([connectionId, revokedAt]) @@index([principalType, principalId, revokedAt]) } // --- Project & Feishu binding (ADR-0001) --------------------------------- model Project { id String @id @default(cuid()) organizationId String name String workspaceDir String createdByUserId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) groupBinding ProjectGroupBinding? agentSessions AgentSession[] agentRuns AgentRun[] agentLock ProjectAgentLock? roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") auditEntries AuditEntry[] @relation("projectAudit") fileChanges AgentFileChange[] @relation("projectFileChanges") @@index([organizationId, archivedAt]) @@index([archivedAt]) } /// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒ /// GroupBinding.WellFormed's injectivity half (no two projects bind one chat). /// The "at most one binding per project" half is enforced by the 1:1 relation. /// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled /// here; archival is a future policy, not a current column. model ProjectGroupBinding { id String @id @default(cuid()) projectId String @unique chatId String @unique createdByUserId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@index([chatId]) } // --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- /// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` + /// `model` capture the binding; provider-specific runtime cursors live in /// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒ /// reuse across runs (ADR-0002); a switch ⇒ new Hub session. model AgentSession { id String @id @default(cuid()) projectId String provider String roleId String model String title String? metadata Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) runs AgentRun[] messages AgentMessage[] @relation("sessionMessages") @@index([projectId, archivedAt]) @@index([provider, roleId, model]) @@index([projectId, provider, roleId, model, archivedAt]) @@index([updatedAt]) } /// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled. /// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a /// spec breach. DB mirrors the current enum. enum AgentRunStatus { ACTIVE WAITING_FOR_USER COMPLETED FAILED TIMED_OUT CANCELED } enum AgentEntrypoint { FEISHU WEB CLI } model AgentRun { id String @id @default(cuid()) projectId String sessionId String? requestedByUserId String? entrypoint AgentEntrypoint status AgentRunStatus @default(ACTIVE) prompt String model String provider String summary String? inputTokens Int? outputTokens Int? /// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact. costUsd Decimal? @db.Decimal(18, 8) /// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback. costSource String? metadata Json error String? startedAt DateTime @default(now()) finishedAt DateTime? updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull) requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull) projectLock ProjectAgentLock? messages AgentMessage[] @relation("runMessages") fileChanges AgentFileChange[] @relation("runFileChanges") @@index([projectId, status]) @@index([projectId, finishedAt]) @@index([sessionId]) @@index([requestedByUserId]) @@index([updatedAt]) } /// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒ /// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒ /// a run holds at most one lock. WellFormed (holder is non-terminal) is an /// app-level invariant checked on read/write, not a DB constraint. model ProjectAgentLock { projectId String @id runId String @unique holderUserId String? acquiredAt DateTime @default(now()) heartbeatAt DateTime? expiresAt DateTime? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade) holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull) @@index([expiresAt]) } // --- Permission grants & settings (ADR-0004) ---------------------------- /// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice). /// Distinct from PlatformRole. Force-release is admin-only, outside this /// lattice (spec RequiresAdmin). enum PermissionRole { READ EDIT MANAGE } /// ADR-0004 resource_type: project | artifact | project_group. The resource /// id's meaning is determined by its type (artifact id semantics align to the /// Courseware half; OPEN here). enum PermissionResourceType { PROJECT ARTIFACT PROJECT_GROUP } /// ADR-0004 PermissionGrant: resource × principal × role. /// ADR-0019 replaces the old opaque `principal` with typed /// `principalType/principalId` so user/team/Feishu principals compose through /// the same authorization path. model PermissionGrant { id String @id @default(cuid()) resourceType PermissionResourceType resourceId String principalType PrincipalType principalId String role PermissionRole createdByUserId String? createdAt DateTime @default(now()) revokedAt DateTime? createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@index([resourceType, resourceId, revokedAt]) @@index([principalType, principalId, revokedAt]) } /// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one /// opaque string column each. ADR-0019 pins `agentTrigger` values used by the /// authorizer: ROLE, MANAGE_ONLY, DISABLED. model PermissionSettings { id String @id @default(cuid()) resourceType PermissionResourceType resourceId String externalShare String comment String copyDownload String collaboratorMgmt String agentTrigger String agentCancel String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([resourceType, resourceId]) @@index([resourceType, resourceId]) } /// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant: /// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability, /// edit+ role); this table decides "can trigger *which* agent role" (e.g. /// /review vs /draft). Two gates in series — both must pass. /// /// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are /// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped /// row grants the role on that project; /// the compound unique covers "one active grant per (project, principal, role)". /// Revocation via `revokedAt`, same pattern as PermissionGrant. model RoleTriggerGrant { id String @id @default(cuid()) projectId String roleId String principalType PrincipalType principalId String createdByUserId String? createdAt DateTime @default(now()) revokedAt DateTime? project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade) createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@index([projectId, roleId, revokedAt]) @@index([principalType, principalId, revokedAt]) } // --- Audit (ADR Audit, content OPEN) ------------------------------------- /// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type, /// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the model AuditEntry { id String @id @default(cuid()) runId String? projectId String? actorUserId String? action String metadata Json createdAt DateTime @default(now()) actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull) project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull) @@index([runId]) @@index([projectId, createdAt]) @@index([actorUserId]) @@index([createdAt]) } // --- Feishu event dedup -------------------------------------------------- /// Idempotency receipt for inbound Feishu ws events. The lark ws client may /// redeliver an event (at-least-once); without dedup a duplicate would spawn a /// second AgentRun — the lock would refuse it, but a FAILED run row + a busy /// reply would still leak. `eventId @unique` makes the second insert a no-op /// signal: the handler checks existence before processing. model FeishuEventReceipt { id String @id @default(cuid()) eventId String @unique eventType String messageId String? receivedAt DateTime @default(now()) @@index([eventType]) @@index([messageId]) @@index([receivedAt]) } // --- Structured run history (ADR-0003 Memory) ---------------------------- /// Per-message record of the agent loop. The transcript JSONL file remains the /// agent's own read-back memory (runner loads it to seed the next run); these /// rows are the queryable/auditable projection for admin APIs. ADR-0003: /// session messages ≠ Feishu group chat history. model AgentMessage { id String @id @default(cuid()) sessionId String runId String? role String content String attachments Json createdAt DateTime @default(now()) session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade) run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull) @@index([sessionId, createdAt]) @@index([runId]) } /// File change captured during a run (writeFile/rename/delete in workspace). /// before/after hash + diff for audit and admin review. Linked to the run /// that produced it; the tool's execute closure records via a sink the runner /// injects into ToolContext (A-3 file-change capture). model AgentFileChange { id String @id @default(cuid()) runId String projectId String path String operation String beforeHash String? afterHash String? diff String? metadata Json createdAt DateTime @default(now()) run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade) project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade) @@index([runId]) @@index([projectId]) @@index([path]) }