forked from EduCraft/curriculum-project-hub
3f9b60f692
The spec/ Lean semantic master had no conformance gate, no codegen, and no CI tie to implementations — alignment was carried entirely by human review, the same mechanism that carries the ADRs. In practice the ADRs plus greppable code comments were already the load-bearing artifacts, so spec/ was the most expensive kind of stale documentation. - delete spec/ and the spec-check CI workflow - README: constitution rewritten around ADRs as decision truth - AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR, never rewrite ADR history; supersede instead) - code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma) - leave ADR bodies and .scratch audit snapshots untouched (history); fix live references in open readiness tickets
926 lines
37 KiB
Plaintext
926 lines
37 KiB
Plaintext
// Prisma schema for Curriculum Project Hub.
|
||
//
|
||
// Aligns to 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 (the run-state set is
|
||
// 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),
|
||
/// 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])
|
||
}
|