// 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[] projectSettings OrganizationProjectSettings? folders Folder[] projects Project[] teams Team[] externalDirectoryConnections ExternalDirectoryConnection[] providerConnections OrganizationProviderConnection[] feishuApplicationConnection OrganizationFeishuApplicationConnection? agentSkills OrganizationAgentSkill[] agentRoles OrganizationAgentRole[] auditEntries AuditEntry[] @relation("organizationAudit") @@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 } /// Organization-scoped, content-addressed Agent skill registration. The DB is /// the runtime registry; `contentDigest` selects an immutable directory below /// the platform-controlled skill store and is never interpreted as a path. model OrganizationAgentSkill { id String @id @default(cuid()) organizationId String name String version String description String? contentDigest String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt disabledAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) roleBindings OrganizationAgentRoleSkill[] @@unique([organizationId, name]) @@unique([organizationId, id]) @@index([organizationId, disabledAt]) @@index([contentDigest]) } /// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than /// a code enum: model, system prompt, tool allowlist and skill selection change /// without a Hub release or process restart. model OrganizationAgentRole { id String @id @default(cuid()) organizationId String roleId String label String defaultModel String? systemPrompt String? tools Json? sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt disabledAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) skillBindings OrganizationAgentRoleSkill[] @@unique([organizationId, roleId]) @@unique([organizationId, id]) @@index([organizationId, disabledAt, sortOrder]) } /// Same-Organization join enforced by both composite foreign keys. `sortOrder` /// gives stable skill listing and prompt discovery order for a role bundle. model OrganizationAgentRoleSkill { organizationId String agentRoleId String agentSkillId String sortOrder Int @default(0) createdAt DateTime @default(now()) role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade) skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade) @@id([organizationId, agentRoleId, agentSkillId]) @@index([organizationId, agentRoleId, sortOrder]) @@index([organizationId, agentSkillId]) } /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can /// create projects from unbound chats only when membersCanCreateProjects=true. model OrganizationProjectSettings { organizationId String @id membersCanCreateProjects Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) } /// A person known to the Hub. `feishuOpenId` remains a legacy compatibility /// key; new customer-app identities live in FeishuUserIdentity and store a /// connection-scoped opaque USER principal here instead of a raw open_id. 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") providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuIdentities FeishuUserIdentity[] } /// ADR-0024: exactly one customer-owned Feishu application connection may be /// configured for an Organization. External Feishu identifiers are scoped by /// this stable connection id, never treated as process-global identities. model OrganizationFeishuApplicationConnection { id String @id @default(cuid()) organizationId String @unique appIdentityFingerprint String @unique status OrganizationConnectionStatus @default(DRAFT) activeSecretVersionId String? @unique activatedAt DateTime? disabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions") activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) userIdentities FeishuUserIdentity[] @@index([status]) } /// ADR-0024 / Issue 44: provider-local Feishu identities are never global. /// The same open_id may identify different people under different customer /// applications, so all lookup and uniqueness begins with connectionId. model FeishuUserIdentity { id String @id @default(cuid()) connectionId String userId String openId String unionId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt connection OrganizationFeishuApplicationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([connectionId, openId]) @@unique([connectionId, unionId]) @@unique([connectionId, userId]) @@index([userId]) } /// ADR-0024: all Feishu app material, including provider-local app/bot ids, is /// inside one immutable authenticated envelope version. model FeishuApplicationCredentialVersion { id String @id @default(cuid()) connectionId String version Int envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion") createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@unique([connectionId, version]) @@index([connectionId, retiredAt]) @@index([keyId]) @@index([createdByUserId]) } /// ADR-0024: model-provider connection identity is stable and belongs to one /// Organization. Both BYOK and platform-managed credentials are tenant-local; /// only ACTIVE connections with an active immutable secret version resolve. model OrganizationProviderConnection { id String @id @default(cuid()) organizationId String providerId String mode ProviderCredentialMode status OrganizationConnectionStatus @default(DRAFT) activeSecretVersionId String? @unique activatedAt DateTime? disabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) secretVersions ProviderCredentialVersion[] @relation("providerCredentialVersions") activeSecretVersion ProviderCredentialVersion? @relation("activeProviderCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) @@unique([organizationId, providerId]) @@index([organizationId, status]) @@index([providerId, status]) } enum ProviderCredentialMode { BYOK PLATFORM_MANAGED } enum OrganizationConnectionStatus { DRAFT ACTIVE DISABLED } /// ADR-0024: write-only provider secret material is one authenticated envelope /// per immutable version. keyId/envelopeVersion are redacted rotation metadata; /// all provider URL/token/API-key fields remain inside envelope ciphertext. model ProviderCredentialVersion { id String @id @default(cuid()) connectionId String version Int envelopeVersion Int @default(1) keyId String envelope Json createdByUserId String? createdAt DateTime @default(now()) retiredAt DateTime? connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) activeFor OrganizationProviderConnection? @relation("activeProviderCredentialVersion") createdBy User? @relation("providerCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@unique([connectionId, version]) @@index([connectionId, retiredAt]) @@index([keyId]) @@index([createdByUserId]) } /// 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 and Feishu external principals use connection-scoped opaque ids, never /// raw provider-local ids; TEAM uses Hub Team.id. 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) --------------------------------- /// ADR-0021: transparent project explorer folder. Folders are org-scoped /// navigation/aggregation nodes, not permission resources; project grants stay /// attached to PROJECT resources. model Folder { id String @id @default(cuid()) organizationId String parentId String? name String sortKey String @default("") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict) children Folder[] @relation("folderTree") projects Project[] @@index([organizationId, parentId, archivedAt]) @@index([organizationId, parentId, sortKey]) } model Project { id String @id @default(cuid()) organizationId String folderId 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) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) groupBindings ProjectGroupBinding[] agentSessions AgentSession[] agentRuns AgentRun[] agentLock ProjectAgentLock? roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") auditEntries AuditEntry[] @relation("projectAudit") fileChanges AgentFileChange[] @relation("projectFileChanges") @@index([organizationId, archivedAt]) @@index([folderId, archivedAt]) @@index([archivedAt]) } /// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat /// (1:1). Historical archived bindings are retained for audit; partial unique /// indexes in migrations enforce one active binding per project and per chat. model ProjectGroupBinding { id String @id @default(cuid()) projectId String chatId String createdByUserId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@index([projectId, archivedAt]) @@index([chatId, archivedAt]) } // --- 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? organizationId 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) organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull) @@index([runId]) @@index([projectId, createdAt]) @@index([organizationId, 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]) }