forked from bai/curriculum-project-hub
854c6189bb
Add a shared transparent OrganizationAgentConfigFolder tree for grouping agent roles and skills in the admin UI without affecting identity, bindings, run loading, or slash commands.
956 lines
38 KiB
Plaintext
956 lines
38 KiB
Plaintext
// 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[]
|
||
agentConfigFolders OrganizationAgentConfigFolder[]
|
||
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
|
||
folderId String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
disabledAt DateTime?
|
||
|
||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||
roleBindings OrganizationAgentRoleSkill[]
|
||
|
||
@@unique([organizationId, name])
|
||
@@unique([organizationId, id])
|
||
@@index([organizationId, disabledAt])
|
||
@@index([organizationId, folderId])
|
||
@@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)
|
||
folderId String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
disabledAt DateTime?
|
||
|
||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||
skillBindings OrganizationAgentRoleSkill[]
|
||
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
|
||
|
||
@@unique([organizationId, roleId])
|
||
@@unique([organizationId, id])
|
||
@@index([organizationId, disabledAt, sortOrder])
|
||
@@index([organizationId, folderId])
|
||
}
|
||
|
||
/// 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-0028: org-scoped transparent folder tree shared by agent roles and
|
||
/// skills. Management-surface navigation/grouping only — not a permission
|
||
/// resource, and never referenced by role→skill bindings, run-time skill
|
||
/// loading, or Feishu slash commands. Skill name and roleId stay unique per
|
||
/// organization regardless of folder membership. A folder is deleted only
|
||
/// when empty (service-enforced); item references are SetNull as backstop.
|
||
model OrganizationAgentConfigFolder {
|
||
id String @id @default(cuid())
|
||
organizationId String
|
||
parentId String?
|
||
name String
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||
parent OrganizationAgentConfigFolder? @relation("agentConfigFolderTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
|
||
skills OrganizationAgentSkill[]
|
||
roles OrganizationAgentRole[]
|
||
|
||
@@index([organizationId, parentId])
|
||
}
|
||
|
||
/// 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])
|
||
}
|