Files
ChickenPige0n ab9dfad53a feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).

Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.

Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
2026-07-14 21:23:41 +08:00

820 lines
32 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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?
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status])
}
enum OrganizationStatus {
ACTIVE
SUSPENDED
ARCHIVED
}
/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
id String @id @default(cuid())
organizationId String
userId String
role OrganizationMemberRole @default(MEMBER)
createdAt DateTime @default(now())
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId, revokedAt])
@@index([organizationId, revokedAt])
@@index([userId, revokedAt])
}
enum OrganizationMemberRole {
OWNER
ADMIN
MEMBER
}
/// Organization-scoped, content-addressed Agent skill registration. The DB is
/// the runtime registry; `contentDigest` selects an immutable directory below
/// the platform-controlled skill store and is never interpreted as a path.
model OrganizationAgentSkill {
id String @id @default(cuid())
organizationId String
name String
version String
description String?
contentDigest String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([contentDigest])
}
/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than
/// a code enum: model, system prompt, tool allowlist and skill selection change
/// without a Hub release or process restart.
model OrganizationAgentRole {
id String @id @default(cuid())
organizationId String
roleId String
label String
defaultModel String?
systemPrompt String?
tools Json?
sortOrder Int @default(0)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
/// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill {
organizationId String
agentRoleId String
agentSkillId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)
@@id([organizationId, agentRoleId, agentSkillId])
@@index([organizationId, agentRoleId, sortOrder])
@@index([organizationId, agentSkillId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
organizationId String @id
membersCanCreateProjects Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// ADR-0022 layered capacity policy. Org admins may set per-dimension lower
/// limits; `limits` is a JSON map of CapacityDimension → number (only the set
/// ones). Each set value must be ≤ the platform ceiling for that dimension
/// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry
/// fall back to the platform ceiling (`LayeredLimit.effective`).
model OrganizationCapacityPolicy {
organizationId String @id
limits Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// A person known to the Hub. `feishuOpenId` remains a legacy compatibility
/// key; new customer-app identities live in FeishuUserIdentity and store a
/// connection-scoped opaque USER principal here instead of a raw open_id.
model User {
id String @id @default(cuid())
feishuOpenId String @unique
displayName String
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
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])
}