<!-- Gerado por `npm run docs:gen` (backend/scripts/docs-gen.ts) a partir de `backend/prisma/schema.prisma`. Não edite à mão: `npm run docs:check` falha se este arquivo divergir do código. -->

# Modelo de dados

Tabelas do Postgres, na ordem do `schema.prisma`. A fonte da verdade são o schema e as
migrations em `backend/prisma/migrations/` (regras DB-01 e DB-02 do guia de padrões).
Objetos que o Prisma não modela (ltree, triggers, colunas `search_tsv`, índices GIN/trgm)
estão nas migrations e aparecem aqui como `Unsupported(...)` ou nos comentários.

44 modelos e 24 enums.

## Relações

Cada linha liga o dono da chave estrangeira (à direita) ao modelo referenciado.

```mermaid
erDiagram
  User |o--o{ AgentKey : createdBy
  Organization |o--o{ AgentKey : organization
  User ||--o{ Membership : user
  Organization ||--o{ Membership : organization
  User ||--o{ UserSession : user
  Organization ||--o{ UserSession : organization
  User |o--o{ Project : owner
  Organization ||--o{ Project : organization
  Project ||--o{ Knowledge : project
  Knowledge |o--o{ Knowledge : parent
  Task |o--o{ Knowledge : demand
  Knowledge ||--o{ KnowledgeVersion : knowledge
  Knowledge ||--o{ KnowledgeComment : knowledge
  KnowledgeComment |o--o{ KnowledgeComment : parentComment
  Knowledge ||--o{ KnowledgeTicketLink : knowledge
  Task ||--o{ KnowledgeTicketLink : task
  Project ||--o{ Memory : project
  Memory |o--o{ Memory : supersededBy
  Memory ||--o{ MemoryVersion : memory
  Project ||--o{ AgentPrompt : project
  AgentPrompt ||--o{ AgentPromptVersion : prompt
  Task ||--o{ AgentPromptTaskLink : task
  AgentPrompt ||--o{ AgentPromptTaskLink : prompt
  Project ||--o{ Task : project
  Task ||--o{ TaskDependency : task
  Task ||--o{ TaskDependency : dependsOn
  Task ||--o{ TaskDecision : task
  TaskDecision |o--o{ TaskDecision : supersededBy
  TaskDecision ||--o{ TaskDecisionOption : decision
  TaskDecision ||--o{ TaskDecisionResponse : decision
  TaskDecisionOption |o--o{ TaskDecisionResponse : option
  Task ||--o{ TaskEvent : task
  Task ||--o{ MemoryTaskLink : task
  Memory ||--o{ MemoryTaskLink : memory
  Task ||--o{ TaskSkillLink : task
  Skill ||--o{ TaskSkillLink : skill
  Task ||--o{ TaskAttachment : task
  TaskDecision |o--o{ TaskAttachment : decision
  Task ||--o{ Todo : task
  SkillFolder |o--o{ Skill : folder
  Skill ||--o{ SkillVersion : skill
  SkillFolder |o--o{ SkillFolder : parent
  Project ||--o{ ProjectSkill : project
  Skill ||--o{ ProjectSkill : skill
  Organization |o--o{ McpServer : organization
  McpServer ||--o{ McpTool : mcpServer
  Project ||--o{ ProjectMcp : project
  McpServer ||--o{ ProjectMcp : mcpServer
  Project |o--o{ RenderDesign : project
  RenderDesign ||--o{ RenderScreen : design
  Project |o--o{ RenderLibrary : project
  Project |o--o{ RenderDesignSystem : project
  RenderDesignSystem ||--o{ RenderDesignSystemVersion : designSystem
  Project ||--o{ RenderPort : project
  RenderDesignSystem |o--o{ RenderPort : designSystem
  RenderDesign ||--o{ RenderRevision : design
  RenderDesign |o--o{ RenderAsset : design
```

## Enums

| Enum | Valores | Descrição |
| --- | --- | --- |
| `UserRole` | `admin`, `member` | — |
| `AgentKeyKind` | `project`, `admin` | — |
| `OrgRole` | `owner`, `admin`, `member` | — |
| `KnowledgeCategory` | `page`, `story`, `docs`, `landing`, `reference` | — |
| `KnowledgeScope` | `general`, `demand` | — |
| `KnowledgeFormat` | `html`, `markdown` | Formato do `content`: html (renderiza no iframe sandbox com Tailwind) ou markdown (renderiza com react-markdown + tema do app). |
| `KnowledgeTicketRelation` | `reference`, `attachment`, `solution` | — |
| `MemoryCategory` | `decision`, `preference`, `rule`, `learned`, `context`, `note` | — |
| `TaskStatus` | `awaiting_human_decision`, `awaiting_more_information`, `todo`, `in_progress`, `done`, `blocked`, `cancelled` | — |
| `TaskDecisionStatus` | `pending`, `partially_answered`, `answered`, `superseded`, `cancelled` | — |
| `TaskEventType` | `decision_requested`, `decision_answered`, `more_information_requested`, `more_information_added`, `status_changed`, `attachment_added`, `attachment_removed`, `created`, `updated`, `memory_linked`, `memory_unlinked`, `skill_linked`, `skill_unlinked`, `prompt_linked`, `prompt_unlinked`, `knowledge_linked`, `knowledge_unlinked`, `decision_cancelled`, `decision_superseded`, `dependency_added`, `dependency_removed`, `claimed`, `released` | Tipos do histórico da task (`services/task-events.ts` descreve o payload de cada um). |
| `MemoryTaskRelation` | `context`, `decision`, `constraint`, `outcome` | — |
| `TaskSkillRelation` | `execution`, `reference`, `validation` | Vínculo Task↔Skill. Espelha MemoryTaskRelation: o tipo diz *por que* a skill está na demanda, não o que ela é. execution — skill obrigatória para executar a Task (ler antes de agir) reference — apoio/consulta, opcional validation — checklist usado para revisar o resultado |
| `AgentPromptTaskRelation` | `execution`, `reference`, `validation` | Vínculo Task↔Prompt de agente. Espelha TaskSkillRelation: o tipo diz *por que* o prompt está na demanda, não o que ele é. execution — prompt obrigatório para executar a Task (ler antes de agir) reference — apoio/consulta, opcional validation — checklist usado para revisar o resultado |
| `TaskPriority` | `low`, `medium`, `high`, `urgent` | — |
| `DecisionSelectionType` | `single`, `multiple` | single: exatamente uma opção; multiple: checkbox, entre minSelections e maxSelections opções. |
| `ActorType` | `human`, `agent`, `system`, `anonymous` | Quem fez a ação registrada em TaskDecisionResponse/TaskEvent: human = usuário autenticado (a resposta a decisão só é gravada assim, regra BE-19); agent = agente/chave de agente; system = o próprio servidor; anonymous = requisição sem principal (AUTH_ENFORCED=false), gravado em TaskEvent pelo task-events da tasks-hitl (TSK-06, EventActorType). |
| `AuditSource` | `rest`, `mcp`, `system` | Channel a write came through (audit trail SEC-10, MemoryVersion): rest = /api, mcp = tools/call on /mcp, system = the server itself (jobs, seeds, migrations). |
| `SkillSource` | `local`, `community`, `imported`, `builtin` | `builtin` = skill que o seed do repositório mantém (prisma/seed-skills): só leitura pela API e pelo MCP; para customizar, duplica-se numa skill `local` (NEW-skills-2, achado SK-09). |
| `SkillWriteOrigin` | `baseline`, `create`, `update`, `files`, `import`, `seed`, `restore`, `duplicate` | De onde veio a escrita que gerou uma SkillVersion (NEW-skills-2, SK-08). `baseline` é o estado anterior de uma skill que já existia antes do histórico, gravado na primeira escrita depois dele. |
| `McpTransport` | `http`, `sse`, `streamable_http` | — |
| `RenderTheme` | `light`, `dark` | — |
| `RenderLibraryKind` | `ui`, `tokens`, `manifest` | Biblioteca do catálogo: ui (componentes + estilos), tokens (fiscaliza.tokens/v1) e manifest (mapa do projeto). |
| `RenderPortSourceKind` | `design`, `library`, `design_system` | O que um RenderPort copiou: uma tela legada global (design), uma biblioteca inteira ou uma seleção dela (library) ou um design system aplicado (design_system). |

