forked from bai/curriculum-project-hub
adce8fb6f5
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.
Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.
The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
805 lines
31 KiB
Plaintext
805 lines
31 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?
|
||
folders Folder[]
|
||
projects Project[]
|
||
teams Team[]
|
||
externalDirectoryConnections ExternalDirectoryConnection[]
|
||
providerConnections OrganizationProviderConnection[]
|
||
feishuApplicationConnection OrganizationFeishuApplicationConnection?
|
||
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)
|
||
}
|
||
|
||
/// 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")
|
||
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?
|
||
/// 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).
|
||
/// 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])
|
||
}
|