// 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? capacityPolicy OrganizationCapacityPolicy? folders Folder[] projects Project[] teams Team[] externalDirectoryConnections ExternalDirectoryConnection[] providerConnections OrganizationProviderConnection[] feishuApplicationConnection OrganizationFeishuApplicationConnection? capabilityConnections OrganizationCapabilityConnection[] agentSkills OrganizationAgentSkill[] agentRoles OrganizationAgentRole[] projectGroupBindings ProjectGroupBinding[] auditEntries AuditEntry[] @relation("organizationAudit") projectSearchDocuments ProjectSearchDocument[] @@index([status]) } enum OrganizationStatus { ACTIVE SUSPENDED ARCHIVED } /// Org-scoped membership role. Distinct from project PermissionRole and from /// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration), /// which is a separate control plane not modeled in alpha (ADR-0025). 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) isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt disabledAt DateTime? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) skillBindings OrganizationAgentRoleSkill[] selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") @@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) } /// ADR-0022 layered capacity policy. Org admins may set per-dimension lower /// limits; `limits` is a JSON map of CapacityDimension → number (only the set /// ones). Each set value must be ≤ the platform ceiling for that dimension /// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry /// fall back to the platform ceiling (`LayeredLimit.effective`). model OrganizationCapacityPolicy { organizationId String @id limits Json 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 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") capabilityCredentialVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersionCreator") 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]) } /// 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. enum FolderKind { REGULAR SYSTEM_INBOX } model Folder { id String @id @default(cuid()) organizationId String parentId String? name String kind FolderKind @default(REGULAR) 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? code 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") searchDocument ProjectSearchDocument? @@unique([organizationId, id]) @@index([organizationId, archivedAt]) @@index([folderId, archivedAt]) @@index([archivedAt]) } /// Derived, rebuildable search projection for project discovery. PostgreSQL /// triggers keep it synchronized with Project and Folder mutations; Project /// remains the source of truth and authorization remains outside this table. model ProjectSearchDocument { projectId String @id organizationId String name String code String? normalizedCode String normalizedName String breadcrumb String normalizedBreadcrumb String normalizedSearchText String updatedAt DateTime @updatedAt project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@index([organizationId]) } /// 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()) organizationId String projectId String chatId String createdByUserId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? selectedAgentRoleId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) project Project @relation(fields: [organizationId, projectId], references: [organizationId, id], onDelete: Cascade) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict) @@index([organizationId]) @@index([projectId, archivedAt]) @@index([chatId, archivedAt]) @@index([selectedAgentRoleId]) } // --- 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? /// ADR-0026: derived rollup cache of UsageFact rows for this run. The truth /// is in UsageFact; this column is convenience for existing readers. Null /// means this run has no trusted cost fact (ADR-0022: missing cost ≠ zero). costUsd Decimal? @db.Decimal(18, 8) /// ADR-0026: semantic source of the rolled-up costUsd (provider_reported | /// pricebook_derived | unknown). Mirrors the dominant CostSource of the /// run's facts; 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") usageFacts UsageFact[] @relation("runUsageFacts") @@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). /// Force-release is platform-admin-only, outside this lattice (spec /// RequiresAdmin); platform admin is a separate control plane (ADR-0023), /// not modeled in alpha (ADR-0025). 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]) } // --- Usage fact ledger (ADR-0026) ---------------------------------------- /// ADR-0026: one billable consumption event inside an AgentRun. Append-only; /// the run's AgentRun.costUsd/inputTokens/outputTokens are a derived rollup /// of these rows, not the truth. kind=external_capability carries capabilityId /// for media transforms (PDF→MD, audio/video→text, …). costUsd=null means /// unknown, not zero (ADR-0022). The kind set is OPEN: new kinds must be /// surfaced, not silently folded in. model UsageFact { id String @id @default(cuid()) runId String /// When the consumption happened. Pricebook derivation uses this, not /// AgentRun.finishedAt, because an external capability may finish before /// the run ends. occurredAt DateTime /// model_completion | external_capability | tool_proxy. OPEN set. kind String /// e.g. openrouter, mineru, openai_whisper. provider String model String? inputTokens Int? outputTokens Int? /// Non-token meter (pages, audio_seconds, invocations). Coexists with tokens. quantity Decimal? @db.Decimal(18, 6) unit String? /// USD. Null = unknown, NOT zero (ADR-0022). When null, costSource=unknown. costUsd Decimal? @db.Decimal(18, 8) /// provider_reported | pricebook_derived | unknown. Required even when /// costUsd is null, so a reader can distinguish "reported zero" from /// "no report at all". costSource String /// For kind=external_capability, the registered capability id /// (e.g. pdf_to_md_bundle, audio_video_to_text). Null for model_completion. capabilityId String? /// External request id for reconciliation / idempotency. Not an aggregation key. correlationId String? metadata Json createdAt DateTime @default(now()) run AgentRun @relation("runUsageFacts", fields: [runId], references: [id], onDelete: Cascade) @@index([runId, occurredAt]) @@index([runId, kind]) @@index([provider, model, occurredAt]) @@index([capabilityId, occurredAt]) } // --- External capability connections (ADR-0027) ------------------------- /// ADR-0027: org-scoped credential connection for an external capability /// (PDF→MD, audio/video→text, …). Structurally mirrors the Feishu Application /// Connection: reuses the ADR-0024 envelope (KEK→DEK→AES-256-GCM, AAD-bound) /// but has its own payload schema and readiness probe, distinct from the /// model-provider connection. Unique by (organizationId, capabilityId). model OrganizationCapabilityConnection { id String @id @default(cuid()) organizationId String capabilityId String 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 CapabilityCredentialVersion[] @relation("capabilityCredentialVersions") activeSecretVersion CapabilityCredentialVersion? @relation("activeCapabilityCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict) @@unique([organizationId, capabilityId]) @@index([organizationId, status]) @@index([capabilityId, status]) } /// ADR-0024/0027: one immutable authenticated envelope per capability secret /// version. Same encryption machinery as Provider/Feishu credential versions; /// the payload inside is CapabilitySecretPayloadV1 (baseUrl, apiToken, /// optional projectId). model CapabilityCredentialVersion { 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 OrganizationCapabilityConnection @relation("capabilityCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade) activeFor OrganizationCapabilityConnection? @relation("activeCapabilityCredentialVersion") createdBy User? @relation("capabilityCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) @@unique([connectionId, version]) @@index([connectionId, retiredAt]) @@index([keyId]) @@index([createdByUserId]) }