## Modelos

### User (`users`)

Pessoa. Desde a AP-SSO-01 o login é só pela Conta Nommand (OIDC): `externalId` é o `sub` da Conta (JIT a cada login, refresh e webhook) e o usuário antigo casa pelo e-mail na primeira entrada. `passwordHash` ficou só para as linhas antigas (nulo nas novas; nenhuma rota lê senha).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `externalId` | `String?` | `@unique @map("external_id")` | `sub` da Conta Nommand (AP-SSO-01). |
| `email` | `String` | `@unique @db.Citext` | Always normalizeEmail() (trim + lowercase) on the way in; citext keeps it unique regardless of case even if a path forgets (AUTH-EMAIL-01). |
| `name` | `String` |  |  |
| `picture` | `String?` |  | URL pública da foto (claim `picture` da Conta); nula = iniciais. |
| `passwordHash` | `String?` | `@map("password_hash")` |  |
| `role` | `UserRole` | `@default(member)` | `admin` = claim `platform_admin` da Conta (admin de plataforma). |
| `isActive` | `Boolean` | `@default(true) @map("is_active")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `ownedProjects` | `Project[]` | `@relation("ProjectOwner")` |  |
| `agentKeys` | `AgentKey[]` | `@relation("AgentKeyCreator")` |  |
| `memberships` | `Membership[]` |  |  |
| `sessions` | `UserSession[]` |  |  |

### AgentKey (`agent_keys`)

Per-agent API key (issued, verified and revoked by the agent-key service, SEC-05). The secret is shown once at creation and never stored: `hash` is its SHA-256 and `prefix` is the public, non-secret part used to find the key and to show it in the UI. `projectIds`, `scopes` and `toolsets` are allowlists interpreted by that service; `projectIds` holds plain ids (no FK), so a deleted project can still be listed there and must be ignored when read. A key is usable while `revokedAt` is NULL and `expiresAt` is NULL or in the future. `kind` (AK-ADMIN): `project` (padrão, toda chave antiga) é restrita aos `projectIds`. `admin` é a chave de administração da org: não guarda projetos (`projectIds: []`, CHECK `agent_keys_admin_shape`) e, a cada requisição, alcança todos os projetos da org dela, cria projeto nela e usa os toolsets `*-admin` que o cliente ligar. A chave é DA ORGANIZAÇÃO, não de quem a emitiu (sair da org, perder o papel, ser desativado ou apagado não a derrubam). CHECK agent_keys_admin_shape (migration 20261011120000_agent_key_kind) keeps an admin key bound to an organization and without projects.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `name` | `String` |  |  |
| `prefix` | `String` | `@unique` |  |
| `hash` | `String` | `@unique` |  |
| `kind` | `AgentKeyKind` | `@default(project)` |  |
| `projectIds` | `String[]` | `@default([]) @map("project_ids")` |  |
| `scopes` | `String[]` | `@default([])` |  |
| `toolsets` | `String[]` | `@default([])` |  |
| `expiresAt` | `DateTime?` | `@map("expires_at")` |  |
| `lastUsedAt` | `DateTime?` | `@map("last_used_at")` |  |
| `revokedAt` | `DateTime?` | `@map("revoked_at")` |  |
| `createdById` | `String?` | `@map("created_by_id")` |  |
| `organizationId` | `String?` | `@map("organization_id")` | Org da chave (AP-ORG-01): os projectIds só valem dentro dela. Nulo nas chaves antigas até a migração, que preenche com a org dos projetos. |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `createdBy` | `User?` | `@relation("AgentKeyCreator", fields: [createdById], references: [id], onDelete: SetNull)` |  |
| `organization` | `Organization?` | `@relation(fields: [organizationId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@index([createdById])`, `@@index([organizationId])`.

### Organization (`organizations`)

Empresa (tenant). Todo projeto pertence a uma organização e tudo que pende de projeto herda o isolamento (middleware/actor: os projectIds do principal são os projetos da org ativa). `externalOrgId` é o `org_id` da Conta Nommand (login pela Conta, AP-SSO-01); nulo nas orgs locais. `isDefault` marca a org de quem não tem vínculo (token de bootstrap, migração: "Nomad").

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `externalOrgId` | `String?` | `@unique @map("external_org_id")` |  |
| `slug` | `String` | `@unique` |  |
| `name` | `String` |  |  |
| `isDefault` | `Boolean` | `@default(false) @map("is_default")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `memberships` | `Membership[]` |  |  |
| `projects` | `Project[]` |  |  |
| `agentKeys` | `AgentKey[]` |  |  |
| `sessions` | `UserSession[]` |  |  |
| `mcpServers` | `McpServer[]` |  |  |

### Membership (`memberships`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `userId` | `String` | `@map("user_id")` |  |
| `organizationId` | `String` | `@map("organization_id")` |  |
| `role` | `OrgRole` | `@default(member)` | Papel local, derivado da Conta (AP-SSO-01): `org_role` owner → owner; senão `apps["agent-package"]` (admin → admin, o resto → member). |
| `externalOrgRole` | `OrgRole?` | `@map("external_org_role")` | O que a Conta disse por último (`org_role` e `apps["agent-package"]`), guardado para exibição e para recalcular `role` no webhook. |
| `externalAppRole` | `String?` | `@map("external_app_role")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `user` | `User` | `@relation(fields: [userId], references: [id], onDelete: Cascade)` |  |
| `organization` | `Organization` | `@relation(fields: [organizationId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([userId, organizationId])`, `@@index([organizationId])`.

### UserSession (`user_sessions`)

Sessão do navegador depois do login pela Conta Nommand. O cookie httpOnly `agentpack_session` leva um segredo aleatório; aqui fica só o SHA-256 dele (`tokenHash`). `sid`/`sub`/`externalOrgId` são os da Conta, para o back-channel e o webhook derrubarem a sessão. `tokens` é o JSON cifrado (AES-256-GCM, services/secrets.service) com id_token, access e refresh token: nunca vão ao navegador. `refreshedAt` controla o refresh (≤10 min) e `refreshLockedUntil` garante um refresh por vez (o refresh token é de uso único: reapresentá-lo derruba o login inteiro na Conta).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `tokenHash` | `String` | `@unique @map("token_hash")` |  |
| `userId` | `String` | `@map("user_id")` |  |
| `organizationId` | `String` | `@map("organization_id")` |  |
| `sid` | `String?` |  |  |
| `sub` | `String` |  |  |
| `externalOrgId` | `String` | `@map("external_org_id")` |  |
| `tokens` | `String` |  |  |
| `accessExpiresAt` | `DateTime` | `@map("access_expires_at")` |  |
| `refreshedAt` | `DateTime` | `@default(now()) @map("refreshed_at")` |  |
| `refreshLockedUntil` | `DateTime?` | `@map("refresh_locked_until")` |  |
| `expiresAt` | `DateTime` | `@map("expires_at")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `user` | `User` | `@relation(fields: [userId], references: [id], onDelete: Cascade)` |  |
| `organization` | `Organization` | `@relation(fields: [organizationId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@index([sid])`, `@@index([sub, externalOrgId])`, `@@index([userId])`, `@@index([organizationId])`.

### OidcProvisioningEvent (`oidc_provisioning_events`)

Webhook de provisionamento da Conta já aplicado (idempotência por eventId).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `eventId` | `String` | `@id @map("event_id")` |  |
| `type` | `String` |  |  |
| `receivedAt` | `DateTime` | `@default(now()) @map("received_at")` |  |

### Project (`projects`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `ownerId` | `String?` | `@map("owner_id")` |  |
| `organizationId` | `String` | `@default(dbgenerated("ensure_default_organization()")) @map("organization_id")` | Sempre a org da sessão no código (project.service). O default do banco (função ensure_default_organization) só cobre insert direto: seed, script e teste caem na org padrão "Nomad". |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `deletedAt` | `DateTime?` | `@map("deleted_at")` | Project trash (DAT-07): deleting a project sets deletedAt instead of removing the rows; a purge job hard-deletes (and removes the files of) projects in the trash for longer than the retention window. Every row soft-deleted by the same operation carries the same deletedBatchId, so a restore brings back exactly what that delete hid. Set together with deletedAt (CHECK projects_trash_consistent, migration 20260925200000_w2_support). |
| `deletedBatchId` | `String?` | `@map("deleted_batch_id")` |  |
| `roadmapZoom` | `String` | `@default("week") @map("roadmap_zoom")` | Preferências da visão de Roadmap do projeto (tela /roadmap/config). Dias úteis: lista CSV "1,2,3,4,5" (1 = segunda ... 7 = domingo). CHECK projects_roadmap_prefs (migration 20261013090000_task_schedule); a validação com 422 fica no UpdateProjectSchema (lib/schemas.ts). |
| `roadmapGroup` | `String` | `@default("status") @map("roadmap_group")` |  |
| `roadmapWorkingDays` | `String` | `@default("1,2,3,4,5") @map("roadmap_working_days")` |  |
| `roadmapWeekStart` | `Int` | `@default(1) @map("roadmap_week_start")` |  |
| `owner` | `User?` | `@relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)` |  |
| `organization` | `Organization` | `@relation(fields: [organizationId], references: [id], onDelete: Restrict)` |  |
| `memories` | `Memory[]` |  |  |
| `tasks` | `Task[]` |  |  |
| `renderDesigns` | `RenderDesign[]` |  |  |
| `renderLibraries` | `RenderLibrary[]` |  |  |
| `renderDesignSystems` | `RenderDesignSystem[]` |  |  |
| `renderPorts` | `RenderPort[]` |  |  |
| `knowledges` | `Knowledge[]` |  |  |
| `projectSkills` | `ProjectSkill[]` |  |  |
| `agentPrompts` | `AgentPrompt[]` |  |  |
| `projectMcps` | `ProjectMcp[]` |  |  |

Restrições e índices: `@@index([ownerId])`, `@@index([organizationId])`, `@@index([createdAt])`, `@@index([updatedAt])`, `@@index([deletedAt])`.

### Knowledge (`knowledges`)

Páginas HTML autocontidas (com Tailwind via CDN) — usadas para landing pages, storytelling, timelines, galerias, etc. O conteúdo é renderizado num iframe sandbox pra isolar CSS/JS. ADR-001 (NEW-001): - parentId: self-FK opcional (hierarquia Confluence-style). - path: ltree materialized (SQL trigger mantém sincronizado). - version: int incremental. O knowledgeService.update() grava o estado anterior em KnowledgeVersion e incrementa na mesma transação quando título, resumo, conteúdo ou formato mudam. - scope: 'general' (cross-project/sistema) ou 'demand' (vinculado a Task). - demandId: FK opcional para Task quando scope='demand'. - isDeleted: soft-delete (preserva auditoria). - publishedAt: NULL = draft; timestamp = published.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `parentId` | `String?` | `@map("parent_id")` |  |
| `path` | `Unsupported("ltree")?` | `@map("path")` | Materialized ltree path (root label . child label ...), written only by the knowledge_path_sync trigger (function knowledge_path_update). Prisma has no ltree type: read it with raw SQL (nlevel, &lt;@, @&gt;). |
| `title` | `String` |  |  |
| `summary` | `String` | `@default("")` |  |
| `content` | `String` | `@default("")` |  |
| `format` | `KnowledgeFormat` | `@default(html)` | Default html por retrocompat. A IA (MCP) escolhe o formato ao criar/atualizar. |
| `tags` | `String` | `@default("[]")` |  |
| `category` | `KnowledgeCategory` | `@default(page)` |  |
| `scope` | `KnowledgeScope` | `@default(general)` |  |
| `demandId` | `String?` | `@map("demand_id")` |  |
| `version` | `Int` | `@default(1)` |  |
| `order` | `Int` | `@default(0) @map("sort_order")` | Ordem manual entre irmãos (mesmo parentId). A IA cria com append automático (maior ordem do grupo + 1); o usuário reordena arrastando. |
| `isDeleted` | `Boolean` | `@default(false) @map("is_deleted")` |  |
| `deletedBatchId` | `String?` | `@map("deleted_batch_id")` | Trash batch (DAT-03): deleting a page sends it and its active subtree to the trash under one id; restoring any page of the batch restores all of it. Set together with isDeleted (CHECK knowledges_trash_consistent). |
| `deletedAt` | `DateTime?` | `@map("deleted_at")` |  |
| `publishedAt` | `DateTime?` | `@map("published_at")` |  |
| `createdBy` | `String?` | `@map("created_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `searchTsv` | `Unsupported("tsvector")?` | `@default(dbgenerated("((setweight(to_tsvector('portuguese'::regconfig, COALESCE(title, ''::text)), 'A'::\"char\") \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(summary, ''::text)), 'B'::\"char\")) \|\| setweight(to_tsvector('portuguese'::regconfig, regexp_replace(COALESCE(content, ''::text), '&lt;[^&gt;]*&gt;'::text, ' '::text, 'gi'::text)), 'C'::\"char\"))")) @map("search_tsv")` | Full-text vector GENERATED ALWAYS AS (title A, summary B, content without HTML C; 'portuguese') STORED — defined in the migration, read-only here. |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `parent` | `Knowledge?` | `@relation("KnowledgeParent", fields: [parentId], references: [id], onDelete: SetNull)` |  |
| `children` | `Knowledge[]` | `@relation("KnowledgeParent")` |  |
| `demand` | `Task?` | `@relation(fields: [demandId], references: [id], onDelete: SetNull)` |  |
| `versions` | `KnowledgeVersion[]` |  |  |
| `comments` | `KnowledgeComment[]` |  |  |
| `ticketLinks` | `KnowledgeTicketLink[]` |  |  |

Restrições e índices: `@@index([createdAt])`, `@@index([parentId, order])`, `@@index([demandId])`, `@@index([projectId, isDeleted, createdAt])`, `@@index([deletedBatchId])`, `@@index([searchTsv], map: "knowledges_search_tsv_gin", type: Gin)`, `@@index([title(ops: raw("gin_trgm_ops"))], map: "knowledges_title_trgm", type: Gin)`, `@@index([summary(ops: raw("gin_trgm_ops"))], map: "knowledges_summary_trgm", type: Gin)`, `@@index([path], map: "knowledges_path_gist", type: Gist)`.

### KnowledgeVersion (`knowledge_versions`)

Histórico append-only dos estados SUBSTITUÍDOS de uma página: a linha `version = N` guarda o estado que a página tinha na versão N, gravada pelo knowledgeService.update() no save que a leva para N + 1. A versão atual fica só em `knowledges` (toda linha aqui tem version < knowledges.version). Nunca UPDATE/DELETE aqui.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `knowledgeId` | `String` | `@map("knowledge_id")` |  |
| `version` | `Int` |  |  |
| `title` | `String` |  |  |
| `summary` | `String` | `@default("")` |  |
| `content` | `String` |  |  |
| `format` | `KnowledgeFormat` | `@default(html)` | Formato do `content` nessa versão: o restore devolve o conteúdo com o renderer certo mesmo se a página mudou de formato. |
| `createdBy` | `String?` | `@map("created_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` | Quando esse estado foi gravado (o updatedAt da página na versão N). |
| `knowledge` | `Knowledge` | `@relation(fields: [knowledgeId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([knowledgeId, version])`, `@@index([createdAt])`.

### KnowledgeComment (`knowledge_comments`)

Comentários em thread dentro de uma knowledge page (estilo Notion). NÃO estão na hierarquia de pages; são comments escopados por page.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `knowledgeId` | `String` | `@map("knowledge_id")` |  |
| `parentCommentId` | `String?` | `@map("parent_comment_id")` |  |
| `authorId` | `String?` | `@map("author_id")` |  |
| `body` | `String` |  |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `knowledge` | `Knowledge` | `@relation(fields: [knowledgeId], references: [id], onDelete: Cascade)` |  |
| `parentComment` | `KnowledgeComment?` | `@relation("CommentParent", fields: [parentCommentId], references: [id], onDelete: Cascade)` |  |
| `replies` | `KnowledgeComment[]` | `@relation("CommentParent")` |  |

Restrições e índices: `@@index([knowledgeId])`, `@@index([parentCommentId])`, `@@index([createdAt])`.

### KnowledgeTicketLink (`knowledge_ticket_links`)

Tabela N:N entre Knowledge e Task (demanda). Bidirecional.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `knowledgeId` | `String` | `@map("knowledge_id")` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `relationType` | `KnowledgeTicketRelation` | `@default(reference)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `knowledge` | `Knowledge` | `@relation(fields: [knowledgeId], references: [id], onDelete: Cascade)` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([knowledgeId, taskId])`, `@@index([taskId])`.

### Memory (`memories`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `title` | `String` |  |  |
| `content` | `String` | `@default("")` |  |
| `tags` | `String` | `@default("[]")` |  |
| `category` | `MemoryCategory` | `@default(note)` |  |
| `version` | `Int` | `@default(1)` | Optimistic concurrency (DAT-10): every update that changes the memory writes the replaced state to MemoryVersion and increments this in the same transaction; writes carrying a stale expectedVersion get 409. |
| `deletedAt` | `DateTime?` | `@map("deleted_at")` | Soft delete (DAT-10): NULL = active. Deleted memories stay restorable. |
| `supersededById` | `String?` | `@map("superseded_by_id")` | Invalidation instead of deletion (DAT-10): the memory that replaces this one, and the moment this one stopped being valid (NULL = still valid). |
| `validUntil` | `DateTime?` | `@map("valid_until")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `searchTsv` | `Unsupported("tsvector")?` | `@default(dbgenerated("(setweight(to_tsvector('portuguese'::regconfig, COALESCE(title, ''::text)), 'A'::\"char\") \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(content, ''::text)), 'C'::\"char\"))")) @map("search_tsv")` | Full-text vector GENERATED ALWAYS AS (title A, content C; 'portuguese') STORED — defined in the migration 20260925180000_global_search, read-only here. Used by the global search (search.service.ts). |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `taskLinks` | `MemoryTaskLink[]` |  |  |
| `versions` | `MemoryVersion[]` |  |  |
| `supersededBy` | `Memory?` | `@relation("MemorySupersede", fields: [supersededById], references: [id], onDelete: SetNull)` |  |
| `supersedes` | `Memory[]` | `@relation("MemorySupersede")` |  |

Restrições e índices: `@@index([projectId, updatedAt(sort: Desc)])`, `@@index([createdAt])`, `@@index([title(ops: raw("gin_trgm_ops"))], map: "memories_title_trgm", type: Gin)`, `@@index([searchTsv], map: "memories_search_tsv_gin", type: Gin)`, `@@index([supersededById])`.

### MemoryVersion (`memory_versions`)

Append-only history of the REPLACED states of a memory, same contract as KnowledgeVersion: the row `version = N` holds the state the memory had at version N, written by the memory service in the save that takes it to N + 1 (the current state lives only in `memories`). actorType/actorId/ source say who made the change that replaced this state. Never UPDATE/DELETE here.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `memoryId` | `String` | `@map("memory_id")` |  |
| `version` | `Int` |  |  |
| `title` | `String` |  |  |
| `content` | `String` | `@default("")` |  |
| `tags` | `String` | `@default("[]")` |  |
| `category` | `MemoryCategory` |  |  |
| `actorType` | `ActorType` | `@default(system) @map("actor_type")` |  |
| `actorId` | `String?` | `@map("actor_id")` |  |
| `source` | `AuditSource` | `@default(system)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` | When this state was replaced. |
| `memory` | `Memory` | `@relation(fields: [memoryId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([memoryId, version])`.

### AgentPrompt (`agent_prompts`)

Espelho da Skill como produto (editor, histórico, duplicar, vínculo com task, contexto do agente, busca), com o escopo da Memory: o prompt é DO projeto (projectId obrigatório, a empresa vem do projeto), sem catálogo global nem tabela de vínculo com projeto. version: concorrência otimista (molde Memory, DAT-10): cada escrita que muda o prompt grava o estado substituído em AgentPromptVersion e incrementa na mesma transação; escrita com expectedVersion defasado recebe 409 VERSION_CONFLICT. isActive: coluna (não tabela de vínculo): o objeto já é do projeto. deletedAt: lixeira (molde Memory): NULL = ativo. templateSlug: procedência, de qual modelo do sistema a cópia veio (NULL = criado do zero ou duplicado de outro prompt). searchTsv: GENERATED ALWAYS AS (name A, description B, content C; 'portuguese') STORED — definido na migration, read-only aqui.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `content` | `String` | `@default("")` |  |
| `tags` | `String` | `@default("[]")` |  |
| `isActive` | `Boolean` | `@default(true) @map("is_active")` |  |
| `templateSlug` | `String?` | `@map("template_slug")` |  |
| `version` | `Int` | `@default(1)` |  |
| `deletedAt` | `DateTime?` | `@map("deleted_at")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `searchTsv` | `Unsupported("tsvector")?` | `@default(dbgenerated("((setweight(to_tsvector('portuguese'::regconfig, COALESCE(name, ''::text)), 'A'::\"char\") \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(description, ''::text)), 'B'::\"char\")) \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(content, ''::text)), 'C'::\"char\"))")) @map("search_tsv")` |  |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `taskLinks` | `AgentPromptTaskLink[]` |  |  |
| `versions` | `AgentPromptVersion[]` |  |  |

Restrições e índices: `@@index([projectId, updatedAt(sort: Desc)])`, `@@index([createdAt])`, `@@index([name(ops: raw("gin_trgm_ops"))], map: "agent_prompts_name_trgm", type: Gin)`, `@@index([searchTsv], map: "agent_prompts_search_tsv_gin", type: Gin)`.

### AgentPromptVersion (`agent_prompt_versions`)

Histórico append-only dos estados SUBSTITUÍDOS de um prompt (molde MemoryVersion): a linha `version = N` guarda o estado que o prompt tinha na versão N, gravada pelo save que o levou a N + 1 (o estado atual vive só em `agent_prompts`). actorType/actorId/source dizem quem fez a escrita que substituiu esse estado. `restoredFrom` marca a versão restaurada quando o estado veio de um restore. Nunca UPDATE/DELETE aqui (trigger `agent_prompt_versions_append_only` recusa UPDATE; o histórico some junto com o prompt, em cascade).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `promptId` | `String` | `@map("prompt_id")` |  |
| `version` | `Int` |  |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `content` | `String` | `@default("")` |  |
| `tags` | `String` | `@default("[]")` |  |
| `isActive` | `Boolean` | `@default(true) @map("is_active")` |  |
| `restoredFrom` | `Int?` | `@map("restored_from")` |  |
| `actorType` | `ActorType` | `@default(system) @map("actor_type")` |  |
| `actorId` | `String?` | `@map("actor_id")` |  |
| `source` | `AuditSource` | `@default(system)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` | Quando esse estado foi substituído. |
| `prompt` | `AgentPrompt` | `@relation(fields: [promptId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([promptId, version])`.

### AgentPromptTaskLink (`agent_prompt_task_links`)

Vínculo Task↔Prompt. Espelho de MemoryTaskLink, com a mesma regra de escopo: o prompt é DO projeto, então o vínculo exige task.projectId === prompt.projectId (molde linkMemory). O linkSkill NÃO faz isso porque skill é catálogo global — o bug que este vínculo não copia.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `promptId` | `String` | `@map("prompt_id")` |  |
| `relationType` | `AgentPromptTaskRelation` | `@default(execution) @map("relation_type")` |  |
| `note` | `String?` | `@db.Text` |  |
| `createdBy` | `String?` | `@map("created_by")` | humanIdOf(ctx): id do humano quando age pessoa, NULL quando veio do MCP. |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `prompt` | `AgentPrompt` | `@relation(fields: [promptId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([taskId, promptId, relationType])`, `@@index([promptId])`.

### AgentPromptBuiltinTemplate (`agent_prompt_builtin_templates`)

Modelos do sistema: globais, somente leitura, gravados por migration. A API nunca escreve aqui (não existe rota nem service de escrita): "usar no projeto" duplica o modelo para dentro do projeto (prompt editável, com template_slug de procedência). Sem FK de propósito.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `slug` | `String` | `@id` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `content` | `String` |  |  |
| `seededHash` | `String` | `@map("seeded_hash")` |  |
| `seededAt` | `DateTime` | `@default(now()) @map("seeded_at")` |  |

### Task (`tasks`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `title` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `status` | `TaskStatus` | `@default(todo)` |  |
| `priority` | `TaskPriority` | `@default(medium)` |  |
| `tags` | `String` | `@default("[]")` |  |
| `assignedTo` | `String?` | `@map("assigned_to")` |  |
| `claimedBy` | `String?` | `@map("claimed_by")` | Agent lease (TSK-09): who holds the task and until when. A claim is a conditional update (claimExpiresAt NULL or in the past), so two agents never hold the same task. Set and cleared together (CHECK tasks_claim_consistent, migration 20260925200000_w2_support). |
| `claimExpiresAt` | `DateTime?` | `@map("claim_expires_at")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `completedAt` | `DateTime?` | `@map("completed_at")` |  |
| `startAt` | `DateTime?` | `@map("start_at")` | Planejamento (roadmap): início e fim previstos. Nulos = sem data. allDay = só o dia (00:00 UTC da data escrita, fim inclusivo); com hora é o instante exato. CHECK tasks_schedule_order (migration 20261013090000_task_schedule) garante end_at &gt;= start_at; a validação com 422 fica no service (task-schedule.ts), com o valor efetivo. |
| `endAt` | `DateTime?` | `@map("end_at")` |  |
| `allDay` | `Boolean` | `@default(true) @map("all_day")` |  |
| `searchTsv` | `Unsupported("tsvector")?` | `@default(dbgenerated("(setweight(to_tsvector('portuguese'::regconfig, COALESCE(title, ''::text)), 'A'::\"char\") \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(description, ''::text)), 'C'::\"char\"))")) @map("search_tsv")` | Full-text vector GENERATED ALWAYS AS (title A, description C; 'portuguese') STORED — migration 20260925180000_global_search, read-only here. Used by the global search (search.service.ts). |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `todos` | `Todo[]` |  |  |
| `knowledges` | `Knowledge[]` |  |  |
| `knowledgeLinks` | `KnowledgeTicketLink[]` |  |  |
| `decisions` | `TaskDecision[]` |  |  |
| `taskEvents` | `TaskEvent[]` |  |  |
| `memoryLinks` | `MemoryTaskLink[]` |  |  |
| `skillLinks` | `TaskSkillLink[]` |  |  |
| `promptLinks` | `AgentPromptTaskLink[]` |  |  |
| `attachments` | `TaskAttachment[]` |  |  |
| `dependsOn` | `TaskDependency[]` | `@relation("TaskDependencyTask")` |  |
| `dependents` | `TaskDependency[]` | `@relation("TaskDependencyDependsOn")` |  |

Restrições e índices: `@@index([projectId, priority(sort: Desc), updatedAt(sort: Desc), id])`, `@@index([projectId, startAt])`, `@@index([createdAt])`, `@@index([title(ops: raw("gin_trgm_ops"))], map: "tasks_title_trgm", type: Gin)`, `@@index([searchTsv], map: "tasks_search_tsv_gin", type: Gin)`.

### TaskDependency (`task_dependencies`)

`taskId` can only start after `dependsOnId` is done. Both tasks belong to the same project and the graph has no cycles: the task-dependency service checks both (recursive CTE) before inserting; the table only forbids the self-edge (CHECK task_dependencies_not_self).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `taskId` | `String` | `@map("task_id")` |  |
| `dependsOnId` | `String` | `@map("depends_on_id")` |  |
| `createdBy` | `String?` | `@map("created_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `task` | `Task` | `@relation("TaskDependencyTask", fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `dependsOn` | `Task` | `@relation("TaskDependencyDependsOn", fields: [dependsOnId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@id([taskId, dependsOnId])`, `@@index([dependsOnId])`.

### TaskDecision (`task_decisions`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `key` | `String` |  |  |
| `position` | `Int` | `@default(0)` |  |
| `question` | `String` | `@db.Text` |  |
| `context` | `String` | `@db.Text` |  |
| `recommendation` | `String?` | `@db.Text` |  |
| `required` | `Boolean` | `@default(true)` |  |
| `selectionType` | `DecisionSelectionType` | `@default(single) @map("selection_type")` |  |
| `minSelections` | `Int` | `@default(1) @map("min_selections")` |  |
| `maxSelections` | `Int?` | `@map("max_selections")` |  |
| `status` | `TaskDecisionStatus` | `@default(pending)` |  |
| `revision` | `Int` | `@default(1)` |  |
| `answeredAt` | `DateTime?` | `@map("answered_at")` |  |
| `closedReason` | `String?` | `@map("closed_reason") @db.Text` | Fechamento sem resposta (TSK-07): cancelada ou substituída, sempre com motivo (CHECK no banco), quando e por quem (id do usuário ou da chave). |
| `closedAt` | `DateTime?` | `@map("closed_at")` |  |
| `closedBy` | `String?` | `@map("closed_by")` |  |
| `supersededById` | `String?` | `@unique @map("superseded_by_id")` | Decisão que substituiu esta (status superseded). |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `supersededBy` | `TaskDecision?` | `@relation("DecisionSupersession", fields: [supersededById], references: [id], onDelete: SetNull)` |  |
| `supersedes` | `TaskDecision?` | `@relation("DecisionSupersession")` |  |
| `options` | `TaskDecisionOption[]` |  |  |
| `responses` | `TaskDecisionResponse[]` |  |  |
| `attachments` | `TaskAttachment[]` |  |  |

Restrições e índices: `@@unique([taskId, key])`, `@@index([taskId, status])`.

### TaskDecisionOption (`task_decision_options`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `decisionId` | `String` | `@map("decision_id")` |  |
| `position` | `Int` | `@default(0)` |  |
| `label` | `String` |  |  |
| `description` | `String` | `@db.Text` |  |
| `pros` | `String` | `@default("[]")` |  |
| `cons` | `String` | `@default("[]")` |  |
| `consequences` | `String?` | `@db.Text` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `decision` | `TaskDecision` | `@relation(fields: [decisionId], references: [id], onDelete: Cascade)` |  |
| `responses` | `TaskDecisionResponse[]` |  |  |

Restrições e índices: `@@unique([decisionId, position])`.

### TaskDecisionResponse (`task_decision_responses`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `decisionId` | `String` | `@map("decision_id")` |  |
| `revision` | `Int` |  |  |
| `optionId` | `String?` | `@map("option_id")` |  |
| `optionIds` | `String` | `@default("[]") @map("option_ids")` |  |
| `otherText` | `String?` | `@map("other_text") @db.Text` |  |
| `observation` | `String?` | `@db.Text` |  |
| `actorId` | `String?` | `@map("actor_id")` |  |
| `actorType` | `ActorType` | `@default(human) @map("actor_type")` |  |
| `source` | `String` | `@default("ui")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `decision` | `TaskDecision` | `@relation(fields: [decisionId], references: [id], onDelete: Cascade)` |  |
| `option` | `TaskDecisionOption?` | `@relation(fields: [optionId], references: [id], onDelete: SetNull)` |  |

Restrições e índices: `@@unique([decisionId, revision])`, `@@index([decisionId, createdAt])`, `@@index([optionId])`.

### TaskEvent (`task_events`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `seq` | `Int` | `@default(autoincrement())` | Ordem de gravação. Os eventos de uma transação saem no mesmo milissegundo, então `createdAt` não ordena a timeline nem serve de cursor. |
| `taskId` | `String` | `@map("task_id")` |  |
| `type` | `TaskEventType` |  |  |
| `payload` | `String` | `@default("{}")` |  |
| `actorId` | `String?` | `@map("actor_id")` |  |
| `actorType` | `ActorType` | `@default(system) @map("actor_type")` |  |
| `source` | `String` | `@default("api")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@index([taskId, seq])`.

### AuditEvent (`audit_events`)

Append-only audit trail of every write made through REST or MCP. It survives the audited rows on purpose: no FK to projects or to the entity (projectId/entityId are plain ids), so deleting or purging a project keeps its history. UPDATE is refused by the trigger audit_events_append_only (migration 20260925200000_w2_support); DELETE stays allowed for the retention job. `diff` never carries secrets (redacted by the writer).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String?` | `@map("project_id")` |  |
| `actorType` | `ActorType` | `@map("actor_type")` |  |
| `actorId` | `String?` | `@map("actor_id")` |  |
| `clientName` | `String?` | `@map("client_name")` |  |
| `clientVersion` | `String?` | `@map("client_version")` |  |
| `sessionId` | `String?` | `@map("session_id")` |  |
| `source` | `AuditSource` |  |  |
| `entity` | `String` |  |  |
| `entityId` | `String?` | `@map("entity_id")` |  |
| `action` | `String` |  |  |
| `diff` | `Json?` |  |  |
| `requestId` | `String?` | `@map("request_id")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |

Restrições e índices: `@@index([projectId, createdAt(sort: Desc), id])`, `@@index([actorType, actorId, createdAt(sort: Desc)])`, `@@index([entity, entityId, createdAt(sort: Desc)])`, `@@index([createdAt])`.

### MemoryTaskLink (`memory_task_links`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `memoryId` | `String` | `@map("memory_id")` |  |
| `relationType` | `MemoryTaskRelation` | `@default(context) @map("relation_type")` |  |
| `note` | `String?` | `@db.Text` |  |
| `createdBy` | `String?` | `@map("created_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `memory` | `Memory` | `@relation(fields: [memoryId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([taskId, memoryId, relationType])`, `@@index([memoryId])`.

### TaskSkillLink (`task_skill_links`)

Espelho exato de MemoryTaskLink, para o catálogo global de Skills. A Memory diz o QUE já foi decidido; a Skill diz COMO executar. Os dois vínculos sobrevivem ao chat. Diferença de escopo: Memory é sempre de um projeto (o service exige projectId igual ao da Task), enquanto Skill é catálogo GLOBAL (ver ProjectSkill). Por isso aqui só validamos que Task e Skill existem — o vínculo com o projeto é ortogonal e vive em `project_skills`.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `skillId` | `String` | `@map("skill_id")` |  |
| `relationType` | `TaskSkillRelation` | `@default(execution) @map("relation_type")` |  |
| `note` | `String?` | `@db.Text` |  |
| `createdBy` | `String?` | `@map("created_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `skill` | `Skill` | `@relation(fields: [skillId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([taskId, skillId, relationType])`, `@@index([skillId])`.

### TaskAttachment (`task_attachments`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `decisionId` | `String?` | `@map("decision_id")` |  |
| `storageKey` | `String` | `@unique @map("storage_key")` |  |
| `filename` | `String` |  |  |
| `mimeType` | `String` | `@map("mime_type")` |  |
| `size` | `Int` |  |  |
| `checksum` | `String` |  |  |
| `uploadedBy` | `String?` | `@map("uploaded_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `deletedAt` | `DateTime?` | `@map("deleted_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |
| `decision` | `TaskDecision?` | `@relation(fields: [decisionId], references: [id], onDelete: SetNull)` |  |

Restrições e índices: `@@index([taskId, createdAt])`, `@@index([decisionId])`.

### Todo (`todos`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `taskId` | `String` | `@map("task_id")` |  |
| `title` | `String` |  |  |
| `isCompleted` | `Boolean` | `@default(false) @map("is_completed")` |  |
| `order` | `Int` | `@default(0)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `task` | `Task` | `@relation(fields: [taskId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@index([taskId, order])`.

### Skill (`skills`)

Agent Skills frontmatter (agentskills.io, filled by the SKILL.md parser, SKL-02): `license`, `compatibility`, `allowedTools` (`allowed-tools`) and `metadata` (string map) keep the optional fields of the spec; `frontmatterRaw` is the original YAML block, for a faithful re-export; `contentHash` is the hash of the stored SKILL.md, to tell local edits apart from the seeded or imported version.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `slug` | `String` | `@unique` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `content` | `String` | `@default("")` |  |
| `tags` | `String` | `@default("[]")` |  |
| `author` | `String` | `@default("")` |  |
| `version` | `String` | `@default("0.1.0")` |  |
| `source` | `SkillSource` | `@default(local)` |  |
| `sourceUrl` | `String?` | `@map("source_url")` |  |
| `attachments` | `String` | `@default("[]")` |  |
| `folderId` | `String?` | `@map("folder_id")` |  |
| `license` | `String?` |  |  |
| `compatibility` | `String?` |  |  |
| `allowedTools` | `String[]` | `@default([]) @map("allowed_tools")` |  |
| `metadata` | `Json?` |  |  |
| `frontmatterRaw` | `String?` | `@map("frontmatter_raw")` |  |
| `contentHash` | `String?` | `@map("content_hash")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `searchTsv` | `Unsupported("tsvector")?` | `@default(dbgenerated("((setweight(to_tsvector('portuguese'::regconfig, COALESCE(name, ''::text)), 'A'::\"char\") \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(description, ''::text)), 'B'::\"char\")) \|\| setweight(to_tsvector('portuguese'::regconfig, COALESCE(content, ''::text)), 'C'::\"char\"))")) @map("search_tsv")` | Full-text vector GENERATED ALWAYS AS (name A, description B, content C; 'portuguese') STORED — migration 20260925180000_global_search, read-only here. Used by the global search (search.service.ts). |
| `folder` | `SkillFolder?` | `@relation(fields: [folderId], references: [id], onDelete: SetNull)` |  |
| `projectSkills` | `ProjectSkill[]` |  |  |
| `taskLinks` | `TaskSkillLink[]` |  |  |
| `versions` | `SkillVersion[]` |  |  |

Restrições e índices: `@@index([createdAt])`, `@@index([folderId])`, `@@index([updatedAt])`, `@@index([name(ops: raw("gin_trgm_ops"))], map: "skills_name_trgm", type: Gin)`, `@@index([slug(ops: raw("gin_trgm_ops"))], map: "skills_slug_trgm", type: Gin)`, `@@index([description(ops: raw("gin_trgm_ops"))], map: "skills_description_trgm", type: Gin)`, `@@index([searchTsv], map: "skills_search_tsv_gin", type: Gin)`.

### SkillVersion (`skill_versions`)

Histórico append-only de uma skill (NEW-skills-2, achado SK-08): cada escrita que muda o pacote (REST, MCP, import, seed, arquivos, restore, duplicação) grava aqui o pacote inteiro e o `contentHash` dele, numerado por skill a partir de 1 (`number`). Linha gravada não muda: um trigger recusa UPDATE (migration 20260926090000_skill_versions). O histórico some junto com a skill.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `skillId` | `String` | `@map("skill_id")` |  |
| `number` | `Int` |  |  |
| `origin` | `SkillWriteOrigin` |  |  |
| `restoredFrom` | `Int?` | `@map("restored_from")` | Número da versão restaurada, quando `origin` = restore. |
| `contentHash` | `String` | `@map("content_hash")` |  |
| `source` | `SkillSource` |  |  |
| `sourceUrl` | `String?` | `@map("source_url")` |  |
| `slug` | `String` |  |  |
| `name` | `String` |  |  |
| `description` | `String` |  |  |
| `content` | `String` |  |  |
| `tags` | `String` | `@default("[]")` |  |
| `author` | `String` |  |  |
| `version` | `String` |  |  |
| `license` | `String?` |  |  |
| `compatibility` | `String?` |  |  |
| `allowedTools` | `String[]` | `@default([]) @map("allowed_tools")` |  |
| `metadata` | `Json?` |  |  |
| `attachments` | `String` | `@default("[]")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `skill` | `Skill` | `@relation(fields: [skillId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([skillId, number])`.

### BuiltinSkillSeed (`builtin_skill_seeds`)

Último pacote que o seed gravou em cada skill built-in (SKL-06, achado SK-09). O seed só atualiza a skill cujo `contentHash` ainda é o `seededHash` daqui; se divergir, alguém editou a skill depois do seed e ela é pulada ("skipped: user-modified"). Sem FK de propósito: apagar a skill não apaga o registro, e o seed recria a built-in.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `slug` | `String` | `@id` |  |
| `seededHash` | `String` | `@map("seeded_hash")` |  |
| `seededAt` | `DateTime` | `@default(now()) @map("seeded_at")` |  |

### SkillFolder (`skill_folders`)

Pastas do catálogo global de skills, aninháveis (parentId self-FK). Uma skill pode estar em zero ou uma pasta (Skill.folderId). `order` é a posição entre irmãos (mesmo parentId), mantida contígua pelo service. Deletar uma pasta faz subpastas e skills subirem pro pai (service).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `name` | `String` |  |  |
| `order` | `Int` | `@default(0)` |  |
| `parentId` | `String?` | `@map("parent_id")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `parent` | `SkillFolder?` | `@relation("SkillFolderTree", fields: [parentId], references: [id], onDelete: SetNull)` |  |
| `children` | `SkillFolder[]` | `@relation("SkillFolderTree")` |  |
| `skills` | `Skill[]` |  |  |

Restrições e índices: `@@index([parentId, order])`.

### ProjectSkill (`project_skills`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `skillId` | `String` | `@map("skill_id")` |  |
| `isActive` | `Boolean` | `@default(true) @map("is_active")` |  |
| `addedAt` | `DateTime` | `@default(now()) @map("added_at")` |  |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `skill` | `Skill` | `@relation(fields: [skillId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([projectId, skillId])`, `@@index([skillId])`.

### McpServer (`mcp_servers`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `transport` | `McpTransport` | `@default(http)` |  |
| `config` | `String` | `@default("{}")` |  |
| `secrets` | `String?` | `@map("secrets")` | Encrypted JSON blob holding per-server secrets (e.g. GitHub token). Shape (after decrypt): { "Authorization": "Bearer ..." } Stored AES-256-GCM: base64(iv \| authTag \| ciphertext). `null` = no secrets configured. Encrypted/decrypted by `services/secrets.service.ts`. |
| `tags` | `String` | `@default("[]")` |  |
| `isBuiltin` | `Boolean` | `@default(false) @map("is_builtin")` | Marks built-in servers (AgentPack, GitHub). Their shape/URL/transport is fixed by code and cannot be edited via REST. Only the `secrets` column can be written (via PUT /api/mcps/:id/secret). API rejects PATCH/DELETE on these rows. Frontend uses this flag to hide edit UI. |
| `toolPrefix` | `String?` | `@unique @map("tool_prefix")` | Prefix of this server's tools in the merged /mcp catalog (`&lt;toolPrefix&gt;_&lt;tool&gt;`). Unique; the CHECK constraint `mcp_servers_tool_prefix_format` (migration 20260925000400) enforces ^[a-z0-9_]{1,24}$. Rows that existed before that migration were backfilled from the name; NULL only until the MCP service assigns one. |
| `organizationId` | `String?` | `@map("organization_id")` | Company that registered this MCP (C4). `null` = platform/legacy row, visible to every company — the state of the catalog before C4. Rows with a company are listed, read, edited, deleted and linked only inside it, so the credential of one company never reaches an agent of another. Rows that existed before C4 were backfilled by migration 20261014090000_mcp_server_organization: a non-built-in row whose linked projects (live or in the trash) all belong to ONE company, with at least one live, got that company; every other row stayed `null`, so no project lost an MCP it used. |
| `organization` | `Organization?` | `@relation(fields: [organizationId], references: [id], onDelete: Restrict)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `tools` | `McpTool[]` |  |  |
| `projectMcps` | `ProjectMcp[]` |  |  |

Restrições e índices: `@@index([createdAt])`, `@@index([updatedAt])`, `@@index([organizationId])`.

### McpTool (`mcp_tools`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `mcpServerId` | `String` | `@map("mcp_server_id")` |  |
| `name` | `String` |  |  |
| `description` | `String` | `@default("")` |  |
| `inputSchema` | `String` | `@default("{}") @map("input_schema")` |  |
| `title` | `String?` |  | Metadata the upstream declares in `tools/list` (MCP-07), republished by the /mcp gateway: display name, behavior hints (`ToolAnnotations`) and the JSON Schema of `structuredContent`. NULL when the upstream does not declare it. |
| `annotations` | `Json?` |  |  |
| `outputSchema` | `Json?` | `@map("output_schema")` |  |
| `mcpServer` | `McpServer` | `@relation(fields: [mcpServerId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([mcpServerId, name])`.

### ProjectMcp (`project_mcps`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `mcpServerId` | `String` | `@map("mcp_server_id")` |  |
| `isActive` | `Boolean` | `@default(true) @map("is_active")` |  |
| `configOverride` | `String?` | `@map("config_override")` |  |
| `enabledTools` | `String[]` | `@default([]) @map("enabled_tools")` | Per-project tool filters (original tool names of this server), applied to the catalog and to tools/call by the MCP gateway. |
| `disabledTools` | `String[]` | `@default([]) @map("disabled_tools")` |  |
| `addedAt` | `DateTime` | `@default(now()) @map("added_at")` |  |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `mcpServer` | `McpServer` | `@relation(fields: [mcpServerId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([projectId, mcpServerId])`, `@@index([mcpServerId])`.

### RenderDesign (`render_designs`)

Catálogo GLOBAL sem projectId (precedente: Skill é global).

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `baseKey` | `String` | `@map("base_key")` |  |
| `family` | `String` |  |  |
| `title` | `String` |  |  |
| `route` | `String?` |  |  |
| `width` | `Int` | `@map("width")` |  |
| `height` | `Int` | `@map("height")` |  |
| `source` | `String` | `@default("dc")` |  |
| `dataSource` | `String?` | `@map("data_source")` |  |
| `lightId` | `String` | `@map("light_id")` |  |
| `darkId` | `String?` | `@map("dark_id")` |  |
| `rev` | `Int` | `@default(1)` |  |
| `projectId` | `String?` | `@map("project_id")` |  |
| `originId` | `String?` | `@map("origin_id")` |  |
| `originRev` | `Int?` | `@map("origin_rev")` |  |
| `portedAt` | `DateTime?` | `@map("ported_at")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `screens` | `RenderScreen[]` |  |  |
| `assets` | `RenderAsset[]` |  |  |
| `revisions` | `RenderRevision[]` |  |  |
| `project` | `Project?` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([projectId, baseKey])`, `@@index([family])`, `@@index([updatedAt])`.

### RenderScreen (`render_screens`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `designId` | `String` | `@map("design_id")` |  |
| `theme` | `RenderTheme` |  |  |
| `tree` | `String` | `@db.Text` |  |
| `styles` | `String` | `@default("[]")` |  |
| `deltaOps` | `String?` | `@map("delta_ops") @db.Text` |  |
| `isIdentical` | `Boolean` | `@default(false) @map("is_identical")` |  |
| `summary` | `String` | `@default("{}")` |  |
| `rev` | `Int` | `@default(1)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `design` | `RenderDesign` | `@relation(fields: [designId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([designId, theme])`.

### RenderLibrary (`render_libraries`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `kind` | `RenderLibraryKind` |  |  |
| `payload` | `String` | `@db.Text` |  |
| `version` | `Int` | `@default(1)` |  |
| `projectId` | `String?` | `@map("project_id")` |  |
| `originKind` | `String?` | `@map("origin_kind")` |  |
| `portedAt` | `DateTime?` | `@map("ported_at")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `project` | `Project?` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([projectId, kind])`.

### RenderDesignSystem (`render_design_systems`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String?` | `@map("project_id")` |  |
| `name` | `String` |  |  |
| `snapshot` | `String` | `@db.Text` | Independent renderer-compatible ui + tokens snapshot; optional project manifest. Global names are unique via partial SQL index (Prisma cannot express WHERE). |
| `version` | `Int` | `@default(1)` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `updatedAt` | `DateTime` | `@updatedAt @map("updated_at")` |  |
| `project` | `Project?` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `ports` | `RenderPort[]` |  |  |
| `published` | `RenderDesignSystemVersion[]` |  |  |

Restrições e índices: `@@unique([projectId, name])`.

### RenderDesignSystemVersion (`render_design_system_versions`)

Immutable published versions of a design system. `version` counts the publications (1, 2, ...) and is what RenderPort.sourceVersion points to; `systemVersion` is the RenderDesignSystem.version (edit counter) that was published, so "unpublished changes" = system.version > latest systemVersion. Never UPDATE here.

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `designSystemId` | `String` | `@map("design_system_id")` |  |
| `version` | `Int` |  |  |
| `systemVersion` | `Int` | `@map("system_version")` |  |
| `snapshot` | `String` | `@db.Text` |  |
| `notes` | `String` | `@default("")` |  |
| `publishedBy` | `String?` | `@map("published_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `designSystem` | `RenderDesignSystem` | `@relation(fields: [designSystemId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@unique([designSystemId, version])`.

### RenderPort (`render_ports`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `projectId` | `String` | `@map("project_id")` |  |
| `designSystemId` | `String?` | `@map("design_system_id")` |  |
| `sourceKind` | `RenderPortSourceKind` | `@map("source_kind")` |  |
| `sourceId` | `String` | `@map("source_id")` |  |
| `targetId` | `String` | `@map("target_id")` |  |
| `sourceVersion` | `Int?` | `@map("source_version")` | Published version of the source design system that this port applied (RenderDesignSystemVersion.version, RND-12). NULL for ports made before publishing existed or from an unversioned source. |
| `actor` | `String` | `@default("api")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `project` | `Project` | `@relation(fields: [projectId], references: [id], onDelete: Cascade)` |  |
| `designSystem` | `RenderDesignSystem?` | `@relation(fields: [designSystemId], references: [id], onDelete: SetNull)` |  |

Restrições e índices: `@@index([projectId])`, `@@index([targetId])`, `@@index([designSystemId])`, `@@index([sourceKind, sourceId])`.

### RenderRevision (`render_revisions`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `designId` | `String` | `@map("design_id")` |  |
| `theme` | `RenderTheme` | `@default(light)` |  |
| `actor` | `String` | `@default("api")` |  |
| `snapshot` | `String?` | `@db.Text` |  |
| `diff` | `String` | `@default("{}")` |  |
| `pinned` | `Boolean` | `@default(false)` | Pinned revisions are never pruned by retention and do not count towards RENDER_REVISION_KEEP. |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `design` | `RenderDesign` | `@relation(fields: [designId], references: [id], onDelete: Cascade)` |  |

Restrições e índices: `@@index([designId, createdAt])`.

### RenderAsset (`render_assets`)

| Campo | Tipo | Atributos | Nota |
| --- | --- | --- | --- |
| `id` | `String` | `@id @default(uuid())` |  |
| `designId` | `String?` | `@map("design_id")` |  |
| `storageKey` | `String` | `@unique @map("storage_key")` |  |
| `filename` | `String` |  |  |
| `mimeType` | `String` | `@map("mime_type")` |  |
| `size` | `Int` |  |  |
| `checksum` | `String` |  |  |
| `uploadedBy` | `String?` | `@map("uploaded_by")` |  |
| `createdAt` | `DateTime` | `@default(now()) @map("created_at")` |  |
| `design` | `RenderDesign?` | `@relation(fields: [designId], references: [id], onDelete: SetNull)` |  |

Restrições e índices: `@@index([designId])`.
