@agentconnect.md/setup 1.37.1 → 1.38.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -56418,7 +56418,7 @@ const config = {
|
|
|
56418
56418
|
"clientVersion": "7.9.0",
|
|
56419
56419
|
"engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
|
|
56420
56420
|
"activeProvider": "postgresql",
|
|
56421
|
-
"inlineSchema": "// AgentConnect Control Plane — Persistence (C6).\n//\n// PostgreSQL is metadata-only for message, transcript, and agent-memory bodies.\n// Approved organization Knowledge Markdown and bounded immutable managed-skill\n// ZIP revisions are the explicit shared-content exception described in\n// docs/designs/organization-knowledge.md; pending suggestion bodies remain on\n// their source daemon.\n//\n// See docs/designs/control-plane-implementation.md §3.\n\ngenerator client {\n provider = \"prisma-client\"\n // v7 emits TypeScript source compiled with the app (tsc). Output lives under\n // `src/` so `rootDir: src` picks it up; it is gitignored and regenerated by\n // `prisma:generate` in CI and the Docker build. No query-engine binary — v7\n // uses the queryCompiler + the `@prisma/adapter-pg` driver adapter.\n output = \"../src/generated/prisma\"\n runtime = \"nodejs\"\n moduleFormat = \"esm\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n // Connection URL is supplied at runtime via the pg driver adapter (see\n // `persistence/prisma.ts`) and to the CLI via `prisma.config.ts`\n // (`datasource.url`). v7 deprecates a `url` here, so it is intentionally omitted.\n}\n\n// Deployment-wide operator configuration. There is exactly one row (`id = 1`),\n// enforced by the migration. `values` is a versioned, application-validated\n// JSON document; secret values live in the side table and never join ordinary\n// configuration reads.\nmodel DeploymentConfig {\n id Int @id\n schemaVersion Int\n values Json @db.JsonB\n revision Int @default(1)\n adminClaimedFor String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n secrets DeploymentSecret[]\n\n @@map(\"deployment_config\")\n}\n\nmodel DeploymentSecret {\n deploymentConfigId Int\n key String\n value String\n // Stable, truncated digest of the plaintext for redacted operator status.\n // It is never used for authentication.\n fingerprint String\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade)\n\n @@id([deploymentConfigId, key])\n @@map(\"deployment_secret\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.2 Tenancy axis — Org / User / Membership (C2/C4 WebUI authz)\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Org {\n id String @id @default(cuid())\n // Optional display name; when absent the console falls back to `slug`.\n name String?\n slug String @unique\n // Console avatar descriptor (protocol AgentIcon): {kind:'runtime'} | {kind:'glyph',glyph,color}\n // | {kind:'image'}. Null ⇒ generated default (glyph plate keyed off the org id). An `image`\n // icon's bytes live in the object store (docs/designs/icon-uploads.md), served by GET\n // /v1/orgs/:id/icon. Org icons are console-only — never fed to Slack.\n icon Json? @db.JsonB\n // Applied to both call directions when a new agent does not explicitly choose\n // a policy. Existing agents keep their persisted directional policies.\n defaultAgentVisibility AgentCallPolicy @default(all)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n members Membership[]\n daemons Daemon[]\n agents Agent[]\n crons CronDef[]\n hooks HookDef[]\n apiKeys ApiKey[]\n integrations Integration[]\n bots Bot[]\n githubInstallations GithubInstallation[]\n slackUserConfigs SlackUserConfig[]\n inviteLink OrgInviteLink?\n mcpProviders McpProvider[]\n skillSources SkillSource[]\n memoryPluginInstallations MemoryPluginInstallation[]\n externalMemoryConnections ExternalMemoryConnection[]\n webchatConversations WebchatConversation[]\n webchatMcpDelegations WebchatMcpDelegation[]\n presetAgents PresetAgent[]\n organizationKnowledge OrganizationKnowledge[]\n managedSkills ManagedSkill[]\n organizationSuggestions OrganizationSuggestion[]\n externalScopes ExternalScope[]\n sessionExternalAccess SessionExternalAccessPolicy[]\n environmentEntries OrganizationEnvironmentEntry[]\n\n @@map(\"org\")\n}\n\nmodel User {\n id String @id @default(cuid())\n email String @unique\n displayName String?\n picture String? // OIDC `picture` claim (avatar URL); display-only, refreshed on sign-in\n // Set when the user uploads a profile photo. The image itself lives in the icon\n // object store under a key derived from this user id; the timestamp marks it as\n // the active photo and cache-busts its public URL.\n profilePictureUpdatedAt DateTime? @db.Timestamptz(6)\n oidcSubject String? @unique // OIDC `sub` → user\n // The moment the user redeemed their waitlist join link = \"formal / activated\"\n // user. Non-null ⇒ may enter the app\n // and create orgs under WAITLIST_MODE. ONLY the CP's redeem path writes this (the\n // external admin app is not granted write on this column, §7 contract 2). Always\n // null when waitlist mode is off — the column is inert there.\n activatedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n memberships Membership[]\n apiKeys ApiKey[]\n createdAgents Agent[] @relation(\"AgentCreatedBy\")\n modifiedAgents Agent[] @relation(\"AgentModifiedBy\")\n createdIntegrations Integration[]\n createdBots Bot[]\n createdMcpProviders McpProvider[] @relation(\"McpProviderCreatedBy\")\n createdSkillSources SkillSource[] @relation(\"SkillSourceCreatedBy\")\n createdMemoryPluginInstallations MemoryPluginInstallation[] @relation(\"MemoryPluginInstallationCreatedBy\")\n createdExternalMemoryConnections ExternalMemoryConnection[] @relation(\"ExternalMemoryConnectionCreatedBy\")\n createdDaemons Daemon[] @relation(\"DaemonCreatedBy\")\n modifiedDaemons Daemon[] @relation(\"DaemonModifiedBy\")\n createdCrons CronDef[] @relation(\"CronCreatedBy\")\n modifiedCrons CronDef[] @relation(\"CronModifiedBy\")\n createdHooks HookDef[] @relation(\"HookCreatedBy\")\n modifiedHooks HookDef[] @relation(\"HookModifiedBy\")\n createdRepoAuths AgentRepoAuthorization[] @relation(\"AgentRepoAuthCreatedBy\")\n slackConfigs SlackUserConfig[]\n createdInviteLinks OrgInviteLink[] @relation(\"OrgInviteLinkCreatedBy\")\n inviteRedemptions OrgInviteRedemption[]\n webchatConversations WebchatConversation[]\n webchatMcpDelegations WebchatMcpDelegation[]\n createdEnvironmentEntries OrganizationEnvironmentEntry[] @relation(\"OrgEnvCreatedBy\")\n modifiedEnvironmentEntries OrganizationEnvironmentEntry[] @relation(\"OrgEnvModifiedBy\")\n authorizedEnvironmentAssignments OrganizationEnvironmentAssignment[] @relation(\"OrgEnvAuthorizedBy\")\n\n @@map(\"app_user\")\n}\n\n// A deleted account's identity boundary: tokens issued at or before `cutoffAt` are\n// refused for that subject, so a still-valid pre-deletion bearer cannot re-run JIT\n// signup and recreate the account it just lost — not even across a CP restart, where\n// the auth plane's in-process cutoff is gone.\n//\n// Two writers, deliberately: an `AFTER DELETE` trigger on app_user (added by the\n// `deleted_identity_trigger` migration) records every deletion as it happens — the CP\n// does not perform account deletion, the external admin app does, and the database is\n// where both meet — and the auth plane also records what it observes, which covers a\n// row that disappeared without the trigger (e.g. a restore from a backup taken before\n// it existed).\n//\n// NOT a ban list: rows are expiry-limited, and once `expiresAt` passes the subject is\n// an ordinary newcomer again. Whether a deleted person may re-apply at all is the\n// deleting app's policy, not this table's.\nmodel DeletedIdentityCutoff {\n oidcSubject String @id // the OIDC `sub` whose local row was found missing\n cutoffAt DateTime @db.Timestamptz(6) // refuse tokens with `iat` <= this\n expiresAt DateTime @db.Timestamptz(6) // pruned/ignored past this point\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([expiresAt])\n @@map(\"deleted_identity_cutoff\")\n}\n\n// One row per deleted organization, recording that its transit key should be\n// destroyed (docs/designs/per-org-secret-encryption.md §6). Written inside the\n// same transaction that deletes the org, so the intent survives whatever\n// happens next; drained by the operator-run `secrets:shred` CLI, which is the\n// ONLY thing that ever deletes a key — the CP process cannot.\n//\n// Deliberately NO foreign key: the row's entire purpose is to outlive the\n// organization it names.\n//\n// The RESOLVED target is stored, not just the org id. Deriving the name at\n// drain time would read it from whatever configuration is current then, so\n// rotating VAULT_TRANSIT_MOUNT or the org key prefix between the delete and the\n// drain would aim the destroy at a name that does not exist — which the\n// shredder reads as \"already gone\", clears the row, and leaves the real key\n// alive forever. Pinning the target at delete time makes the tombstone\n// self-describing and immune to later configuration changes.\nmodel PendingKeyShred {\n orgId String @id // the deleted org (identity + idempotency key)\n mount String // transit mount as configured when the org was deleted\n keyName String // fully resolved key name, e.g. <orgKeyPrefix><orgId>\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"pending_key_shred\")\n}\n\n// Console roles (§3.2): owner = edit everything + manage members/org info (an\n// org can have several owners); collaborator = create/edit/run agents, manage\n// sessions; viewer = read-only.\nenum OrgRole {\n owner\n collaborator\n viewer\n}\n\n// Per-resource visibility (docs/designs/resource-visibility.md §1): 'org' (default)\n// = visible to every org member; 'restricted' = the complete non-empty\n// `sharedWith` audience. Enforced ONLY on console read/write paths —\n// never crosses the daemon↔CP wire (a restricted-but-active resource still runs).\nenum ResourceVisibility {\n org\n restricted\n}\n\n// Directional agent-call policy. The inbound fields control which peers may call\n// THIS agent; the outbound fields control which peers this agent may discover/call.\n// Separate from ResourceVisibility, which governs human console access.\nenum AgentCallPolicy {\n all\n selected\n}\n\nmodel Membership {\n id String @id @default(cuid())\n orgId String\n userId String\n role OrgRole @default(collaborator)\n // When this user joined THIS org — not their account signup. The console's\n // \"joined\" column reads it, and removal picks the ownership-transfer\n // recipient by it (resource-visibility.md §8.2). Pre-existing rows were\n // backfilled from the cuid embedded in `id`, which is that same instant.\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n // The last time THIS user selected THIS org in the console. Nullable keeps\n // existing memberships in their original insertion order until a choice is made.\n lastSelectedAt DateTime? @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([orgId, userId])\n @@index([userId])\n @@map(\"membership\")\n}\n\n// One shareable join link per org. The token is stored as a peppered hash and\n// always grants collaborator for exactly seven days; neither is configurable.\nmodel OrgInviteLink {\n id String @id @default(cuid())\n orgId String @unique\n tokenHash String @unique\n displayTail String\n expiresAt DateTime @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"OrgInviteLinkCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n redemptions OrgInviteRedemption[]\n\n @@map(\"org_invite_link\")\n}\n\n// Persists use even after membership removal, so the same account cannot use\n// the same link to restore its own access. A newly generated link has a new id.\nmodel OrgInviteRedemption {\n inviteLinkId String\n userId String\n redeemedAt DateTime @default(now()) @db.Timestamptz(6)\n\n inviteLink OrgInviteLink @relation(fields: [inviteLinkId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([inviteLinkId, userId])\n @@index([userId])\n @@map(\"org_invite_redemption\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// WaitlistEntry — closed-beta admission\n// One row per activation link. TWO write sides share this table (§7):\n// • the EXTERNAL admin app writes approval/mint fields (name, email, status,\n// note, source, tokenHash, displayTail, joinExpiresAt, revokedAt, approved*)\n// — enforced by a column-level least-privilege DB role granted in the migration;\n// • the CP writes ONLY the redemption fields (redeemed*), in the same\n// transaction that sets User.activatedAt.\n// `email` is OPTIONAL and drives the ONE redemption rule (§6): a row WITH an email\n// may be redeemed ONLY by that verified email (strong binding); a row with NO email\n// is a one-time BEARER link — any verified identity may redeem it once, and the\n// redeemer's email is recorded in `redeemedEmail`. `email` stays `@unique` so real\n// emails don't collide and self-signup upserts still work; Postgres treats NULLs as\n// distinct, so any number of bearer rows (email null) coexist.\n// The join link reuses OrgInviteLink's peppered-hash + tail + expiry/revoke\n// shape, but the token is minted by the admin app and only VERIFIED (hashed &\n// compared) by the CP on redeem. Minting/approval/admin-auth are out of this\n// repo's scope; the CP owns the schema + migrations for its application DB.\n// ───────────────────────────────────────────────────────────────────────────\n\nenum WaitlistStatus {\n pending // user self-submitted, awaiting review\n approved // admin approved = whitelisted; a join link has been minted\n rejected // admin rejected\n}\n\nmodel WaitlistEntry {\n id String @id @default(cuid())\n name String? // display name for the applicant / invitee (admin- or intake-supplied)\n email String? @unique // normalized lowercase (auth.ts); NULL ⇒ bearer link\n status WaitlistStatus @default(pending)\n note String? // applicant message / admin note\n source String? // 'self' | 'admin' | …\n\n // ── join link (one per email; minted by the admin app on approve) ──\n tokenHash String? @unique // peppered hash; plaintext only ever in the mint response\n displayTail String? // display-only tail for reconciliation\n joinExpiresAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n\n // ── approval / redemption audit ──\n approvedByUserId String?\n approvedAt DateTime? @db.Timestamptz(6)\n redeemedByUserId String? // written ONLY by the CP redeem path\n redeemedAt DateTime? @db.Timestamptz(6) // written ONLY by the CP redeem path\n redeemedEmail String? // the redeemer's verified email; written ONLY by the CP redeem path\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@map(\"waitlist_entry\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.3 Daemon — fleet registry & fencing root (C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum DaemonStatus {\n provisioned\n authenticating\n ready\n draining\n unreachable\n disabled\n}\n\nenum HealthState {\n ok\n degraded\n}\n\nmodel Daemon {\n id String @id @db.Uuid // AuthReq.daemonId (wire UUID)\n orgId String\n host String? // RegisterReq.host (display)\n name String? // human-assigned display name (set via the console, never by the daemon)\n agentVersion String?\n machineId String? @db.Uuid // 🅼 AuthReq.machineId (stored, stub-enforced)\n tokenFp String? // `id` of the authenticating ApiKey (audit; written by upsertOnAuth)\n attestationFp String? // 🅼 last accepted attestation digest\n capabilities Json @default(\"{}\") @db.JsonB // {platforms[],runtimes[],acp,features[]}\n mcpServers Json @default(\"[]\") @db.JsonB // facts/daemon-runtimes MCP-server list (FactsMcpServer[] = {name,transport}); replaced whole per frame\n maxAgents Int @default(0)\n\n createdByUserId String? // WebUI user who provisioned the daemon (immutable audit; null for CLI/self-registered). Surfaced in the console \"Created\" row.\n\n // ── last-modification audit (human edits only) ──\n // Stamped together on user-initiated writes (provision, rename). Deliberately\n // SEPARATE from `updatedAt`, which the row bumps on every system write\n // (heartbeat, (re)auth, register, watchdog) and so cannot mean \"last human edit\".\n lastModifiedByUserId String? // WebUI user who last edited the daemon (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each human edit\n\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n\n // ── console-set daemon settings ──\n // Retention window for FINISHED sessions on the daemon's LOCAL store (the console's\n // \"Expire sessions\" option): 'never' | '7d' | '30d' | '90d'. The CP only stores and\n // delivers it (register/ok baseline + config/push hot update); the daemon's hourly\n // retention sweep is what actually deletes expired sessions.\n sessionRetention String @default(\"7d\")\n\n // ── fencing root ──\n sessionEpoch BigInt @default(0) @db.BigInt // bumped each successful (re)auth\n routingEpoch BigInt @default(0) @db.BigInt // version of THIS daemon's assignment set\n\n // Last applied `facts/daemon-runtimes.seq` (per-connection monotonic; reset to\n // NULL on register). Snapshots with an older seq are dropped so interleaved\n // frame transactions cannot commit out of order (runtime-model-catalog.md §5).\n runtimesSnapshotSeq Int?\n\n // ── liveness / watchdog ──\n status DaemonStatus @default(provisioned)\n health HealthState @default(ok)\n load Json? @db.JsonB // Heartbeat.load {cpu,mem,agents}\n activeSessions Int @default(0)\n degradedScopes String[] @default([]) // Heartbeat.degradedScopes\n lastSeenAt DateTime? @db.Timestamptz(6) // drives watchdog\n unreachableAt DateTime? @db.Timestamptz(6) // reassignGrace clock origin\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Restrict)\n createdBy User? @relation(\"DaemonCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"DaemonModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n agents Agent[]\n assignments Assignment[]\n leases SecretLease[]\n launches AgentLaunch[]\n runtimeProfiles RuntimeProfile[]\n apiKeys ApiKey[]\n sessions SessionMeta[]\n lifecycleOps DaemonLifecycleOp[]\n webchatMcpDelegations WebchatMcpDelegation[]\n\n @@index([orgId])\n @@index([status])\n @@index([lastSeenAt])\n @@map(\"daemon\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// DaemonLifecycleOp — a CP-commanded restart/upgrade in flight (cli-daemon-split.md §7)\n// A console \"Restart\"/\"Upgrade\" opens a `pending` row and sends the C→D\n// `daemon/restart` / `daemon/upgrade` REQ. The `DaemonControlAck` only means the\n// daemon accepted the command; the real outcome is closed out-of-band when the\n// daemon drains, its supervisor relaunches it, and it re-registers READY (an\n// upgrade additionally requires its reported `agentVersion` to reach the target\n// within the deadline). A decline (`accepted:false`) or a `deadline` lapse closes\n// the row `failed`. At most one op may be pending per daemon (partial unique index,\n// hand-added in the migration — not faithfully expressible in the Prisma schema).\n// ───────────────────────────────────────────────────────────────────────────\n\nenum DaemonLifecycleOpType {\n restart\n upgrade\n}\n\nenum DaemonLifecycleOpStatus {\n pending\n succeeded\n failed\n}\n\nmodel DaemonLifecycleOp {\n id String @id @default(cuid())\n daemonId String @db.Uuid\n op DaemonLifecycleOpType\n targetVersion String? // the version to reach (upgrade only); null for restart\n initiator String? // app_user.id that commanded it; null under devAuth / system\n status DaemonLifecycleOpStatus @default(pending)\n // The daemon `sessionEpoch` at command-send time. The op only settles on a READY\n // whose epoch is STRICTLY GREATER (the daemon re-authed after draining + relaunching),\n // so a coincidental reconnect at the same epoch can never close it.\n commandEpoch BigInt @default(0) @db.BigInt\n // Set when the daemon ACKs `accepted:true` (the op is \"armed\"). A READY before this\n // must NOT settle the op — the command hadn't been accepted/executed yet.\n acceptedAt DateTime? @db.Timestamptz(6)\n startedAt DateTime @default(now()) @db.Timestamptz(6)\n deadline DateTime @db.Timestamptz(6) // drain+relaunch budget; a lapse closes the op `failed`\n outcome String? // short closure detail (decline reason / \"version mismatch\" / \"expired\")\n settledAt DateTime? @db.Timestamptz(6) // when it left `pending`\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@index([daemonId, status])\n @@map(\"daemon_lifecycle_op\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.3a ApiKey — long-lived, revocable control-channel credential (C4)\n// ───────────────────────────────────────────────────────────────────────────\n// One table with a `principalType` discriminator serves daemon, personal-user,\n// relay, and OAuth credentials. Hash-only at rest: the token\n// a bare opaque `<secret><crc>` is looked up by `hash = HMAC-SHA256(secret, API_KEY_PEPPER)`\n// (@unique); the plaintext is shown exactly once at mint and never persisted.\n// See docs/designs/daemon-api-key-auth.md.\n\nenum PrincipalType {\n daemon\n user\n relay // relay↔CP credential (shared-bot-relay.md §8) — org-less infra key\n oauth // access token minted by the embedded OAuth AS (agent-assistant.md §7) — same shape as a user key\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n principalType PrincipalType // daemon | user | relay | oauth\n orgId String? // org-scoped for daemon/user/oauth keys; NULL for relay keys\n daemonId String? @db.Uuid // set iff principalType=daemon\n userId String? // set iff principalType=user or oauth\n\n hash String @unique // HMAC-SHA256(secret, pepper) hex — NEVER plaintext; the unique lookup key\n displayTail String // \"…a2b1\" (non-secret) for the console\n name String? // human label (\"ci-runner-east\")\n scopes String[] @default([]) // daemon/user/relay keys use []; oauth keys carry granted mcp:* scopes\n createdByUserId String? // operator who minted it (audit)\n oauthGrantId String? // set iff principalType=oauth — links the access token to its OAuthGrant so revoking the grant kills its tokens\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n lastUsedAt DateTime? @db.Timestamptz(6) // throttled write on auth\n expiresAt DateTime? @db.Timestamptz(6) // long-lived principals may be non-expiring; user/oauth mint policies set TTLs\n revokedAt DateTime? @db.Timestamptz(6) // kill switch — checked on every auth\n revokedReason String?\n\n org Org? @relation(fields: [orgId], references: [id], onDelete: Cascade)\n // Cascade: deleting a daemon (DELETE /daemons/:id) removes its keys, so no\n // orphaned credential rows (daemonId-null but still hash-valid) survive.\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([daemonId])\n @@index([userId])\n @@index([orgId, revokedAt])\n @@index([oauthGrantId])\n @@map(\"api_key\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Embedded OAuth 2.1 Authorization Server (docs/designs/agent-assistant.md §7)\n// Lets MCP clients (claude.ai / Claude Code) do the browser login flow against\n// the CP. The CP issues its OWN tokens (access token = an `api_key` row with\n// principalType='oauth'); the human login on /authorize is delegated to the web\n// console (Logto / devAuth). These tables hold only the AS's own protocol state.\n// ───────────────────────────────────────────────────────────────────────────\n\n// A dynamically-registered (RFC 7591) MCP client. Public clients only (PKCE is the\n// proof; no client secret). Reaped after `expiresAt` to bound DCR-table growth.\nmodel OAuthClient {\n clientId String @id // AS-generated opaque id\n clientName String?\n redirectUris String[] @default([])\n grantTypes String[] @default([]) // e.g. [\"authorization_code\",\"refresh_token\"]\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // DCR registration TTL (default 90d)\n\n @@index([expiresAt])\n @@map(\"oauth_client\")\n}\n\n// A single-use authorization code, issued AFTER the user consents on the console\n// and exchanged at /token. Hash-only at rest (like api_key). Bound to the PKCE\n// challenge + the consenting user/org so the client cannot forge identity.\nmodel OAuthCode {\n codeHash String @id // HMAC-SHA256(secret, pepper) hex of the code\n clientId String\n redirectUri String\n userId String\n orgId String\n scopes String[] @default([])\n codeChallenge String\n codeChallengeMethod String // always \"S256\"\n resource String? // RFC 8707 audience the client requested\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // short (≈60s)\n consumedAt DateTime? @db.Timestamptz(6) // set on first exchange — single-use guard\n\n @@index([expiresAt])\n @@map(\"oauth_code\")\n}\n\n// A persisted authorization grant = the refresh-token state for one (user, org,\n// client). Refresh tokens rotate on every use; the previous generation stays valid\n// for one more use (workers-oauth-provider's fix for the lost-response deadlock).\nmodel OAuthGrant {\n id String @id @default(cuid())\n userId String\n orgId String\n clientId String\n scopes String[] @default([])\n resource String?\n rtHash String? @unique // current refresh-token hash (rotating)\n prevRtHash String? // previous generation — still redeemable once\n rtExpiresAt DateTime? @db.Timestamptz(6) // refresh inactivity expiry (default 30d, slid on use)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n lastUsedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6) // \"disconnect\" — also cascade-revokes its access tokens\n\n @@index([userId])\n @@index([prevRtHash])\n @@map(\"oauth_grant\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.4 RuntimeProfile — observed runtime capabilities (C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AcpSupport {\n full\n partial\n none\n}\n\nmodel RuntimeProfile {\n id String @id @default(cuid())\n daemonId String @db.Uuid\n runtime String // \"claude\" / \"codex\"\n version String\n models String[] @default([])\n contextWindow Int?\n acpSupport AcpSupport @default(none)\n acpProtocolVersion Int? // ACP protocol version negotiated at initialize\n toolCalling Boolean @default(false)\n mcpCapabilities Json? @db.JsonB // MCP transports advertised at initialize {http,sse}; null ⇒ not probed (assume stdio-only)\n modelCatalog Json? @db.JsonB // wire RuntimeModelCatalog verbatim (runtime-model-catalog.md §5); null ⇒ no catalog reported\n modelsSource String? // provenance of models[]: 'cached' | 'probed'; null ⇒ older daemon (probed semantics)\n authRequired Boolean @default(false) // last probe hit ACP auth-required (-32000): installed but needs a login on the daemon host\n observedAt DateTime @default(now()) @db.Timestamptz(6)\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@unique([daemonId, runtime])\n @@map(\"runtime_profile\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.5 Workspace mode — INLINE on Agent (no standalone entity).\n// ───────────────────────────────────────────────────────────────────────────\n// The agent's working dir is daemon-generated; the caller picks one of two modes\n// (mirrors protocol AgentWorkspace). `github` clones gitRepo@gitBranch and runs the\n// agent in `agentDir` (a subdir); multiple agents may share a repo by differing\n// `agentDir`, so workspace config lives per-agent on the Agent row below.\nenum WorkspaceMode {\n scratch // fresh empty working dir, no repo\n github // clone gitRepo @ gitBranch, run in agentDir\n}\n\nenum WorkspaceIsolation {\n shared // every session uses the primary checkout\n session // each logical session uses its own git worktree\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.6 Agent — agent definition & capability pin (C6 + C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AgentStatus {\n active\n inactive\n paused\n}\n\nmodel Agent {\n id String @id @db.Uuid // wire UUID across route/*, agent/*, event/session\n orgId String\n name String // slug — lowercase [a-z0-9-]; unique per org; the daemon-facing handle\n displayName String? // human-readable original (\"Acme Network Bot\"); the console derives the slug from it\n // Console avatar descriptor (protocol AgentIcon): {kind:'runtime'} | {kind:'glyph',glyph,color} |\n // {kind:'image',url}. Null ⇒ legacy default = the runtime mark. New agents are created with a\n // random glyph+color. Rendered by GET /v1/agents/:id/icon and used as the Slack per-message icon_url.\n icon Json? @db.JsonB\n description String? // system prompt seed → AgentLaunch.spec.description; daemon appends to standing prompt\n // Nullable = \"deferred exec config\" (preset-agents.md §3.2): an agent may exist\n // UNPLACED with no runtime chosen yet; the invariant moves to placement, which\n // requires one. When set it must be in Daemon.capabilities.runtimes.\n runtime String?\n status AgentStatus @default(inactive)\n daemonId String? @db.Uuid // 1 agent : 1 machine (null until placed)\n // ── workspace (inline; §3.5) — daemon-generated path ──\n workspaceMode WorkspaceMode @default(scratch)\n workspaceIsolation WorkspaceIsolation @default(shared)\n gitRepo String? // github mode: e.g. github.com/acme/infra\n gitBranch String? @default(\"main\")\n agentDir String? // github mode: subdir within the repo (repo-root if null)\n // github-app credential mode (docs/designs/github-app-git-credentials.md).\n // `installationId` = GithubInstallation.id picked at create time — a PROVENANCE\n // HINT only, deliberately NO relation/FK: minting re-resolves the live\n // installation by repo owner every time, so an uninstall→reinstall (new GitHub\n // installation id) self-heals without touching agents. Null ⇒ anonymous git.\n installationId String?\n // GitHub's numeric repository id for the workspace repo. Unlike gitRepo,\n // this survives rename and is the authority for repo-scoped effects.\n // Nullable for pre-migration/anonymous workspaces and lazily repairable.\n workspaceRepoId BigInt?\n gitAccess GitAccess @default(write) // ceiling for minted tokens (contents read|write)\n capabilities String[] @default([]) // → AgentLaunch.activeCapabilities\n permissions Json @default(\"{}\") @db.JsonB // {policy:\"ask\",autoApprove:[...]}\n runtimeOverrides Json? @db.JsonB // {model, reasoningEffort, outputMode, fastMode, env{K:V}, mcpServers[], skills[]} — NO secret values (those live in agent_secret)\n managedSkills String[] @default([]) // centrally accepted managed_skill ids, explicitly enabled\n createdByUserId String? // WebUI user who created the agent (immutable audit; null for daemon/CLI-created). Surfaced in the console \"Created\" row.\n // Last-modification audit (human edits only): stamped on create + PATCH. Kept\n // separate from `updatedAt`, which also bumps on system writes (e.g. placement).\n lastModifiedByUserId String? // WebUI user who last edited the agent (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each PATCH\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n // ── inbound agent-call policy (UI: Agent visibility) ──\n callPolicy AgentCallPolicy @default(all) // 'all' = any org peer agent may call this as a sub-agent\n allowedCallerAgentIds String[] @default([]) // agent.id set used when callPolicy='selected'\n // ── outbound agent-call policy (UI: Agent visibility) ──\n outboundPolicy AgentCallPolicy @default(all) // 'all' = this agent may discover/call any otherwise-callable org peer\n allowedTargetAgentIds String[] @default([]) // agent.id set used when outboundPolicy='selected'\n introduceOnJoin Boolean @default(false) // #536: self-introduce to peers on a genuine channel join\n runInSandbox Boolean @default(false) // #642: request an OS sandbox; daemon policy may force it on\n // Monotonic revision of the fully resolved AgentSpec (organization-secrets-and-\n // variables.md §5). NOT environment-specific: every durable mutation that can\n // change a CP-owned field assembled into the spec bumps it through the same\n // writer, so there is ONE ordering domain per agent rather than competing\n // revisions per feature area. The daemon refuses a snapshot older than the\n // greatest it applied, which is what makes full-map env/secret replacement safe.\n configRevision BigInt @default(0)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"AgentCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"AgentModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n assignments Assignment[]\n launches AgentLaunch[]\n sessions SessionMeta[]\n sessionUsage SessionUsage[]\n sessionSpend SessionSpend[]\n crons CronDef[]\n hooks HookDef[]\n integrations Integration[]\n repoAuths AgentRepoAuthorization[]\n secrets AgentSecret[]\n webchatConversations WebchatConversation[]\n webchatParticipations WebchatConversationAgent[]\n webchatMcpDelegations WebchatMcpDelegation[]\n presetRecords PresetAgent[]\n\n organizationEnvironmentAssignments OrganizationEnvironmentAssignment[]\n\n @@unique([orgId, name]) // slug is unique within an org\n // Referenced by the composite assignment FK: a binding cannot name an agent in\n // another organization (organization-secrets-and-variables.md §5).\n @@unique([id, orgId])\n @@index([orgId])\n @@index([daemonId])\n @@map(\"agent\")\n}\n\n// Which preset an org-level preset_agent row describes (preset-agents.md §3).\n// `general` (the `agentconnect` dev agent) is the ONLY preset: the dedicated\n// assistant preset was cancelled — assistant/admin capabilities are planned to\n// fold into the general agent's webapp sessions instead. Additive enum if a\n// new preset ever ships.\nenum PresetAgentKind {\n general\n}\n\nenum PresetAgentState {\n created // the agent row exists — written in the same transaction\n skipped // permanently not created (backfill slug collision, or org opt-out)\n}\n\n// Per-preset provisioning state (preset-agents.md §3.2). The row IS the\n// idempotency marker: creation (org-creation seam or one-time backfill) writes it\n// transactionally with the agent row, and a deleted preset is never recreated\n// because creation has no later trigger — the row remains as the record the\n// onboarding checklist derives from. `placementSettledAt` is stamped by the FIRST\n// placement of any kind (or an explicit opt-out) so auto-placement (M1) never\n// fights a user who unplaced or moved the agent.\nmodel PresetAgent {\n orgId String\n preset PresetAgentKind\n agentId String? @db.Uuid // null once the agent is deleted (SetNull) or when skipped\n status PresetAgentState\n placementSettledAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)\n\n @@id([orgId, preset])\n @@index([agentId])\n @@map(\"preset_agent\")\n}\n\n// One write-only secret env var of an agent, row-per-key. BotSecret discipline:\n// list/DTO queries never join it — key NAMES are read via AgentSecretStore.keys\n// (values untouched), values ONLY via AgentSecretStore.get on the wire-projection\n// paths (agent/upsert, register/ok roster, agent/activate). The store seam is the\n// single read/write path, so the configured SecretCipher transforms every value;\n// an encrypting provider supplies at-rest encryption while `none` is identity.\nmodel AgentSecret {\n agentId String @db.Uuid\n key String // env var name (validated at the API edge)\n value String // passes through the SecretCipher seam (plaintext under the identity cipher)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, key])\n @@map(\"agent_secret\")\n}\n\n/// Access level of an AgentRepoAuthorization row. Three tiers instead of\n/// GitAccess's two: `comment` (contents:read + issues/PR:write) is the github\n/// hook write-back shape — two tiers would force granting contents:write just\n/// to let the agent comment (agent-multi-repo-authorization.md decision 3).\nenum RepoAccess {\n read // contents/issues/PR all read — reference repo\n comment // contents:read, issues/PR write — watch + write-back\n write // all write — secondary working repo\n}\n\n/// Explicit grant of a GitHub repo to an agent (issue #457,\n/// agent-multi-repo-authorization.md). Anchored on the agent, NOT on hooks —\n/// creating a hook must never silently widen credentials. `repoId` (numeric,\n/// rename-immune) is the match key; `repoFullName` is display + the request\n/// fast-path. The covering installation is deliberately NOT bound here: minting\n/// re-resolves the live installation by repo owner (gitcred decision 7), so an\n/// uninstall→reinstall self-heals. Rows are mutable (add/remove) — the\n/// workspace-immutability convention is untouched.\nmodel AgentRepoAuthorization {\n id String @id @default(uuid()) @db.Uuid\n agentId String @db.Uuid\n repoId BigInt // GitHub numeric repo id — the match key\n repoFullName String // \"owner/repo\" — display + case-insensitive fast-path; refreshed on rename detection\n access RepoAccess\n createdByUserId String? // audit: who authorized (the identity-assertion subject)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"AgentRepoAuthCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n\n @@unique([agentId, repoId])\n @@index([agentId])\n @@map(\"agent_repo_authorization\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.7 Assignment — the routing table (session ownership + fencing) (C3)\n// ───────────────────────────────────────────────────────────────────────────\n\n// The persisted chat-platform id is TEXT, not an enum (integration-plugin-architecture.md\n// §11): a new platform id must be storable without a migration once the platform registry\n// lands. The closed-set guard moved to the application layer — `toDbPlatform`\n// (persistence/platform.ts) still refuses ids outside the served set, fail-closed, until\n// the registry replaces it. `session_meta.platform` was already text; these columns joined\n// it in the S1b migration.\n\nenum AssignmentState {\n active\n draining\n released // released = drain/done (reassignable under NEW epoch)\n frozen\n}\n\nmodel Assignment {\n id String @id @default(cuid())\n platform String\n channel String\n thread String?\n threadKey String @default(dbgenerated(\"(COALESCE(thread, ''::text))\")) // STORED generated col, added in migration SQL (§3.13); read-only\n agentId String @db.Uuid\n daemonId String? @db.Uuid // null while released/unplaced\n workspaceId String // opaque scope id on the wire (RouteAssign.workspaceId); now = agentId (workspace is inline)\n\n // ── fencing ──\n assignedEpoch BigInt @db.BigInt // Daemon.sessionEpoch at assign time (ControlExt.epoch)\n assignedSeq BigInt? @db.BigInt // per-agent seq of the route/assign frame\n routingEpoch BigInt @db.BigInt // table version this row reflects\n state AssignmentState @default(active)\n bindRules Json @default(\"[]\") @db.JsonB // RouteAssign.bindRules[]\n releasedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n\n @@index([daemonId, state]) // register/ok reconcile: active set for a daemon\n @@index([agentId])\n @@index([platform, channel, threadKey])\n @@map(\"assignment\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.8 SessionMeta — converged session milestones (NO bodies) (C6 + dashboard)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum SessionPhase {\n start\n plan\n problem\n end\n}\n\nenum ActivityState {\n thinking\n tool_call\n awaiting_permission\n idle\n}\n\n// Per-session visibility tier (docs/designs/session-visibility.md §1): 'org'\n// (default) = visible to every organization member; 'private' = session owner\n// (identity match) ONLY — no role override, org owners included; 'external' =\n// current provider audience. Session reads are independent from owning-Agent\n// Team visibility. Share-by-link is deliberately NOT a member of this enum (§8).\nenum SessionVisibility {\n private\n org\n external\n}\n\nenum ExternalResolution {\n pending\n settled\n invalid\n}\n\nenum ExternalAccessPolicyState {\n disabled\n enabling\n enabled\n degraded\n}\n\n// How a session's visibility was determined (session-visibility.md §3, §4.5).\n// The A2A reconciliation state machine: settlement flips inherited_pending →\n// inherited iff the row is still pending (CAS on this column); 'explicit' pins\n// the row against settlement but NOT against a tightening cascade (privacy wins).\nenum VisibilitySource {\n default // classified by the §4.2 ingest rules\n inherited_pending // A2A child awaiting parent resolution (§4.5)\n inherited // settled from (or cascaded by) its parent\n explicit // set by a human via §4.3\n}\n\nmodel SessionMeta {\n id String @id // EventSession.sessionId = ACP session id (agent-assigned string, NOT a UUID)\n parentSessionId String? // Parent ACP session id reported by the daemon; no FK because parent metadata may arrive later or live on another daemon\n agentId String @db.Uuid\n launchId String? @db.Uuid // CP launch fence; null for Slack/Discord-created sessions (no CP launch)\n platform String? // denormalized sessionKey echo for dashboard filters (free string so webchat can be listed)\n channel String?\n thread String?\n // Durable workspace/tenant scope (EventSession.transportScope — a Slack team\n // id, Feishu tenant key, or stable per-integration mint), persisted so the\n // conversation grouping key (merged-conversation-view.md §5.1) can tell\n // installations apart. NOT the daemon's credential-derived transport scope.\n tenantScope String?\n phase SessionPhase @default(start)\n link String? // deep-link (NOT a body)\n summary String? @db.Text // short milestone text (NOT the stream)\n title String? @db.Text // daemon-derived display title (NOT a body)\n status String?\n triggeredBy String?\n channelName String?\n triggeredByName String?\n threadUrl String? @db.Text\n // ── execution-config snapshot (what the session actually ran with) ──\n // Daemon-reported via event/session (session override ?? agent config at run\n // time); null ⇒ never reported / the runtime's own default. Recorded so the\n // console shows what a session USED, not the agent's config at view time.\n runtime String?\n model String?\n effort String? // reasoning effort level (runtime-owned vocabulary)\n fastMode Boolean?\n permissionMode String? // runtime permission/approval mode\n outputMode String? // daemon-side output verbosity (low/medium/high)\n daemonId String? @db.Uuid // first daemon that reported the session; immutable content owner, stamped by the CP from the authenticated WS conn, never daemon-echoed\n // Session-pinned checkout choice. Null is a legacy row whose daemon never\n // reported it; `session` identifies a daemon-local worktree eligible for the\n // authorized Workspace viewer.\n workspaceIsolation WorkspaceIsolation?\n activityState ActivityState @default(idle)\n // ── session visibility (docs/designs/session-visibility.md §3) ──\n orgId String // denormalized from agent.orgId at ingest so the org-wide list predicate/index never joins agent\n visibility SessionVisibility @default(org)\n ownerIdentity String? // §2 namespaced identity (`user:<id>` | `<platform>:<scope>:<uid>`); null for automation/legacy/unresolved-owner rows (NOT a §2 owner-orphan, whose tuple is stored but unmatched)\n visibilitySource VisibilitySource @default(default)\n visibilityRev Int @default(0) // dedicated monotonic counter, bumped in the same tx as any visibility change (§5.1)\n visibilityAckedRev Int @default(-1) // daemon-ack watermark: 'applied' once >= visibilityRev; -1 = never acked (rev 0 is a real revision)\n // Shared external input. The first trusted binding is immutable; ownerIdentity\n // is provenance only and never authorizes an external row.\n externalProvider String?\n externalScopeId String? @db.Uuid\n externalResolution ExternalResolution?\n classifiedPolicyRev BigInt? @db.BigInt\n // Provenance for the unresolved set: true when this row was ALREADY unresolved\n // when its policy was enabled. Such a scope only comes back if new trusted\n // activity rebinds the session, so it is expected rather than a fault, and\n // `state` degrades only while an unresolved row WITHOUT this mark exists (a\n // mere COUNT cannot tell the two apart: settling one legacy row would silently\n // absolve a live post-enable failure). Stamped at enable; A2A descendants\n // inherit it with the audience they inherit.\n legacyUnresolved Boolean @default(false)\n // Cursor tokens pass through JavaScript Date (millisecond precision), so the\n // complete keyset tuple must use the same precision in Postgres.\n // ── retention GC receipt (#485, `event/session-purged`) ──\n // When the owning daemon deleted this session's local row (and any per-session\n // worktree) after its retention window. The metadata row deliberately SURVIVES\n // — it is all that remains — so the console can say the transcript was deleted\n // instead of rendering the now-permanently-empty history as \"no messages\".\n // First-wins: an at-least-once re-report keeps the original stamp.\n contentPurgedAt DateTime? @db.Timestamptz(6)\n contentPurgedReason String? // SessionPurgeReason ('retention'); null while not purged\n lastActivityAt DateTime @db.Timestamptz(3)\n startedAt DateTime @default(now()) @db.Timestamptz(3)\n endedAt DateTime? @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n launch AgentLaunch? @relation(fields: [launchId], references: [id], onDelete: SetNull)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n externalScope ExternalScope? @relation(fields: [externalScopeId, orgId, externalProvider], references: [id, orgId, provider], onDelete: Restrict, onUpdate: Restrict)\n\n webchatCurrentFor WebchatConversation[] @relation(\"WebchatCurrentSession\")\n webchatAgentCurrentFor WebchatConversationAgent[] @relation(\"WebchatAgentCurrentSession\")\n\n @@index([agentId, startedAt])\n @@index([lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc), agentId], map: \"session_meta_activity_page_idx\")\n @@index([agentId, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_activity_page_idx\")\n @@index([agentId, platform, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_platform_page_idx\")\n @@index([agentId, channel, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_channel_page_idx\")\n @@index([agentId, triggeredBy, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_trigger_page_idx\")\n @@index([orgId, visibility, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_org_visibility_page_idx\")\n @@index([parentSessionId])\n // Conversation grouping (merged-conversation-view.md §5.2): serves the\n // emit-at-max exists-newer probe, the member backfill, and the\n // conversationKey resolver. Replaces the unused bare (platform, channel).\n @@index([orgId, platform, tenantScope, channel, thread, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_conversation_key_idx\")\n @@index([launchId])\n @@index([daemonId])\n @@index([orgId, externalProvider, externalScopeId])\n @@map(\"session_meta\")\n}\n\n// Stable provider resource referenced by SessionMeta. Provider ACLs themselves\n// are never copied here; access is resolved at read time and only short-lived\n// decisions are cached in process memory.\nmodel ExternalScope {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n provider String\n realmKey String\n resourceKind String\n resourceKey String\n credentialKind String?\n credentialId String?\n aclRevision BigInt @default(0) @db.BigInt\n revokedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n sessions SessionMeta[]\n\n @@unique([id, orgId, provider])\n @@unique([orgId, provider, realmKey, resourceKind, resourceKey])\n @@index([credentialKind, credentialId])\n @@map(\"external_scope\")\n}\n\n// Organization/provider policy. Missing is never interpreted as disabled: the\n// ingest transaction ensures this row before creating a supported candidate.\nmodel SessionExternalAccessPolicy {\n orgId String\n provider String\n state ExternalAccessPolicyState @default(disabled)\n currentRev BigInt @default(0) @db.BigInt\n readFenceRev BigInt? @db.BigInt\n migrationCursor String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@id([orgId, provider])\n @@map(\"session_external_access_policy\")\n}\n\n// Durable ownership metadata for a browser webchat conversation. The message\n// stream and transcript remain daemon-local; this row only lets the CP prove\n// that a requested resume belongs to the authenticated human, agent, and org.\nmodel WebchatConversation {\n id String @id @db.Uuid\n orgId String\n agentId String @db.Uuid\n userId String\n delegationGeneration Int @default(0)\n // The exact ACP session currently installed for this conversation — the\n // current-session fence for remote MCP authorization. Maintained ONLY by the\n // session-milestone upsert, transactionally, under a lock on this row, so a\n // replacement-session insert serializes with every authorization read that\n // locks the conversation. `endedAt` cannot express this (\"end\" is stamped\n // after every turn); identity must be explicit. SetNull fails closed if the\n // session row disappears.\n currentSessionId String?\n currentSessionRev Int @default(0) // bumped in the same tx as any pointer change\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n currentSession SessionMeta? @relation(\"WebchatCurrentSession\", fields: [currentSessionId], references: [id], onDelete: SetNull)\n delegations WebchatMcpDelegation[]\n mcpOperations WebchatMcpOperation[]\n participants WebchatConversationAgent[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([userId])\n @@index([currentSessionId])\n @@map(\"webchat_conversation\")\n}\n\n// One participant agent of a webchat conversation (webchat-multi-agents.md §3.1).\n// The roster is fixed at creation; `WebchatConversation.agentId` always mirrors\n// the `role='primary'` row. `ord` preserves the pick order (primary is ord 0).\n// Each participant carries its OWN current-session pointer, maintained by the\n// session-milestone upsert exactly like the conversation-level fence.\nmodel WebchatConversationAgent {\n conversationId String @db.Uuid\n agentId String @db.Uuid\n role String @default(\"member\") // 'primary' | 'member'\n ord Int @default(0)\n addedByUserId String\n addedAt DateTime @default(now()) @db.Timestamptz(6)\n currentSessionId String?\n currentSessionRev Int @default(0)\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n currentSession SessionMeta? @relation(\"WebchatAgentCurrentSession\", fields: [currentSessionId], references: [id], onDelete: SetNull)\n\n @@id([conversationId, agentId])\n @@index([agentId])\n @@index([currentSessionId])\n @@map(\"webchat_conversation_agent\")\n}\n\n// Durable, generation-fenced authority for one browser conversation to invoke\n// curated AgentConnect MCP tools through its currently placed daemon.\nmodel WebchatMcpDelegation {\n id String @id @default(uuid()) @db.Uuid\n conversationId String @db.Uuid\n generation Int\n userId String\n orgId String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n revokedReason String?\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n grants WebchatMcpAccessGrant[]\n\n @@unique([conversationId, generation])\n @@index([conversationId, revokedAt])\n @@index([agentId, revokedAt])\n @@index([expiresAt])\n @@map(\"webchat_mcp_delegation\")\n}\n\nenum WebchatMcpOperationStatus {\n awaiting_confirmation\n executing\n completed\n failed\n ambiguous\n stale\n}\n\nenum WebchatMcpGrantStatus {\n pending\n active\n revoked\n expired\n}\n\n// Short-lived bearer credential for one exact runtime descriptor. Only the\n// peppered token hash is durable; plaintext exists only in the issuance reply.\nmodel WebchatMcpAccessGrant {\n id String @id @default(uuid()) @db.Uuid\n authorityId String @db.Uuid\n descriptorInstanceId String @db.Uuid\n grantRevision Int\n tokenHash String @unique\n status WebchatMcpGrantStatus @default(pending)\n pendingExpiresAt DateTime @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n activatedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n revokedReason String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n authority WebchatMcpDelegation @relation(fields: [authorityId], references: [id], onDelete: Cascade)\n operations WebchatMcpOperation[]\n receipts WebchatMcpTransportReceipt[]\n\n @@unique([descriptorInstanceId, grantRevision])\n @@index([authorityId, status])\n @@index([descriptorInstanceId, status])\n @@index([status, pendingExpiresAt])\n @@index([status, expiresAt])\n @@map(\"webchat_mcp_access_grant\")\n}\n\n// Browser-confirmed logical write. Identity and terminal status live for the\n// conversation lifetime; the bounded response may be evicted independently.\nmodel WebchatMcpOperation {\n id String @id @default(uuid()) @db.Uuid\n conversationId String @db.Uuid\n createdAuthorityGeneration Int\n sourceGrantId String @db.Uuid\n userId String\n toolName String\n canonicalArguments Json @db.JsonB\n intentHash String\n status WebchatMcpOperationStatus @default(awaiting_confirmation)\n executionAttemptId String? @db.Uuid\n claimedAt DateTime? @db.Timestamptz(6)\n recoveryDeadline DateTime? @db.Timestamptz(6)\n boundedResponse Bytes?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n confirmationExpiresAt DateTime @db.Timestamptz(6)\n completedAt DateTime? @db.Timestamptz(6)\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n sourceGrant WebchatMcpAccessGrant @relation(fields: [sourceGrantId], references: [id], onDelete: Restrict)\n receipts WebchatMcpTransportReceipt[]\n\n @@index([conversationId, status, createdAt])\n @@index([status, recoveryDeadline, id])\n @@map(\"webchat_mcp_operation\")\n}\n\n// Standard JSON-RPC retry coordinate. It never authorizes or claims execution.\nmodel WebchatMcpTransportReceipt {\n grantId String @db.Uuid\n jsonRpcRequestId String\n conversationId String @db.Uuid\n requestHash String\n operationId String @db.Uuid\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n supersededAt DateTime? @db.Timestamptz(6)\n\n grant WebchatMcpAccessGrant @relation(fields: [grantId], references: [id], onDelete: Cascade)\n operation WebchatMcpOperation @relation(fields: [operationId], references: [id], onDelete: Restrict)\n\n @@id([grantId, jsonRpcRequestId])\n @@index([operationId])\n @@index([grantId, createdAt])\n @@map(\"webchat_mcp_transport_receipt\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// SessionUsage — per-session token accounting for the console's usage dashboard.\n//\n// Unlike the message stream (daemon-local, never on the CP), token counts + cost\n// ARE dashboard telemetry: the daemon reports each session's CUMULATIVE usage via\n// the `usage/report` EVT and the CP upserts one row per (agentId, sessionId). This\n// is the ONLY historical usage store — the `/usage` aggregates sum over it by\n// time window. `sessionId` is the agent-assigned ACP id (a string, NOT a wire\n// UUID), so the PK is composite with agentId. Latest-wins upsert = idempotent.\n// ───────────────────────────────────────────────────────────────────────────\nmodel SessionUsage {\n agentId String @db.Uuid\n sessionId String // ACP session id (agent-assigned; NOT a wire UUID)\n platform String? // denormalized sessionKey echo (free string, not the Platform enum)\n channel String?\n totalTokens Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n thoughtTokens Int @default(0)\n cachedReadTokens Int @default(0)\n cachedWriteTokens Int @default(0)\n contextUsed Int?\n contextSize Int?\n costAmount Float @default(0)\n costCurrency String?\n startedAt DateTime @default(now()) @db.Timestamptz(6)\n lastActivityAt DateTime @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, sessionId])\n @@index([agentId, lastActivityAt])\n @@map(\"session_usage\")\n}\n\n// SessionSpend — cumulative usage timeline backing range-scoped spend and the\n// durable per-model breakdown.\n//\n// session_usage is a latest-wins lifetime snapshot, so it can't answer \"how much\n// was spent inside this window\": a long-lived session collapses its entire cost\n// into its newest report. This table records the session's CUMULATIVE cost at each\n// report time together with the model observed for that interval. Readers derive\n// token/cost deltas by diffing consecutive cumulatives and attribute each delta to\n// this row's model. Storing cumulatives keeps writes idempotent on\n// (agentId, sessionId, at); null model is an observed or legacy unknown.\nmodel SessionSpend {\n agentId String @db.Uuid\n sessionId String // ACP session id (echo of the snapshot row)\n at DateTime @db.Timestamptz(6) // the report's lastActivityAt — the bucket time\n model String?\n cumulativeTotalTokens Int @default(0)\n cumulativeInputTokens Int @default(0)\n cumulativeOutputTokens Int @default(0)\n cumulativeThoughtTokens Int @default(0)\n cumulativeCachedReadTokens Int @default(0)\n cumulativeCachedWriteTokens Int @default(0)\n cumulativeCost Float // session's cumulative cost as of `at`; window spend = diff of consecutive cumulatives\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, sessionId, at])\n @@index([agentId, at])\n @@map(\"session_spend\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.9 AgentLaunch — launch fencing (C3)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum LaunchMode {\n long_lived\n per_turn\n}\n\nenum LaunchStatus {\n launching\n running\n stopped\n crashed\n}\n\nmodel AgentLaunch {\n id String @id @db.Uuid // AgentLaunched.launchId (the fence value)\n agentId String @db.Uuid\n daemonId String @db.Uuid\n runtime String\n mode LaunchMode @default(long_lived)\n acpSessionId String? // set iff long-lived ACP session\n // Web API launch provenance (session-visibility.md §4.4) — DISTINCT from the\n // fencing `id`: the CP mints it, the daemon echoes it on the session's\n // `event/session` frame, and ingest resolves it back to the launching user.\n correlationId String? @unique @db.Uuid\n createdByUserId String? // launching principal; raw scalar (no FK) so a deleted user never breaks provenance\n activeCapabilities String[] @default([]) // capability pin (AgentCapabilities.active)\n status LaunchStatus @default(launching)\n launchEpoch BigInt @db.BigInt // sessionEpoch the launch was issued under\n startedAt DateTime? @db.Timestamptz(6)\n stoppedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n sessions SessionMeta[]\n\n @@index([agentId, status])\n @@index([daemonId])\n @@map(\"agent_launch\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.10 SecretLease — lease metadata (NO plaintext) (C5)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum LeaseStatus {\n active\n expired\n revoked\n}\n\nmodel SecretLease {\n id String @id @db.Uuid // SecretsGrant.leaseId\n daemonId String @db.Uuid\n scopePlatform String\n scopeWorkspaceId String @db.Uuid // SecretsGrant.scope.workspaceId — opaque scope id; now = agentId (workspace inline)\n ref String // Vault/KMS path/handle — NOT the secret\n ttlSec Int\n renewBeforeSec Int @default(60)\n status LeaseStatus @default(active)\n issuedAt DateTime @default(now()) @db.Timestamptz(6)\n renewedAt DateTime? @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // issuedAt+ttl; advanced on renew\n revokedReason String?\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@index([daemonId, status])\n @@index([status, expiresAt])\n @@map(\"secret_lease\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.11 CronDef — cron definitions (C6 + C3)\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel CronDef {\n id String @id @db.Uuid // CronUpsert.cronId\n orgId String\n agentId String? @db.Uuid // required at the API; null only via agent-delete SetNull (inert until re-assigned)\n name String? // console display name (\"weekly-deploy-report\"); pure console metadata, never on the daemon wire. Null for legacy/CLI rows.\n schedule String // croner expression interpreted in timezone\n timezone String // IANA timezone resolved by the CP before persistence\n targetPlatform String @default(\"slack\")\n targetChannel String? // CronUpsert.target.channel; null ⇒ headless fire (no platform output)\n targetIntegrationId String? @db.Uuid // CronUpsert.target.integrationId — the agent integration posting the anchor; null (legacy / uninstalled) ⇒ daemon falls back to the agent's first integration\n trigger String @db.Text // synthetic trigger text (control metadata)\n enabled Boolean @default(true)\n lastRunAt DateTime? @db.Timestamptz(6) // advisory (cron/report EVT, latest-wins); daemon authoritative\n createdByUserId String? // WebUI user who created the cron (immutable audit; stamped on create only)\n // Last-modification audit (human edits only): stamped on create AND on every\n // edit through the PUT upsert. Separate from `updatedAt`, which also bumps on\n // system writes (e.g. `lastRunAt` advanced by a daemon cron/report).\n lastModifiedByUserId String? // WebUI user who last edited the cron (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each upsert-edit\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)\n targetIntegration Integration? @relation(fields: [targetIntegrationId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"CronCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"CronModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n runs CronRun[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([targetIntegrationId])\n @@map(\"cron_def\")\n}\n\n// One fire of a schedule (`cron/report` EVT pairs keyed on (cronId, startedAt)):\n// the FIRE report opens the row (`running`), the COMPLETION report closes it\n// with outcome + duration + the ACP session to deep-link. `running` rows whose\n// completion report was lost (daemon crashed / CP down at turn end) are\n// reconciled to `failed` (orphaned) by the CronRunReaper once they age past\n// CRON_RUN_TTL_SEC — a late completion still overwrites that with the real\n// outcome (the upsert is last-writer-wins), the daemon remaining authoritative.\nenum CronRunStatus {\n running\n success\n failed\n}\n\nmodel CronRun {\n id String @id @default(cuid())\n cronId String @db.Uuid\n orgId String\n startedAt DateTime @db.Timestamptz(6) // CronReport.firedAt\n status CronRunStatus @default(running)\n durationMs Int?\n sessionId String? // ACP session id (console deep-link); null while running / on legacy reports\n reason String? // short failure text (status \"failed\")\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n cron CronDef @relation(fields: [cronId], references: [id], onDelete: Cascade)\n\n @@unique([cronId, startedAt])\n @@index([cronId, startedAt(sort: Desc)])\n @@map(\"cron_run\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// HookDef — inbound-webhook triggers (webhook-triggers-and-github-events.md)\n// A hook maps an inbound webhook delivery to ONE agent turn. The CP owns the\n// definition and compiles it into relay-side rules (rc/hook-assign, broadcast\n// to the whole pool); the relay is the public ingress — event payloads never\n// touch the CP, these rows are definitions + run metadata only.\n// ───────────────────────────────────────────────────────────────────────────\n\nenum HookKind {\n webhook // generic inbound webhook (POST /webhooks/in/:token on the relay)\n github // GitHub event subscription (P2)\n}\n\nenum HookSessionMode {\n perDelivery // new ACP session per delivery (webhook-kind default)\n perThread // source-thread affinity: github = repo#number (github-kind default & only)\n shared // the whole hook shares one session (webhook-kind opt-in)\n}\n\nenum HookReviewPolicy {\n off\n comment\n request_changes\n full\n}\n\nenum HookReportingMode {\n off\n check\n status\n}\n\nenum HookGateMode {\n informational\n required\n}\n\nmodel HookDef {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n agentId String? @db.Uuid // required at the API; nullable only for legacy inert rows\n kind HookKind\n name String\n enabled Boolean @default(true)\n // No trigger prompt: the agent's description is its standing context and the\n // delivery payload carries the caller's message (design security boundary 1).\n sessionMode HookSessionMode\n // ── kind=webhook ──\n urlToken String? @unique // ≥128-bit random ingress routing key (capability URL; canEdit-visible)\n // hmacSecret lives in HookSecret — never on this row (accidental-serialization guard)\n // ── kind=github (P2) ──\n repoId BigInt? // GitHub numeric repo id — the match key (rename-proof)\n repoFullName String? // \"owner/repo\" — display + create-time validation; never matched on\n githubSessionKey String? // immutable per-thread namespace; existing rows keep their pre-rename owner/repo\n events String[] @default([]) // \"issues:opened\" / \"pull_request:*\" / \"issue_comment:created\"\n commentFamilies String[] @default([]) // empty = legacy repo-wide comments; otherwise issues/pull_request scope\n labelFilter String[] @default([]) // non-empty ⇒ issue/PR must carry one of these labels\n mentionOnly Boolean @default(false) // P3 summon mode: authored event text must @<agent-name> or @<app-slug>\n // Durable configuration/dispatch fences. configRevision changes whenever\n // the compiled definition changes; dispatchRevision additionally changes\n // when the owning agent is re-placed.\n configRevision BigInt @default(1)\n dispatchRevision BigInt @default(1)\n // Changes only when the review projection binding/lifecycle changes\n // (enablement, agent/repo binding, reporting transport, or gate mode). It is\n // part of the projection natural key, so a one-way tombstone from an older\n // lifecycle can never suppress a later explicit re-enable on the same SHA.\n projectionEpoch BigInt @default(1)\n reviewPolicy HookReviewPolicy @default(off)\n reportingMode HookReportingMode @default(off)\n gateMode HookGateMode @default(informational)\n requiredAcknowledgedAt DateTime? @db.Timestamptz(6)\n requiredAcknowledgedByUserId String?\n requiredAcknowledgedConfigRevision BigInt?\n // ── output anchoring (same trio as CronDef.target*; null channel ⇒ headless) ──\n targetPlatform String @default(\"slack\")\n targetChannel String?\n targetIntegrationId String? @db.Uuid\n lastFiredAt DateTime? @db.Timestamptz(6) // advisory; bumped on rc/run-report\n // A hook is subordinate to ONE agent (like an Integration, unlike a CronDef):\n // it is only ever listed under that agent, so it carries NO visibility of its\n // own — access is gated by the owning agent's visibility. createdBy is audit.\n createdByUserId String?\n lastModifiedByUserId String?\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)\n targetIntegration Integration? @relation(fields: [targetIntegrationId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"HookCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"HookModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n secret HookSecret?\n\n @@index([orgId])\n @@index([agentId])\n @@index([kind, repoId]) // github rule-compile main query (P2)\n @@map(\"hook_def\")\n}\n\n// Per-hook HMAC signing key, 1:1 with HookDef. BotSecret discipline: list/DTO\n// queries never join it, it is read only through the HookSecretStore, and it is\n// echoed exactly once in the create response. The stored value passes through\n// SecretCipher: plaintext under `none`, ciphertext under an encrypting provider.\nmodel HookSecret {\n hookId String @id @db.Uuid\n hmacSecret String // stored SecretCipher representation; read ONLY via HookSecretStore\n\n hook HookDef @relation(fields: [hookId], references: [id], onDelete: Cascade)\n\n @@map(\"hook_secret\")\n}\n\n// One webhook delivery that fired (or failed to fire) an agent turn. Two-stage\n// lifecycle: the relay's rc/run-report opens the row (`running` on accepted, or\n// `failed` outright on a delivery failure), the daemon's hook/report completion\n// EVT closes it. `running` rows whose completion was lost are reconciled to\n// `failed` (orphaned) by the HookRunReaper; a late completion still overwrites\n// (last-writer-wins). (hookId, deliveryKey) is the idempotency key absorbing\n// GitHub redeliveries and reconcile re-posts.\nmodel HookRun {\n id String @id @default(cuid())\n hookId String @db.Uuid\n orgId String\n deliveryKey String // X-GitHub-Delivery GUID / X-AC-Delivery-Key / relay-minted uuid\n event String? // github: \"issues:opened\"; webhook kind: null (metadata, never a body)\n startedAt DateTime @db.Timestamptz(6) // RcRunReport.firedAt (relay ingest time)\n // Exact accepted dispatch snapshot. Nullable only for legacy rows whose\n // relay/daemon predates the R1 protocol additions.\n agentId String? @db.Uuid\n configRevision BigInt?\n dispatchRevision BigInt?\n projectionEpoch BigInt?\n dispatchDaemonId String? @db.Uuid\n reviewPolicySnapshot HookReviewPolicy?\n reportingModeSnapshot HookReportingMode?\n gateModeSnapshot HookGateMode?\n projectionIntent String?\n repoId BigInt?\n repoFullName String?\n sourceInstallationId BigInt?\n subjectKind String?\n pullNumber Int?\n headSha String?\n baseSha String?\n reportSha String?\n isDraft Boolean?\n baseChanged Boolean?\n turnStartedAt DateTime? @db.Timestamptz(6)\n completedAt DateTime? @db.Timestamptz(6)\n orphanedAt DateTime? @db.Timestamptz(6)\n projectionId String? @db.Uuid\n projectionGeneration BigInt?\n reviewAttemptId String? @unique @db.Uuid\n reviewAttemptState String?\n reviewErrorCode String?\n reviewId String?\n reviewEvent String?\n verdict String?\n reviewCommitId String?\n publishedCommentKind String?\n publishedCommentId String?\n status CronRunStatus @default(running)\n durationMs Int?\n sessionId String? // ACP session id (console deep-link); null while running\n reason String? // short failure text: daemon_offline / dispatch_timeout / orphaned / turn failure\n // Durable, metadata-only GitHub redelivery schedule. These fields are used\n // only for delivery-stage failures that are explicitly classified as safe\n // to retry; no webhook payload is stored in the control plane.\n redeliveryAttempts Int @default(0)\n redeliveryLastRequestedAt DateTime? @db.Timestamptz(6)\n redeliveryNextAttemptAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@unique([hookId, deliveryKey])\n @@index([orgId])\n @@index([hookId, startedAt(sort: Desc)])\n @@index([hookId, repoId, reportSha, startedAt(sort: Desc)])\n @@index([projectionId, projectionGeneration])\n @@index([status, startedAt])\n @@index([deliveryKey, status, redeliveryNextAttemptAt])\n @@index([status, redeliveryNextAttemptAt, redeliveryAttempts], map: \"hook_run_redelivery_due_idx\")\n @@map(\"hook_run\")\n}\n\n// Durable GitHub Checks/status projection. It deliberately has no FK to\n// HookDef/Agent/Org: cleanup must still run after an owner row is deleted.\nmodel HookReviewProjection {\n id String @id @default(uuid()) @db.Uuid\n hookId String @db.Uuid\n orgId String\n agentId String @db.Uuid\n // Stable, single-line agent slug displayed in the external Check summary.\n // Snapshotted because this projection must survive Agent deletion for cleanup.\n agentName String?\n lastResolvedInstallationId BigInt?\n repoId BigInt\n repoFullName String\n headSha String\n reportSha String\n projectionEpoch BigInt\n\n generation BigInt @default(0)\n currentHookRunId String?\n externalId String @unique\n checkRunId String? @unique\n\n mode HookReportingMode\n gateMode HookGateMode\n desiredState String\n observedState String?\n sealedThrough BigInt @default(0)\n\n // Live commit -> current PR association is evaluated once per generation\n // before a terminal informational Check write. The canonical desired state\n // remains untouched when association fails closed.\n subjectSyncGeneration BigInt @default(0)\n subjectSyncErrorCode String?\n\n leaseOwner String?\n leaseUntil DateTime? @db.Timestamptz(6)\n nextAttemptAt DateTime? @db.Timestamptz(6)\n attempts Int @default(0)\n lastErrorCode String?\n pendingIntent Json? @db.JsonB\n writeMarker String? @unique\n writePhase String?\n writeStartedAt DateTime? @db.Timestamptz(6)\n tombstonedAt DateTime? @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n subjects HookReviewSubject[]\n\n @@unique([hookId, repoId, reportSha, projectionEpoch])\n @@index([orgId])\n @@index([nextAttemptAt, leaseUntil])\n @@index([agentId, repoId])\n @@index([lastResolvedInstallationId])\n @@map(\"hook_review_projection\")\n}\n\nmodel HookReviewSubject {\n projectionId String @db.Uuid\n pullNumber Int\n headSha String\n baseSha String?\n isOpen Boolean @default(true)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n projection HookReviewProjection @relation(fields: [projectionId], references: [id], onDelete: Cascade)\n\n @@id([projectionId, pullNumber])\n @@index([headSha])\n @@map(\"hook_review_subject\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Relay — one registered ingress-relay instance (shared-bot-relay.md §6)\n// The relay pool is the platform's unified inbound plane: webchat now,\n// shared-bot + webhook with milestone B. Instances register dynamically over\n// the relay WS (`rc/register`); `lastSeenAt` is bumped by `rc/heartbeat` and\n// drives the liveness sweeper (stale rows are swept, and — milestone B —\n// their bots reassigned). No org column: relays are deployment-level infra\n// serving every tenant. No FKs and no secret material.\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Relay {\n id String @id @db.Uuid // minted by the CP on rc/register → rc/registered\n // Deployment-side identity (pod name etc.) — THE upsert key: a relay is\n // stateless, so after a restart `name` is its only stable identity and\n // re-registration reclaims the same row (and relayId). Unique so the upsert\n // is atomic (no duplicate rows for one pod racing the sweeper).\n name String @unique\n // The address daemons dial for THIS relay instance. MUST route to this\n // specific instance (per-pod DNS, or one hostname with relay-id-sticky\n // paths) — a pool-level random LB here breaks the no-cross-pod-forwarding\n // topology (design §5). The pool-level public ingress (browser/webhook) is\n // env-level PUBLIC_RELAY_URL and never stored per relay.\n daemonUrl String\n lastSeenAt DateTime? @db.Timestamptz(6) // rc/heartbeat; drives the failover sweeper\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([lastSeenAt])\n @@map(\"relay\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.15 Bot + Integration — a platform bot identity and its install (C6/C5)\n// `bot` is the durable identity (name + token material): it OUTLIVES the\n// integration that installs it, so uninstalling frees the bot for reuse from\n// the console picker instead of forcing a re-create. `bot_secret` holds the\n// stored SecretCipher representation behind the BotSecretStore seam — the ONLY\n// read/write path for tokens. `none` stores plaintext; an encrypting provider\n// stores ciphertext. `integration` is the install: it binds ONE bot to\n// ONE agent (`botId` unique ⇒ a bot serves at most one agent at a time) and\n// is what the owning daemon opens the socket from. The metadata read paths\n// (GET /bots, GET /integrations) never select the secret table.\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Bot {\n id String @id @db.Uuid\n orgId String\n platform String @default(\"slack\")\n name String // Slack app / display name\n prebuilt Boolean @default(false) // provisioned by AgentConnect, not a console user\n slackAppId String? // Slack app id (A…), parsed from the pasted xapp token — deep-links the console to api.slack.com/apps/{id}. Public metadata, NOT secret material.\n // Slack workspace id (\"T…\", == Events API `team_id`), persisted by the platform\n // (distributed) app's OAuth callback. Load-bearing for relay demux: every install\n // of a distributed app shares one app id + signing secret, so only the composite\n // (slackAppId, teamId) identifies a workspace's Bot. NULL for legacy /\n // single-workspace bots. Public metadata, NOT secret material.\n teamId String?\n // External workspace identity used only to label/group bot rows in the Console.\n // Slack fills this from auth.test/OAuth for every bot kind. It is deliberately\n // separate from teamId, whose non-null value marks a distributed platform-app\n // install and participates in relay demux/admission behavior.\n workspaceId String?\n workspaceName String?\n // Slack bot user id (\"U…\"/\"B…\"), from the OAuth exchange (`bot_user_id`). Saves\n // the relay an auth.test round-trip and backs echo suppression. Public metadata.\n botUserId String?\n // Stamped when the workspace uninstalled the app or revoked its tokens\n // (`app_uninstalled` / `tokens_revoked` via rc/bot-revoked). A revoked bot is\n // dead credential-wise; a platform-app re-install clears it (fresh token).\n revokedAt DateTime? @db.Timestamptz(6)\n // Install GENERATION of the bot's CURRENT credential. Slack does not guarantee\n // the ordering of `app_uninstalled` / `tokens_revoked`, so a delayed event from\n // a PRIOR install can land after the workspace re-installed — applying it would\n // revoke the fresh token and its live integrations. Both fields advance together\n // every time a new credential lands (`BotRepo.bumpCredential`): the revision is\n // echoed through rc/bot-assign → rc/bot-revoked so revocation can CAS on it, and\n // the timestamp lets the CP reject an event that HAPPENED before the credential\n // it would kill (the case where the relay already holds the newer assignment).\n credentialRevision Int @default(1)\n credentialInstalledAt DateTime? @db.Timestamptz(6)\n // ── generic bot demux identity (integration-plugin-architecture.md D6/§11) ──\n // Generalizes (slackAppId, teamId): the platform's app-scoped id plus its tenant\n // scope. NULL is reserved for LEGACY rows (pre-capture Slack installs keep\n // today's NULLs-distinct semantics; backfilled tenantless rows keep NULL too); a\n // NEW row on a tenantless platform writes the '-' sentinel so the composite\n // unique below enforces (platform, externalAppId) uniqueness declaratively.\n // Dual-write window: reads still ride the legacy per-platform columns; these are\n // written alongside them until the legacy columns fold away.\n externalAppId String?\n externalTenantId String?\n // Display-only per-platform bag (discordAppId / feishuAppId / feishuRegion fold\n // here when reads switch). Never demux identity, never secret material.\n platformConfig Json?\n discordAppId String? // Discord application (client) id, decoded from the bot token's first segment — lets the console offer a ready-made \"Add to Discord\" invite URL. Public metadata (it IS the bot's user id), NOT secret material.\n feishuAppId String? // Feishu/Lark app id (cli_…), copied from the create request so Settings can deep-link to this app without reading bot_secret. Public metadata, NOT secret material.\n feishuRegion String? // Feishu/Lark open-platform gateway for this app: 'feishu' (open.feishu.cn) | 'lark' (open.larksuite.com). NULL ⇒ 'feishu'. DURABLE home (survives uninstall) so a freed Lark bot reinstalls against the right gateway. Public config, NOT secret.\n // Shared-bot opt-in (shared-bot-relay.md §4.1). false ⇒ classic 1-bot:1-agent\n // (daemon owns the whole socket, zero behaviour change). true ⇒ the bot's\n // INBOUND migrates to a relay and it may serve MULTIPLE agents (one Integration\n // row per agent). Per-bot, not per-install — a uniform rule, no mode switching.\n shareable Boolean @default(false)\n // IM ingress transport. `socket` means the daemon owns the platform's outbound\n // long connection (Slack Socket Mode or Feishu WSClient). `http` means the relay\n // receives callbacks and the daemon gets a send-only spec. This is the\n // direct-vs-shared axis; `shareable` is the multi-agent-within-http sub-flag.\n transport SlackTransport @default(socket)\n createdByUserId String? // WebUI user who registered it (audit)\n lastUsedAt DateTime? @db.Timestamptz(6) // stamped when its integration is removed\n lastAgentName String? // agent the bot was last freed from (console display hint)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret BotSecret?\n // A shareable bot backs MANY installs (one per agent); a classic bot backs ≤1.\n // The 1-install cap for classic bots is enforced in the install route, not by a\n // unique FK (dropped so shared bots can fan out).\n integrations Integration[]\n\n // One Bot per workspace install of a distributed app — the relay demux key.\n // NULL teamIds (legacy bots) are distinct under Postgres semantics, so existing\n // rows are unaffected. Cross-org global: a workspace binds to exactly one org.\n @@unique([slackAppId, teamId])\n // The generic successor of the fence above (D6): one Bot per external app\n // identity per platform. Legacy rows carry NULLs (distinct, unaffected); new\n // tenantless rows carry the '-' sentinel, which makes this enforce\n // (platform, externalAppId) uniqueness. Coexists with the Slack fence during\n // the dual-write window.\n @@unique([platform, externalAppId, externalTenantId])\n @@index([orgId])\n @@map(\"bot\")\n}\n\n/**\n * Inbound transport for a bot. The historical enum name is retained to avoid a\n * destructive PostgreSQL enum rename; see Bot.transport.\n */\nenum SlackTransport {\n socket\n http\n}\n\nmodel BotSecret {\n botId String @id @db.Uuid\n botToken String // xoxb-… / Telegram token in stored SecretCipher form; read ONLY via BotSecretStore\n appToken String? // xapp-… for Slack Socket Mode; Feishu/Lark reuses this slot for its app id; NULL for single-token platforms\n // Slack signing secret — verifies inbound Events API POSTs (HMAC). Lives ONLY here\n // + is shipped to the relay in rc/bot-assign (http transport); daemons never get it.\n signingSecret String? // Slack signing secret (http transport); NULL for socket/Telegram\n // Feishu callback credentials. The verification token is required in HTTP mode;\n // encryptKey is optional and enables signed/encrypted callback bodies. Neither is\n // sent to daemons, while the Feishu app secret remains daemon-only.\n verificationToken String?\n encryptKey String?\n\n bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)\n\n @@map(\"bot_secret\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// SharedThreadAgent (slack-http-mode §10) — durable per-sessionKey thread affinity\n// for http-transport shared bots. A relay reports (botId, sessionKey)→{agentId,\n// daemonId} the first time it routes a thread (rc/thread-assign); the CP persists it\n// here (single writer) and broadcasts it to every relay (rc/assign), and answers a\n// pull-on-miss lookup (rc/thread-lookup). Relay-opaque `sessionKey`; no FKs (daemon/\n// agent may churn — mirrors relay / github_install_state FK-less infra).\n// ───────────────────────────────────────────────────────────────────────────\nmodel SharedThreadAgent {\n botId String @db.Uuid\n sessionKey String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@id([botId, sessionKey])\n @@index([botId])\n @@map(\"shared_thread_agent\")\n}\n\n// Durable multi-agent room membership for relay-pooled HTTP bots. Unlike\n// SharedThreadAgent, this is a set and never changes the compatibility owner.\n// It is routing metadata only: no message body or transcript content is stored.\nmodel SharedThreadParticipant {\n botId String @db.Uuid\n sessionKey String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@id([botId, sessionKey, agentId])\n @@index([botId])\n @@map(\"shared_thread_participant\")\n}\n\nenum IntegrationStatus {\n active\n revoked\n}\n\nmodel Integration {\n id String @id @db.Uuid // IntegrationSpec.integrationId\n orgId String\n agentId String @db.Uuid // owner ⇒ delivery daemon (agent.daemonId)\n // The identity this install runs as. NO @unique: a shareable bot fans out to\n // one Integration row per agent (shared-bot-relay.md §4.1). A classic\n // (non-shareable) bot is still capped at ≤1 install by the create route.\n botId String @db.Uuid\n platform String @default(\"slack\")\n name String // Slack app / display name (mirrors bot.name at install time)\n status IntegrationStatus @default(active)\n // Credential generation whose uninstall/token-revocation flipped this row.\n // Null for active rows and user-freed rows (which are deleted, not revoked).\n revokedCredentialRevision Int?\n feishuRegion String? // Feishu/Lark open-platform gateway: 'feishu' (open.feishu.cn) | 'lark' (open.larksuite.com). NULL ⇒ 'feishu' (default / non-feishu integrations). Public config, NOT secret.\n createdByUserId String? // WebUI user who installed it (audit)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n // Restrict: the bot is the durable identity — it must never vanish underneath a\n // live install. Uninstall deletes the integration row and FREES the bot.\n bot Bot @relation(fields: [botId], references: [id], onDelete: Restrict)\n createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)\n channels IntegrationChannel[]\n // Crons/hooks whose target anchor posts through this integration (SetNull on uninstall).\n targetedByCrons CronDef[]\n targetedByHooks HookDef[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([botId])\n @@map(\"integration\")\n}\n\n// How the bot activates in one conversation: not at all (conversation gating,\n// resource-visibility.md §14), only when @-mentioned, or on any message.\nenum ChannelTrigger {\n off\n mention\n any\n}\n\n// Conversation kind (resource-visibility.md §14.3): a member channel vs a direct\n// conversation. Direct rows are observed incrementally for every integration.\n// `mpim` is a Slack multi-person DM — reported on observation like `im` (Slack\n// never lists them as bot membership), but mention-gated like a channel.\nenum ConversationKind {\n channel\n im\n mpim\n}\n\n// One conversation the integration's bot participates in. `integration/channels`\n// carries either an authoritative membership snapshot or a partial observed-\n// conversation report; DM rows and rows absent from partial reports are retained.\n// The operator's per-conversation trigger survives both forms. Channel id/name are\n// control metadata — never message content (§1/§12).\nmodel IntegrationChannel {\n integrationId String @db.Uuid\n channelId String // platform conversation id (Slack \"C…\" / DM \"D…\")\n name String? // \"#deploys\" without the hash (or DM counterpart); null if lookup failed\n // Enclosing space the conversation lives in — a Discord guild. One bot spans several\n // servers, each with its own \"#general\", so the console needs it to tell the rows\n // apart. `spaceId` (the guild snowflake) is the identity — two guilds may share a\n // name — and `space` is the display label. Null on single-container platforms, on DM\n // rows, and until the daemon resolves them.\n spaceId String?\n space String?\n isPrivate Boolean @default(false)\n kind ConversationKind @default(channel)\n // Relay-backed channel rows repeat the effective trigger across each active\n // integration so deleting the canonical owner does not discard channel state.\n // Direct rows remain independently configurable per integration.\n trigger ChannelTrigger @default(mention)\n // Per-channel default/owning agent for a SHARED bot (shared-bot-relay.md §10.1\n // channel ownership — the primary disambiguation path). Exactly one active\n // integration row per shared channel carries the owner; sibling rows are null.\n // Irrelevant for a classic integration, where its agent is the only target.\n agentId String? @db.Uuid\n firstSeenAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n @@id([integrationId, channelId])\n @@index([agentId])\n @@map(\"integration_channel\")\n}\n\n// Pending Slack auto-install session (docs/designs/slack-install-smoothing.md §Tier B).\n// A short-lived bridge for the config-token funnel: the app is created via\n// apps.manifest.create and its client credentials + the OAuth-obtained bot token\n// live here until the operator pastes the app-level token and `finalize` creates\n// the real bot + integration (then this row is DELETED). `clientSecret`/`botToken`\n// pass through SecretCipher with the same discipline as `bot_secret` — plaintext\n// under `none`, ciphertext under an encrypting provider; NEVER logged or included\n// in a DTO. No FKs (mirrors github_install_state): a dangling row after an\n// org/agent delete is harmless and TTL-reaped. The `id` doubles as the\n// unforgeable OAuth `state`.\nmodel SlackInstall {\n id String @id @db.Uuid // == OAuth state\n orgId String\n agentId String @db.Uuid // install target (owner ⇒ delivery daemon)\n appId String // A… — manifest-created app id (deep links + association)\n clientId String\n clientSecret String // sensitive — secret discipline, never logged/DTO'd\n botToken String? // xoxb-…, backfilled by the OAuth callback\n name String? // operator-chosen app name; null ⇒ derived at finalize\n // slack-http-mode quick-install: the finalize path + the http signing secret that\n // apps.manifest.create returns at start (the browser never sees it), so an http\n // auto-install finalizes with no manual paste. `shareable` is NOT stored — it's a\n // non-secret choice the console re-sends in the finalize body.\n transport SlackTransport @default(socket)\n signingSecret String? // http: captured at app-create; used at finalize\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"slack_install\")\n}\n\n// Pending install of the PLATFORM-published (distributed) Slack app\n// (preset-agents.md §5.3). Unlike SlackInstall, no per-app credentials live here —\n// the app id / client secret / signing secret are deployment env config\n// (SLACK_PLATFORM_*). The row binds the OAuth `state` to either {org, target\n// agent, initiating user} or {org, expected bot, initiating user}. The `id`\n// doubles as the unforgeable OAuth `state`; rows are TTL-reaped alongside\n// slack_install. No FKs (mirrors slack_install): a dangling row after an\n// org/agent/bot delete is harmless.\nmodel SlackPlatformInstall {\n id String @id @db.Uuid // == OAuth state\n orgId String\n // Generic installs bind a target agent. A bot-bound Settings reauthorization\n // leaves this null so a freed bot stays free.\n agentId String? @db.Uuid\n // Terminal state of the OAuth round trip — the row IS the console's completion\n // signal. It must survive the callback (not be deleted) because a successful\n // RE-authorization of a workspace this agent already has need not create any\n // new integration: \"a new integration appeared\" cannot distinguish success\n // from a still-pending tab. `failureReason` carries the same short code the\n // callback's close page shows, so the console can report WHY it failed.\n status SlackPlatformInstallStatus @default(pending)\n failureReason String?\n // For a Settings reauthorization this is populated while pending and fences\n // the OAuth callback to that exact Bot/workspace. Generic installs fill it on\n // completion for the console deep-link.\n botId String? @db.Uuid\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n settledAt DateTime? @db.Timestamptz(6)\n\n @@map(\"slack_platform_install\")\n}\n\n/**\n * Terminal state of a platform Slack app install (see SlackPlatformInstall.status).\n */\nenum SlackPlatformInstallStatus {\n pending\n completed\n failed\n}\n\n// Durable Feishu/Lark one-click app registration. The browser polls this row,\n// so any Control Plane replica can continue the provider device flow after a\n// request is load-balanced or a process restarts. `deviceCode` and `appSecret`\n// pass through SecretCipher in PgFeishuAppRegistrationStore and are cleared on\n// every terminal outcome. No FKs: the route re-validates the agent immediately\n// before installation, and abandoned rows are TTL-reaped.\nmodel FeishuAppRegistration {\n id String @id @db.Uuid\n // Non-null only while open. The unique slot prevents two users from creating\n // two apps for the same target; terminal settlement clears it.\n targetKey String? @unique\n orgId String\n agentId String @db.Uuid\n requestedName String?\n fallbackRegion String\n transport SlackTransport @default(socket)\n authorizationUrl String\n providerDomain String\n deviceCode String? // sensitive — sealed by SecretCipher\n intervalMs Int\n nextPollAt DateTime @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n status FeishuAppRegistrationStatus @default(pending)\n failureReason String?\n appId String?\n appSecret String? // sensitive — sealed by SecretCipher\n resolvedRegion String?\n // Pre-reserved IDs make finalization restart-idempotent.\n botId String @db.Uuid\n integrationId String @db.Uuid\n createdByUserId String?\n claimToken String? @db.Uuid\n claimedUntil DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n settledAt DateTime? @db.Timestamptz(6)\n\n @@index([status, nextPollAt])\n @@map(\"feishu_app_registration\")\n}\n\nenum FeishuAppRegistrationStatus {\n pending\n authorized\n completed\n failed\n}\n\n// One user's stored Slack App Configuration Token, scoped to an org (composite key\n// orgId+userId) — docs/designs/slack-install-smoothing.md §Tier B. PER-USER on purpose:\n// the app that `apps.manifest.create` builds is owned by whoever's config token\n// created it, and only that user can then generate the app's app-level (xapp) token\n// on api.slack.com. So each initiator stores their OWN token and installs entirely\n// on their own — no shared token that pins every app to one person. When the caller\n// has one AND the funnel is enabled, the console FORCES auto-install; absent ⇒ the\n// manual flow. The access token expires ~12h after issue, so we persist the refresh\n// token and rotate via `tooling.tokens.rotate` when it is stale (each rotate returns\n// a NEW pair — last write wins). `accessToken`/`refreshToken` pass through\n// SecretCipher with the same discipline as `bot_secret`: plaintext under `none`,\n// ciphertext under an encrypting provider; NEVER logged or included in a DTO.\nmodel SlackUserConfig {\n orgId String\n userId String\n accessToken String // xoxe.xoxp-… — the App Configuration access token\n refreshToken String? // xoxe-… — used to rotate a fresh access token; null ⇒ access-only (expires ~12h, then re-enter)\n accessExpiresAt DateTime @db.Timestamptz(6) // when accessToken expires (~12h; drives rotation / re-entry)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([orgId, userId])\n @@map(\"slack_user_config\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.12 AuditEvent — audit log / events feed (C6 + C7)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AuditKind {\n daemon_auth\n daemon_register\n daemon_unreachable\n route_assign\n route_release\n drain\n agent_launch\n agent_stop\n scope_denied\n secret_grant\n secret_revoke\n cron_change\n hook_change\n agent_repo_change\n org_invite_change\n protocol_error\n api_key_create\n api_key_rotate\n api_key_revoke\n mcp_tool_call\n}\n\nmodel AuditEvent {\n id BigInt @id @default(autoincrement()) @db.BigInt\n orgId String?\n kind AuditKind\n daemonId String? @db.Uuid\n agentId String? @db.Uuid\n sessionId String? @db.Uuid\n actorUserId String? // set when action came from WebUI\n frameType String? // \"route/assign\", \"error\", …\n frameCorr String? @db.Uuid // Envelope.id / corr (tracing)\n message String? @db.Text // redacted, human-readable\n details Json? @db.JsonB // {expected:<seq>}, {capability}, …\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([orgId, createdAt])\n @@index([daemonId, createdAt])\n @@index([kind, createdAt])\n @@map(\"audit_event\")\n}\n\n// ── GitHub App (github-app workspaces; docs/designs/github-app-git-credentials.md §Configuration and Data Model) ──\n// The App identity itself (id/slug/private key) is DEPLOYMENT CONFIG (GITHUB_APP_* env),\n// never persisted. Only its installations are rows, claimed to an org.\n// Visibility taxonomy: infrastructure, like `bot` — always org-visible, never restricted.\n\nenum GitAccess {\n read\n write\n}\n\n/// One installation of the deployment GitHub App on a GitHub org/user account,\n/// claimed by an AgentConnect org. Rows are only ever MARKED dead (`revokedAt`)\n/// — never deleted — because agents keep `installationId` provenance pointers;\n/// minting resolves live installations by account login, not by row liveness.\nmodel GithubInstallation {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n installationId BigInt @unique // GitHub-side installation id\n accountLogin String // e.g. \"example-org\"\n accountType String // \"Organization\" | \"User\"\n repositorySelection String // \"all\" | \"selected\"\n // Installation-effective permissions as returned by GitHub. Unknown/legacy\n // rows use {}, which is intentionally fail-closed for review/check effects.\n permissions Json @default(\"{}\") @db.JsonB\n suspendedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6) // sync found it uninstalled ⇒ mark, don't delete\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@index([orgId])\n @@map(\"github_installation\")\n}\n\n/// One-shot HMAC-signed install-state nonces (the `state` on the GitHub install\n/// deep link / setup callback). Consumed exactly once — replay ⇒ reject. Expired\n/// rows are garbage; consumption deletes, and expiry is also embedded in the\n/// signed state itself so stale rows can never be replayed.\nmodel GithubInstallState {\n nonce String @id // random 128-bit, base64url\n orgId String\n expiresAt DateTime @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"github_install_state\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Centralized tool management — MCP provider registry (docs/designs/\n// centralized-tool-management.md). CP owns MCP provider definitions; the relay\n// reverse-proxies agent calls so the upstream credential never reaches the\n// daemon/agent. Mirrors the Bot / BotSecret split: metadata table + secret\n// side-table read only via McpProviderSecretStore.\n// ───────────────────────────────────────────────────────────────────────────\nenum McpTransport {\n http\n sse\n}\n\n// How a provider row was created / what manages it. `custom` = an operator-entered\n// upstream MCP server (url + headers). `open_connector` = a connection provisioned\n// through the open-connector integration (docs: connectors); the url is the\n// open-connector /mcp endpoint and the upstream header carries the connection\n// profile alias. Display + create-flow discriminator only — the wire (rc/mcp-assign,\n// daemon proxy def) is identical for both.\nenum McpProviderKind {\n custom\n open_connector\n}\n\nmodel McpProvider {\n // uuid (not cuid): providerId rides the wire as rc/mcp-assign.providerId (z.string().uuid()).\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String // reference key; the agent enable-list + probe facts key on it\n kind McpProviderKind @default(custom) // custom upstream vs open-connector connection\n transport McpTransport @default(http) // v1 accepts http (Streamable HTTP) only\n url String // upstream endpoint (non-secret; may appear in DTOs)\n // Console visibility — same Shareable model as Agent/Daemon/Cron\n // (docs/designs/resource-visibility.md): 'org' = every member sees it; 'restricted'\n // = the complete sharedWith audience. Governs console access only — an agent that\n // already enabled a provider keeps reaching it regardless (never crosses the wire).\n visibility ResourceVisibility @default(org)\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"McpProviderCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret McpProviderSecret?\n grants McpGrant[]\n\n @@unique([orgId, name])\n @@index([orgId])\n @@map(\"mcp_provider\")\n}\n\n// Upstream auth headers pass through SecretCipher: plaintext under `none`,\n// ciphertext under an encrypting provider. Read ONLY via\n// McpProviderSecretStore, never in a DTO, and never pushed to a daemon (same\n// discipline as bot_secret). Shipped to the relay in rc/mcp-assign, injected on\n// forward.\nmodel McpProviderSecret {\n mcpProviderId String @id @db.Uuid\n headers Json @default(\"[]\") @db.JsonB // {name,value}[] — upstream apikey etc.\n\n provider McpProvider @relation(fields: [mcpProviderId], references: [id], onDelete: Cascade)\n\n @@map(\"mcp_provider_secret\")\n}\n\n// A proxy grant: the bearer key the daemon injects into the agent and the relay\n// validates. v1 = one active grant per provider (shared identity). The CP re-pushes\n// the plaintext key to daemons on every reconcile, so the persisted value must be\n// recoverable through SecretCipher. `none` stores plaintext; an encrypting provider\n// stores ciphertext. The key remains store-only and never enters a DTO; the relay\n// receives only sha256(key) via rc/mcp-assign.\nmodel McpGrant {\n id String @id @default(cuid())\n mcpProviderId String @db.Uuid\n key String @unique // bearer grant key in stored SecretCipher form; store-only\n status String @default(\"active\") // active | revoked\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n provider McpProvider @relation(fields: [mcpProviderId], references: [id], onDelete: Cascade)\n\n @@index([mcpProviderId])\n @@map(\"mcp_grant\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// Shared skills registry (docs/designs/shared-skills.md). The CP records only a\n// bounded public GitHub SOURCE, its numeric repository identity, and optional\n// ref/subdir/skill filter — skill CONTENT never touches the CP. The daemon\n// acquires a commit-bound local snapshot and installs it with its bundled exact\n// CLI before the ACP host spawns. There is no secret side-table or repo grant.\n//\n// The per-agent enable-list is NOT a relation here; like mcpServers it lives as\n// a string[] of \"<sourceName>/<skillName>\" (or \"<sourceName>/*\") inside the\n// agent's runtimeOverrides JSON bag. The CP resolves those into self-contained\n// AgentSpec.skills entries when it assembles the spec (agentSpecAssembler).\nmodel SkillSource {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String // reference key; the agent enable-list keys on it. @@unique([orgId, name])\n source String // bounded GitHub acquisition input; the CLI sees only a local snapshot\n githubRepoId BigInt? // nullable for migration; unbound rows never project to AgentSpec\n ref String? // optional branch/tag/commit; composed into the source (design §5)\n subDir String? // optional repo-relative install dir\n skills String[] @default([]) // empty ⇒ install every skill; else only these (passed as -s)\n visibility ResourceVisibility @default(org)\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"SkillSourceCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n\n @@unique([orgId, name])\n @@index([orgId])\n @@map(\"skill_source\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// Organization Knowledge + managed Agent Skills bundles\n// (docs/designs/organization-knowledge.md).\n// Pending candidate bodies stay daemon-local; only their metadata is indexed\n// here. Accepted revisions are immutable product-owned shared content.\n// ───────────────────────────────────────────────────────────────────────\n\n// ── organization environment registry (organization-secrets-and-variables.md) ──\n\nenum OrganizationEnvironmentKind {\n variable\n secret\n}\n\n/// How an entry enrolls agents. `all` is an automatic-ENROLLMENT policy, not an\n/// authorization bypass: every effective assignment is still an explicit\n/// OrganizationEnvironmentAssignment row created under a `resource.edit` decision\n/// for that agent (design §3.4).\nenum OrganizationEnvironmentAudience {\n all\n selected\n}\n\n/// One organization-owned variable or secret. Metadata only — a secret's value\n/// lives in the sibling OrganizationEnvironmentSecret row so list and human-DTO\n/// queries can never join it (the AgentSecret discipline).\n///\n/// `key` and `kind` are IMMUTABLE after creation: renaming or converting is an\n/// explicit delete-and-create, which prevents an edit from silently changing the\n/// meaning of the same organization-owned credential (design §3.1).\nmodel OrganizationEnvironmentEntry {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n key String // env var name, validated at the API edge\n kind OrganizationEnvironmentKind\n variableValue String? // non-null ONLY for kind=variable\n audience OrganizationEnvironmentAudience\n version Int @default(1) // editor-conflict fence for PATCH (expectedVersion)\n createdByUserId String?\n lastModifiedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"OrgEnvCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n modifiedBy User? @relation(\"OrgEnvModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n secret OrganizationEnvironmentSecret?\n assignments OrganizationEnvironmentAssignment[]\n\n // ONE organization keyspace: an org cannot hold both a variable and a secret\n // with the same key (design §3.1).\n @@unique([orgId, key])\n // Referenced by the assignment FK so a binding cannot cross organizations.\n @@unique([id, orgId])\n @@index([orgId, audience])\n @@map(\"organization_environment_entry\")\n}\n\n/// The write-only value of an organization secret. Every value passes through the\n/// injected SecretCipher via OrganizationEnvironmentSecretStore — the ONLY\n/// value-reading seam — so at-rest encryption stays a wiring change.\nmodel OrganizationEnvironmentSecret {\n entryId String @id @db.Uuid\n value String // passes through the SecretCipher seam (plaintext under the identity cipher)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n entry OrganizationEnvironmentEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)\n\n @@map(\"organization_environment_secret\")\n}\n\n/// One entry→agent binding: the durable delegation created when a request\n/// authorized for `resource.edit` on that agent enrolled it (design §4). Once it\n/// exists, `organization.manage` may rotate or delete the entry without gaining\n/// any visibility into the agent.\nmodel OrganizationEnvironmentAssignment {\n orgId String\n entryId String @db.Uuid\n agentId String @db.Uuid\n authorizedByUserId String? // the actor whose resource.edit decision created the delegation\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n // Both FKs are COMPOSITE on orgId: a cross-organization binding is impossible\n // even for an internal caller, not merely rejected in application code.\n entry OrganizationEnvironmentEntry @relation(fields: [entryId, orgId], references: [id, orgId], onDelete: Cascade)\n agent Agent @relation(fields: [agentId, orgId], references: [id, orgId], onDelete: Cascade)\n authorizedBy User? @relation(\"OrgEnvAuthorizedBy\", fields: [authorizedByUserId], references: [id], onDelete: SetNull)\n\n @@id([entryId, agentId])\n @@index([orgId, agentId])\n @@map(\"organization_environment_assignment\")\n}\n\nenum OrganizationArtifactSource {\n manual\n dream\n}\n\nenum OrganizationSuggestionKind {\n knowledge\n skill\n}\n\nenum OrganizationSuggestionOperation {\n create\n update\n}\n\nenum OrganizationSuggestionState {\n pending\n accepted\n rejected\n}\n\nmodel OrganizationKnowledge {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n title String\n currentRevision Int @default(1)\n archivedAt DateTime? @db.Timestamptz(6)\n archivedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n revisions OrganizationKnowledgeRevision[]\n\n @@index([orgId, archivedAt, updatedAt])\n @@map(\"organization_knowledge\")\n}\n\nmodel OrganizationKnowledgeRevision {\n knowledgeId String @db.Uuid\n revision Int\n content String @db.Text\n summary String? @db.Text\n tags String[] @default([])\n digest String\n source OrganizationArtifactSource\n sourceAgentId String? @db.Uuid\n sourceDreamId String?\n sourceCandidateId String? @db.Uuid\n sourceSessionIds String[] @default([])\n createdByUserId String?\n reviewedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n knowledge OrganizationKnowledge @relation(fields: [knowledgeId], references: [id], onDelete: Cascade)\n\n @@id([knowledgeId, revision])\n @@index([createdAt])\n @@map(\"organization_knowledge_revision\")\n}\n\nmodel ManagedSkill {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String\n description String\n currentRevision Int @default(1)\n archivedAt DateTime? @db.Timestamptz(6)\n archivedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n revisions ManagedSkillRevision[]\n\n @@unique([orgId, name])\n @@index([orgId, archivedAt, updatedAt])\n @@map(\"managed_skill\")\n}\n\nmodel ManagedSkillRevision {\n managedSkillId String @db.Uuid\n revision Int\n archive Bytes\n digest String\n compressedBytes Int\n expandedBytes Int\n fileCount Int\n manifest Json @db.JsonB\n source OrganizationArtifactSource\n sourceAgentId String? @db.Uuid\n sourceDreamId String?\n sourceCandidateId String? @db.Uuid\n sourceSessionIds String[] @default([])\n createdByUserId String?\n reviewedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n skill ManagedSkill @relation(fields: [managedSkillId], references: [id], onDelete: Cascade)\n\n @@id([managedSkillId, revision])\n @@index([createdAt])\n @@map(\"managed_skill_revision\")\n}\n\nmodel OrganizationSuggestion {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n sourceAgentId String @db.Uuid\n sourceDaemonId String? @db.Uuid\n dreamId String\n candidateId String @db.Uuid\n kind OrganizationSuggestionKind\n operation OrganizationSuggestionOperation\n targetArtifactId String? @db.Uuid\n targetRevision Int?\n title String\n summary String? @db.Text\n tags String[] @default([])\n digest String\n contentBytes Int\n sessionIds String[] @default([])\n state OrganizationSuggestionState @default(pending)\n reviewedByUserId String?\n reviewedAt DateTime? @db.Timestamptz(6)\n reviewReason String? @db.Text\n acceptedArtifactId String? @db.Uuid\n acceptedArtifactRevision Int?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@unique([sourceAgentId, dreamId, candidateId])\n @@index([orgId, state, createdAt])\n @@index([sourceDaemonId, state])\n @@map(\"organization_suggestion\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// External-memory plugin control plane (docs/designs/memory-evolution.md M-5A).\n// Purpose-separated from the model-facing MCP registry. Upstream secret values\n// and daemon grant keys live only in side tables whose values pass through the\n// configured SecretCipher.\n// ────────────────────────────────────────────────────────────────────────\nenum MemoryPluginTransport {\n streamable_http\n stdio\n}\n\nenum ExternalMemoryConnectionStatus {\n probing\n ready\n degraded\n invalid\n}\n\nmodel MemoryPluginInstallation {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n pluginId String\n transport MemoryPluginTransport @default(streamable_http)\n endpoint String?\n commandRef String?\n pinnedProfileMajor Int @default(1)\n expectedManifestDigest String?\n // Reviewed logical-secret → upstream-header mapping. Header names are\n // configuration; values live only in ExternalMemoryConnectionSecret.\n secretHeaders Json @default(\"[]\") @db.JsonB\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"MemoryPluginInstallationCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n connections ExternalMemoryConnection[]\n\n // One plugin may have multiple independently reviewed immutable pins: another\n // endpoint, manifest digest, or secret contract must not overwrite agents\n // still using the old installation.\n @@index([orgId, pluginId])\n @@map(\"memory_plugin_installation\")\n}\n\nmodel ExternalMemoryConnection {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n installationId String @db.Uuid\n config Json @default(\"{}\") @db.JsonB\n status ExternalMemoryConnectionStatus @default(probing)\n revision Int @default(1)\n probedRevision Int?\n pluginVersion String?\n profile String?\n manifestDigest String?\n capabilities Json? @db.JsonB\n declaredEgressHosts String[] @default([])\n reasonCode String?\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n installation MemoryPluginInstallation @relation(fields: [installationId], references: [id], onDelete: Restrict)\n createdBy User? @relation(\"ExternalMemoryConnectionCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret ExternalMemoryConnectionSecret?\n grants ExternalMemoryGrant[]\n\n @@index([orgId])\n @@index([installationId])\n @@map(\"external_memory_connection\")\n}\n\nmodel ExternalMemoryConnectionSecret {\n connectionId String @id @db.Uuid\n values Json @default(\"{}\") @db.JsonB\n\n connection ExternalMemoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)\n\n @@map(\"external_memory_connection_secret\")\n}\n\nmodel ExternalMemoryGrant {\n id String @id @default(cuid())\n connectionId String @db.Uuid\n key String @unique\n status String @default(\"active\")\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n connection ExternalMemoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)\n\n @@index([connectionId])\n @@map(\"external_memory_grant\")\n}\n",
|
|
56421
|
+
"inlineSchema": "// AgentConnect Control Plane — Persistence (C6).\n//\n// PostgreSQL is metadata-only for message, transcript, and agent-memory bodies.\n// Approved organization Knowledge Markdown and bounded immutable managed-skill\n// ZIP revisions are the explicit shared-content exception described in\n// docs/designs/organization-knowledge.md; pending suggestion bodies remain on\n// their source daemon.\n//\n// See docs/designs/control-plane-implementation.md §3.\n\ngenerator client {\n provider = \"prisma-client\"\n // v7 emits TypeScript source compiled with the app (tsc). Output lives under\n // `src/` so `rootDir: src` picks it up; it is gitignored and regenerated by\n // `prisma:generate` in CI and the Docker build. No query-engine binary — v7\n // uses the queryCompiler + the `@prisma/adapter-pg` driver adapter.\n output = \"../src/generated/prisma\"\n runtime = \"nodejs\"\n moduleFormat = \"esm\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n // Connection URL is supplied at runtime via the pg driver adapter (see\n // `persistence/prisma.ts`) and to the CLI via `prisma.config.ts`\n // (`datasource.url`). v7 deprecates a `url` here, so it is intentionally omitted.\n}\n\n// Deployment-wide operator configuration. There is exactly one row (`id = 1`),\n// enforced by the migration. `values` is a versioned, application-validated\n// JSON document; secret values live in the side table and never join ordinary\n// configuration reads.\nmodel DeploymentConfig {\n id Int @id\n schemaVersion Int\n values Json @db.JsonB\n revision Int @default(1)\n adminClaimedFor String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n secrets DeploymentSecret[]\n\n @@map(\"deployment_config\")\n}\n\nmodel DeploymentSecret {\n deploymentConfigId Int\n key String\n value String\n // Stable, truncated digest of the plaintext for redacted operator status.\n // It is never used for authentication.\n fingerprint String\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade)\n\n @@id([deploymentConfigId, key])\n @@map(\"deployment_secret\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.2 Tenancy axis — Org / User / Membership (C2/C4 WebUI authz)\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Org {\n id String @id @default(cuid())\n // Optional display name; when absent the console falls back to `slug`.\n name String?\n slug String @unique\n // Console avatar descriptor (protocol AgentIcon): {kind:'runtime'} | {kind:'glyph',glyph,color}\n // | {kind:'image'}. Null ⇒ generated default (glyph plate keyed off the org id). An `image`\n // icon's bytes live in the object store (docs/designs/icon-uploads.md), served by GET\n // /v1/orgs/:id/icon. Org icons are console-only — never fed to Slack.\n icon Json? @db.JsonB\n // Applied to both call directions when a new agent does not explicitly choose\n // a policy. Existing agents keep their persisted directional policies.\n defaultAgentVisibility AgentCallPolicy @default(all)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n members Membership[]\n daemons Daemon[]\n agents Agent[]\n crons CronDef[]\n hooks HookDef[]\n apiKeys ApiKey[]\n integrations Integration[]\n bots Bot[]\n githubInstallations GithubInstallation[]\n slackUserConfigs SlackUserConfig[]\n inviteLink OrgInviteLink?\n mcpProviders McpProvider[]\n skillSources SkillSource[]\n memoryPluginInstallations MemoryPluginInstallation[]\n externalMemoryConnections ExternalMemoryConnection[]\n webchatConversations WebchatConversation[]\n webchatMcpDelegations WebchatMcpDelegation[]\n presetAgents PresetAgent[]\n organizationKnowledge OrganizationKnowledge[]\n managedSkills ManagedSkill[]\n organizationSuggestions OrganizationSuggestion[]\n externalScopes ExternalScope[]\n sessionExternalAccess SessionExternalAccessPolicy[]\n environmentEntries OrganizationEnvironmentEntry[]\n\n @@map(\"org\")\n}\n\nmodel User {\n id String @id @default(cuid())\n email String @unique\n displayName String?\n picture String? // OIDC `picture` claim (avatar URL); display-only, refreshed on sign-in\n // Set when the user uploads a profile photo. The image itself lives in the icon\n // object store under a key derived from this user id; the timestamp marks it as\n // the active photo and cache-busts its public URL.\n profilePictureUpdatedAt DateTime? @db.Timestamptz(6)\n oidcSubject String? @unique // OIDC `sub` → user\n // The moment the user redeemed their waitlist join link = \"formal / activated\"\n // user. Non-null ⇒ may enter the app\n // and create orgs under WAITLIST_MODE. ONLY the CP's redeem path writes this (the\n // external admin app is not granted write on this column, §7 contract 2). Always\n // null when waitlist mode is off — the column is inert there.\n activatedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n memberships Membership[]\n apiKeys ApiKey[]\n createdAgents Agent[] @relation(\"AgentCreatedBy\")\n modifiedAgents Agent[] @relation(\"AgentModifiedBy\")\n createdIntegrations Integration[]\n createdBots Bot[]\n createdMcpProviders McpProvider[] @relation(\"McpProviderCreatedBy\")\n createdSkillSources SkillSource[] @relation(\"SkillSourceCreatedBy\")\n createdMemoryPluginInstallations MemoryPluginInstallation[] @relation(\"MemoryPluginInstallationCreatedBy\")\n createdExternalMemoryConnections ExternalMemoryConnection[] @relation(\"ExternalMemoryConnectionCreatedBy\")\n createdDaemons Daemon[] @relation(\"DaemonCreatedBy\")\n modifiedDaemons Daemon[] @relation(\"DaemonModifiedBy\")\n createdCrons CronDef[] @relation(\"CronCreatedBy\")\n modifiedCrons CronDef[] @relation(\"CronModifiedBy\")\n createdHooks HookDef[] @relation(\"HookCreatedBy\")\n modifiedHooks HookDef[] @relation(\"HookModifiedBy\")\n createdRepoAuths AgentRepoAuthorization[] @relation(\"AgentRepoAuthCreatedBy\")\n slackConfigs SlackUserConfig[]\n createdInviteLinks OrgInviteLink[] @relation(\"OrgInviteLinkCreatedBy\")\n inviteRedemptions OrgInviteRedemption[]\n webchatConversations WebchatConversation[]\n webchatMcpDelegations WebchatMcpDelegation[]\n createdEnvironmentEntries OrganizationEnvironmentEntry[] @relation(\"OrgEnvCreatedBy\")\n modifiedEnvironmentEntries OrganizationEnvironmentEntry[] @relation(\"OrgEnvModifiedBy\")\n authorizedEnvironmentAssignments OrganizationEnvironmentAssignment[] @relation(\"OrgEnvAuthorizedBy\")\n\n @@map(\"app_user\")\n}\n\n// A deleted account's identity boundary: tokens issued at or before `cutoffAt` are\n// refused for that subject, so a still-valid pre-deletion bearer cannot re-run JIT\n// signup and recreate the account it just lost — not even across a CP restart, where\n// the auth plane's in-process cutoff is gone.\n//\n// Two writers, deliberately: an `AFTER DELETE` trigger on app_user (added by the\n// `deleted_identity_trigger` migration) records every deletion as it happens — the CP\n// does not perform account deletion, the external admin app does, and the database is\n// where both meet — and the auth plane also records what it observes, which covers a\n// row that disappeared without the trigger (e.g. a restore from a backup taken before\n// it existed).\n//\n// NOT a ban list: rows are expiry-limited, and once `expiresAt` passes the subject is\n// an ordinary newcomer again. Whether a deleted person may re-apply at all is the\n// deleting app's policy, not this table's.\nmodel DeletedIdentityCutoff {\n oidcSubject String @id // the OIDC `sub` whose local row was found missing\n cutoffAt DateTime @db.Timestamptz(6) // refuse tokens with `iat` <= this\n expiresAt DateTime @db.Timestamptz(6) // pruned/ignored past this point\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([expiresAt])\n @@map(\"deleted_identity_cutoff\")\n}\n\n// One row per deleted organization, recording that its transit key should be\n// destroyed (docs/designs/per-org-secret-encryption.md §6). Written inside the\n// same transaction that deletes the org, so the intent survives whatever\n// happens next; drained by the operator-run `secrets:shred` CLI, which is the\n// ONLY thing that ever deletes a key — the CP process cannot.\n//\n// Deliberately NO foreign key: the row's entire purpose is to outlive the\n// organization it names.\n//\n// The RESOLVED target is stored, not just the org id. Deriving the name at\n// drain time would read it from whatever configuration is current then, so\n// rotating VAULT_TRANSIT_MOUNT or the org key prefix between the delete and the\n// drain would aim the destroy at a name that does not exist — which the\n// shredder reads as \"already gone\", clears the row, and leaves the real key\n// alive forever. Pinning the target at delete time makes the tombstone\n// self-describing and immune to later configuration changes.\nmodel PendingKeyShred {\n orgId String @id // the deleted org (identity + idempotency key)\n mount String // transit mount as configured when the org was deleted\n keyName String // fully resolved key name, e.g. <orgKeyPrefix><orgId>\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"pending_key_shred\")\n}\n\n// Console roles (§3.2): owner = edit everything + manage members/org info (an\n// org can have several owners); collaborator = create/edit/run agents, manage\n// sessions; viewer = read-only.\nenum OrgRole {\n owner\n collaborator\n viewer\n}\n\n// Per-resource visibility (docs/designs/resource-visibility.md §1): 'org' (default)\n// = visible to every org member; 'restricted' = the complete non-empty\n// `sharedWith` audience. Enforced ONLY on console read/write paths —\n// never crosses the daemon↔CP wire (a restricted-but-active resource still runs).\nenum ResourceVisibility {\n org\n restricted\n}\n\n// Directional agent-call policy. The inbound fields control which peers may call\n// THIS agent; the outbound fields control which peers this agent may discover/call.\n// Separate from ResourceVisibility, which governs human console access.\nenum AgentCallPolicy {\n all\n selected\n}\n\nmodel Membership {\n id String @id @default(cuid())\n orgId String\n userId String\n role OrgRole @default(collaborator)\n // When this user joined THIS org — not their account signup. The console's\n // \"joined\" column reads it, and removal picks the ownership-transfer\n // recipient by it (resource-visibility.md §8.2). Pre-existing rows were\n // backfilled from the cuid embedded in `id`, which is that same instant.\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n // The last time THIS user selected THIS org in the console. Nullable keeps\n // existing memberships in their original insertion order until a choice is made.\n lastSelectedAt DateTime? @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([orgId, userId])\n @@index([userId])\n @@map(\"membership\")\n}\n\n// One shareable join link per org. The token is stored as a peppered hash and\n// always grants collaborator for exactly seven days; neither is configurable.\nmodel OrgInviteLink {\n id String @id @default(cuid())\n orgId String @unique\n tokenHash String @unique\n displayTail String\n expiresAt DateTime @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"OrgInviteLinkCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n redemptions OrgInviteRedemption[]\n\n @@map(\"org_invite_link\")\n}\n\n// Persists use even after membership removal, so the same account cannot use\n// the same link to restore its own access. A newly generated link has a new id.\nmodel OrgInviteRedemption {\n inviteLinkId String\n userId String\n redeemedAt DateTime @default(now()) @db.Timestamptz(6)\n\n inviteLink OrgInviteLink @relation(fields: [inviteLinkId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([inviteLinkId, userId])\n @@index([userId])\n @@map(\"org_invite_redemption\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// WaitlistEntry — closed-beta admission\n// One row per activation link. TWO write sides share this table (§7):\n// • the EXTERNAL admin app writes approval/mint fields (name, email, status,\n// note, source, tokenHash, displayTail, joinExpiresAt, revokedAt, approved*)\n// — enforced by a column-level least-privilege DB role granted in the migration;\n// • the CP writes ONLY the redemption fields (redeemed*), in the same\n// transaction that sets User.activatedAt.\n// `email` is OPTIONAL and drives the ONE redemption rule (§6): a row WITH an email\n// may be redeemed ONLY by that verified email (strong binding); a row with NO email\n// is a one-time BEARER link — any verified identity may redeem it once, and the\n// redeemer's email is recorded in `redeemedEmail`. `email` stays `@unique` so real\n// emails don't collide and self-signup upserts still work; Postgres treats NULLs as\n// distinct, so any number of bearer rows (email null) coexist.\n// The join link reuses OrgInviteLink's peppered-hash + tail + expiry/revoke\n// shape, but the token is minted by the admin app and only VERIFIED (hashed &\n// compared) by the CP on redeem. Minting/approval/admin-auth are out of this\n// repo's scope; the CP owns the schema + migrations for its application DB.\n// ───────────────────────────────────────────────────────────────────────────\n\nenum WaitlistStatus {\n pending // user self-submitted, awaiting review\n approved // admin approved = whitelisted; a join link has been minted\n rejected // admin rejected\n}\n\nmodel WaitlistEntry {\n id String @id @default(cuid())\n name String? // display name for the applicant / invitee (admin- or intake-supplied)\n email String? @unique // normalized lowercase (auth.ts); NULL ⇒ bearer link\n status WaitlistStatus @default(pending)\n note String? // applicant message / admin note\n source String? // 'self' | 'admin' | …\n\n // ── join link (one per email; minted by the admin app on approve) ──\n tokenHash String? @unique // peppered hash; plaintext only ever in the mint response\n displayTail String? // display-only tail for reconciliation\n joinExpiresAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n\n // ── approval / redemption audit ──\n approvedByUserId String?\n approvedAt DateTime? @db.Timestamptz(6)\n redeemedByUserId String? // written ONLY by the CP redeem path\n redeemedAt DateTime? @db.Timestamptz(6) // written ONLY by the CP redeem path\n redeemedEmail String? // the redeemer's verified email; written ONLY by the CP redeem path\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@map(\"waitlist_entry\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.3 Daemon — fleet registry & fencing root (C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum DaemonStatus {\n provisioned\n authenticating\n ready\n draining\n unreachable\n disabled\n}\n\nenum HealthState {\n ok\n degraded\n}\n\nmodel Daemon {\n id String @id @db.Uuid // AuthReq.daemonId (wire UUID)\n orgId String\n host String? // RegisterReq.host (display)\n name String? // human-assigned display name (set via the console, never by the daemon)\n agentVersion String?\n machineId String? @db.Uuid // 🅼 AuthReq.machineId (stored, stub-enforced)\n tokenFp String? // `id` of the authenticating ApiKey (audit; written by upsertOnAuth)\n attestationFp String? // 🅼 last accepted attestation digest\n capabilities Json @default(\"{}\") @db.JsonB // {platforms[],runtimes[],acp,features[]}\n mcpServers Json @default(\"[]\") @db.JsonB // facts/daemon-runtimes MCP-server list (FactsMcpServer[] = {name,transport}); replaced whole per frame\n maxAgents Int @default(0)\n\n createdByUserId String? // WebUI user who provisioned the daemon (immutable audit; null for CLI/self-registered). Surfaced in the console \"Created\" row.\n\n // ── last-modification audit (human edits only) ──\n // Stamped together on user-initiated writes (provision, rename). Deliberately\n // SEPARATE from `updatedAt`, which the row bumps on every system write\n // (heartbeat, (re)auth, register, watchdog) and so cannot mean \"last human edit\".\n lastModifiedByUserId String? // WebUI user who last edited the daemon (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each human edit\n\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n\n // ── console-set daemon settings ──\n // Retention window for FINISHED sessions on the daemon's LOCAL store (the console's\n // \"Expire sessions\" option): 'never' | '7d' | '30d' | '90d'. The CP only stores and\n // delivers it (register/ok baseline + config/push hot update); the daemon's hourly\n // retention sweep is what actually deletes expired sessions.\n sessionRetention String @default(\"7d\")\n\n // ── fencing root ──\n sessionEpoch BigInt @default(0) @db.BigInt // bumped each successful (re)auth\n routingEpoch BigInt @default(0) @db.BigInt // version of THIS daemon's assignment set\n\n // Last applied `facts/daemon-runtimes.seq` (per-connection monotonic; reset to\n // NULL on register). Snapshots with an older seq are dropped so interleaved\n // frame transactions cannot commit out of order (runtime-model-catalog.md §5).\n runtimesSnapshotSeq Int?\n\n // ── liveness / watchdog ──\n status DaemonStatus @default(provisioned)\n health HealthState @default(ok)\n load Json? @db.JsonB // Heartbeat.load {cpu,mem,agents}\n activeSessions Int @default(0)\n degradedScopes String[] @default([]) // Heartbeat.degradedScopes\n lastSeenAt DateTime? @db.Timestamptz(6) // drives watchdog\n unreachableAt DateTime? @db.Timestamptz(6) // reassignGrace clock origin\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Restrict)\n createdBy User? @relation(\"DaemonCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"DaemonModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n agents Agent[]\n assignments Assignment[]\n leases SecretLease[]\n launches AgentLaunch[]\n runtimeProfiles RuntimeProfile[]\n apiKeys ApiKey[]\n sessions SessionMeta[]\n lifecycleOps DaemonLifecycleOp[]\n webchatMcpDelegations WebchatMcpDelegation[]\n\n @@index([orgId])\n @@index([status])\n @@index([lastSeenAt])\n @@map(\"daemon\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// DaemonLifecycleOp — a CP-commanded restart/upgrade in flight (cli-daemon-split.md §7)\n// A console \"Restart\"/\"Upgrade\" opens a `pending` row and sends the C→D\n// `daemon/restart` / `daemon/upgrade` REQ. The `DaemonControlAck` only means the\n// daemon accepted the command; the real outcome is closed out-of-band when the\n// daemon drains, its supervisor relaunches it, and it re-registers READY (an\n// upgrade additionally requires its reported `agentVersion` to reach the target\n// within the deadline). A decline (`accepted:false`) or a `deadline` lapse closes\n// the row `failed`. At most one op may be pending per daemon (partial unique index,\n// hand-added in the migration — not faithfully expressible in the Prisma schema).\n// ───────────────────────────────────────────────────────────────────────────\n\nenum DaemonLifecycleOpType {\n restart\n upgrade\n}\n\nenum DaemonLifecycleOpStatus {\n pending\n succeeded\n failed\n}\n\nmodel DaemonLifecycleOp {\n id String @id @default(cuid())\n daemonId String @db.Uuid\n op DaemonLifecycleOpType\n targetVersion String? // the version to reach (upgrade only); null for restart\n initiator String? // app_user.id that commanded it; null under devAuth / system\n status DaemonLifecycleOpStatus @default(pending)\n // The daemon `sessionEpoch` at command-send time. The op only settles on a READY\n // whose epoch is STRICTLY GREATER (the daemon re-authed after draining + relaunching),\n // so a coincidental reconnect at the same epoch can never close it.\n commandEpoch BigInt @default(0) @db.BigInt\n // Set when the daemon ACKs `accepted:true` (the op is \"armed\"). A READY before this\n // must NOT settle the op — the command hadn't been accepted/executed yet.\n acceptedAt DateTime? @db.Timestamptz(6)\n startedAt DateTime @default(now()) @db.Timestamptz(6)\n deadline DateTime @db.Timestamptz(6) // drain+relaunch budget; a lapse closes the op `failed`\n outcome String? // short closure detail (decline reason / \"version mismatch\" / \"expired\")\n settledAt DateTime? @db.Timestamptz(6) // when it left `pending`\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@index([daemonId, status])\n @@map(\"daemon_lifecycle_op\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.3a ApiKey — long-lived, revocable control-channel credential (C4)\n// ───────────────────────────────────────────────────────────────────────────\n// One table with a `principalType` discriminator serves daemon, personal-user,\n// relay, and OAuth credentials. Hash-only at rest: the token\n// a bare opaque `<secret><crc>` is looked up by `hash = HMAC-SHA256(secret, API_KEY_PEPPER)`\n// (@unique); the plaintext is shown exactly once at mint and never persisted.\n// See docs/designs/daemon-api-key-auth.md.\n\nenum PrincipalType {\n daemon\n user\n relay // relay↔CP credential (shared-bot-relay.md §8) — org-less infra key\n oauth // access token minted by the embedded OAuth AS (agent-assistant.md §7) — same shape as a user key\n}\n\nmodel ApiKey {\n id String @id @default(cuid())\n principalType PrincipalType // daemon | user | relay | oauth\n orgId String? // org-scoped for daemon/user/oauth keys; NULL for relay keys\n daemonId String? @db.Uuid // set iff principalType=daemon\n userId String? // set iff principalType=user or oauth\n\n hash String @unique // HMAC-SHA256(secret, pepper) hex — NEVER plaintext; the unique lookup key\n displayTail String // \"…a2b1\" (non-secret) for the console\n name String? // human label (\"ci-runner-east\")\n scopes String[] @default([]) // daemon/user/relay keys use []; oauth keys carry granted mcp:* scopes\n createdByUserId String? // operator who minted it (audit)\n oauthGrantId String? // set iff principalType=oauth — links the access token to its OAuthGrant so revoking the grant kills its tokens\n\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n lastUsedAt DateTime? @db.Timestamptz(6) // throttled write on auth\n expiresAt DateTime? @db.Timestamptz(6) // long-lived principals may be non-expiring; user/oauth mint policies set TTLs\n revokedAt DateTime? @db.Timestamptz(6) // kill switch — checked on every auth\n revokedReason String?\n\n org Org? @relation(fields: [orgId], references: [id], onDelete: Cascade)\n // Cascade: deleting a daemon (DELETE /daemons/:id) removes its keys, so no\n // orphaned credential rows (daemonId-null but still hash-valid) survive.\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([daemonId])\n @@index([userId])\n @@index([orgId, revokedAt])\n @@index([oauthGrantId])\n @@map(\"api_key\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Embedded OAuth 2.1 Authorization Server (docs/designs/agent-assistant.md §7)\n// Lets MCP clients (claude.ai / Claude Code) do the browser login flow against\n// the CP. The CP issues its OWN tokens (access token = an `api_key` row with\n// principalType='oauth'); the human login on /authorize is delegated to the web\n// console (Logto / devAuth). These tables hold only the AS's own protocol state.\n// ───────────────────────────────────────────────────────────────────────────\n\n// A dynamically-registered (RFC 7591) MCP client. Public clients only (PKCE is the\n// proof; no client secret). Reaped after `expiresAt` to bound DCR-table growth.\nmodel OAuthClient {\n clientId String @id // AS-generated opaque id\n clientName String?\n redirectUris String[] @default([])\n grantTypes String[] @default([]) // e.g. [\"authorization_code\",\"refresh_token\"]\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // DCR registration TTL (default 90d)\n\n @@index([expiresAt])\n @@map(\"oauth_client\")\n}\n\n// A single-use authorization code, issued AFTER the user consents on the console\n// and exchanged at /token. Hash-only at rest (like api_key). Bound to the PKCE\n// challenge + the consenting user/org so the client cannot forge identity.\nmodel OAuthCode {\n codeHash String @id // HMAC-SHA256(secret, pepper) hex of the code\n clientId String\n redirectUri String\n userId String\n orgId String\n scopes String[] @default([])\n codeChallenge String\n codeChallengeMethod String // always \"S256\"\n resource String? // RFC 8707 audience the client requested\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // short (≈60s)\n consumedAt DateTime? @db.Timestamptz(6) // set on first exchange — single-use guard\n\n @@index([expiresAt])\n @@map(\"oauth_code\")\n}\n\n// A persisted authorization grant = the refresh-token state for one (user, org,\n// client). Refresh tokens rotate on every use; the previous generation stays valid\n// for one more use (workers-oauth-provider's fix for the lost-response deadlock).\nmodel OAuthGrant {\n id String @id @default(cuid())\n userId String\n orgId String\n clientId String\n scopes String[] @default([])\n resource String?\n rtHash String? @unique // current refresh-token hash (rotating)\n prevRtHash String? // previous generation — still redeemable once\n rtExpiresAt DateTime? @db.Timestamptz(6) // refresh inactivity expiry (default 30d, slid on use)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n lastUsedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6) // \"disconnect\" — also cascade-revokes its access tokens\n\n @@index([userId])\n @@index([prevRtHash])\n @@map(\"oauth_grant\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.4 RuntimeProfile — observed runtime capabilities (C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AcpSupport {\n full\n partial\n none\n}\n\nmodel RuntimeProfile {\n id String @id @default(cuid())\n daemonId String @db.Uuid\n runtime String // \"claude\" / \"codex\"\n version String\n models String[] @default([])\n contextWindow Int?\n acpSupport AcpSupport @default(none)\n acpProtocolVersion Int? // ACP protocol version negotiated at initialize\n toolCalling Boolean @default(false)\n mcpCapabilities Json? @db.JsonB // MCP transports advertised at initialize {http,sse}; null ⇒ not probed (assume stdio-only)\n modelCatalog Json? @db.JsonB // wire RuntimeModelCatalog verbatim (runtime-model-catalog.md §5); null ⇒ no catalog reported\n modelsSource String? // provenance of models[]: 'cached' | 'probed'; null ⇒ older daemon (probed semantics)\n authRequired Boolean @default(false) // last probe hit ACP auth-required (-32000): installed but needs a login on the daemon host\n observedAt DateTime @default(now()) @db.Timestamptz(6)\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@unique([daemonId, runtime])\n @@map(\"runtime_profile\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.5 Workspace mode — INLINE on Agent (no standalone entity).\n// ───────────────────────────────────────────────────────────────────────────\n// The agent's working dir is daemon-generated; the caller picks one of two modes\n// (mirrors protocol AgentWorkspace). `github` clones gitRepo@gitBranch and runs the\n// agent in `agentDir` (a subdir); multiple agents may share a repo by differing\n// `agentDir`, so workspace config lives per-agent on the Agent row below.\nenum WorkspaceMode {\n scratch // fresh empty working dir, no repo\n github // clone gitRepo @ gitBranch, run in agentDir\n}\n\nenum WorkspaceIsolation {\n shared // every session uses the primary checkout\n session // each logical session uses its own git worktree\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.6 Agent — agent definition & capability pin (C6 + C4)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AgentStatus {\n active\n inactive\n paused\n}\n\nmodel Agent {\n id String @id @db.Uuid // wire UUID across route/*, agent/*, event/session\n orgId String\n name String // slug — lowercase [a-z0-9-]; unique per org; the daemon-facing handle\n displayName String? // human-readable original (\"Acme Network Bot\"); the console derives the slug from it\n // Console avatar descriptor (protocol AgentIcon): {kind:'runtime'} | {kind:'glyph',glyph,color} |\n // {kind:'image',url}. Null ⇒ legacy default = the runtime mark. New agents are created with a\n // random glyph+color. Rendered by GET /v1/agents/:id/icon and used as the Slack per-message icon_url.\n icon Json? @db.JsonB\n description String? // system prompt seed → AgentLaunch.spec.description; daemon appends to standing prompt\n // Nullable = \"deferred exec config\" (preset-agents.md §3.2): an agent may exist\n // UNPLACED with no runtime chosen yet; the invariant moves to placement, which\n // requires one. When set it must be in Daemon.capabilities.runtimes.\n runtime String?\n status AgentStatus @default(inactive)\n daemonId String? @db.Uuid // 1 agent : 1 machine (null until placed)\n // ── workspace (inline; §3.5) — daemon-generated path ──\n workspaceMode WorkspaceMode @default(scratch)\n workspaceIsolation WorkspaceIsolation @default(shared)\n gitRepo String? // github mode: e.g. github.com/acme/infra\n gitBranch String? @default(\"main\")\n agentDir String? // github mode: subdir within the repo (repo-root if null)\n // github-app credential mode (docs/designs/github-app-git-credentials.md).\n // `installationId` = GithubInstallation.id picked at create time — a PROVENANCE\n // HINT only, deliberately NO relation/FK: minting re-resolves the live\n // installation by repo owner every time, so an uninstall→reinstall (new GitHub\n // installation id) self-heals without touching agents. Null ⇒ anonymous git.\n installationId String?\n // GitHub's numeric repository id for the workspace repo. Unlike gitRepo,\n // this survives rename and is the authority for repo-scoped effects.\n // Nullable for pre-migration/anonymous workspaces and lazily repairable.\n workspaceRepoId BigInt?\n gitAccess GitAccess @default(write) // ceiling for minted tokens (contents read|write)\n capabilities String[] @default([]) // → AgentLaunch.activeCapabilities\n permissions Json @default(\"{}\") @db.JsonB // {policy:\"ask\",autoApprove:[...]}\n runtimeOverrides Json? @db.JsonB // {model, reasoningEffort, outputMode, fastMode, env{K:V}, mcpServers[], skills[]} — NO secret values (those live in agent_secret)\n managedSkills String[] @default([]) // centrally accepted managed_skill ids, explicitly enabled\n createdByUserId String? // WebUI user who created the agent (immutable audit; null for daemon/CLI-created). Surfaced in the console \"Created\" row.\n // Last-modification audit (human edits only): stamped on create + PATCH. Kept\n // separate from `updatedAt`, which also bumps on system writes (e.g. placement).\n lastModifiedByUserId String? // WebUI user who last edited the agent (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each PATCH\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n // ── inbound agent-call policy (UI: Agent visibility) ──\n callPolicy AgentCallPolicy @default(all) // 'all' = any org peer agent may call this as a sub-agent\n allowedCallerAgentIds String[] @default([]) // agent.id set used when callPolicy='selected'\n // ── outbound agent-call policy (UI: Agent visibility) ──\n outboundPolicy AgentCallPolicy @default(all) // 'all' = this agent may discover/call any otherwise-callable org peer\n allowedTargetAgentIds String[] @default([]) // agent.id set used when outboundPolicy='selected'\n introduceOnJoin Boolean @default(false) // #536: self-introduce to peers on a genuine channel join\n runInSandbox Boolean @default(false) // #642: request an OS sandbox; daemon policy may force it on\n // Monotonic revision of the fully resolved AgentSpec (organization-secrets-and-\n // variables.md §5). NOT environment-specific: every durable mutation that can\n // change a CP-owned field assembled into the spec bumps it through the same\n // writer, so there is ONE ordering domain per agent rather than competing\n // revisions per feature area. The daemon refuses a snapshot older than the\n // greatest it applied, which is what makes full-map env/secret replacement safe.\n configRevision BigInt @default(0)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"AgentCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"AgentModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n assignments Assignment[]\n launches AgentLaunch[]\n sessions SessionMeta[]\n sessionUsage SessionUsage[]\n sessionSpend SessionSpend[]\n crons CronDef[]\n hooks HookDef[]\n integrations Integration[]\n repoAuths AgentRepoAuthorization[]\n secrets AgentSecret[]\n webchatConversations WebchatConversation[]\n webchatParticipations WebchatConversationAgent[]\n webchatMcpDelegations WebchatMcpDelegation[]\n presetRecords PresetAgent[]\n\n organizationEnvironmentAssignments OrganizationEnvironmentAssignment[]\n\n @@unique([orgId, name]) // slug is unique within an org\n // Referenced by the composite assignment FK: a binding cannot name an agent in\n // another organization (organization-secrets-and-variables.md §5).\n @@unique([id, orgId])\n @@index([orgId])\n @@index([daemonId])\n @@map(\"agent\")\n}\n\n// Which preset an org-level preset_agent row describes (preset-agents.md §3).\n// `general` (the `agentconnect` dev agent) is the ONLY preset: the dedicated\n// assistant preset was cancelled — assistant/admin capabilities are planned to\n// fold into the general agent's webapp sessions instead. Additive enum if a\n// new preset ever ships.\nenum PresetAgentKind {\n general\n}\n\nenum PresetAgentState {\n created // the agent row exists — written in the same transaction\n skipped // permanently not created (backfill slug collision, or org opt-out)\n}\n\n// Per-preset provisioning state (preset-agents.md §3.2). The row IS the\n// idempotency marker: creation (org-creation seam or one-time backfill) writes it\n// transactionally with the agent row, and a deleted preset is never recreated\n// because creation has no later trigger — the row remains as the record the\n// onboarding checklist derives from. `placementSettledAt` is stamped by the FIRST\n// placement of any kind (or an explicit opt-out) so auto-placement (M1) never\n// fights a user who unplaced or moved the agent.\nmodel PresetAgent {\n orgId String\n preset PresetAgentKind\n agentId String? @db.Uuid // null once the agent is deleted (SetNull) or when skipped\n status PresetAgentState\n placementSettledAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)\n\n @@id([orgId, preset])\n @@index([agentId])\n @@map(\"preset_agent\")\n}\n\n// One write-only secret env var of an agent, row-per-key. BotSecret discipline:\n// list/DTO queries never join it — key NAMES are read via AgentSecretStore.keys\n// (values untouched), values ONLY via AgentSecretStore.get on the wire-projection\n// paths (agent/upsert, register/ok roster, agent/activate). The store seam is the\n// single read/write path, so the configured SecretCipher transforms every value;\n// an encrypting provider supplies at-rest encryption while `none` is identity.\nmodel AgentSecret {\n agentId String @db.Uuid\n key String // env var name (validated at the API edge)\n value String // passes through the SecretCipher seam (plaintext under the identity cipher)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, key])\n @@map(\"agent_secret\")\n}\n\n/// Access level of an AgentRepoAuthorization row. Three tiers instead of\n/// GitAccess's two: `comment` (contents:read + issues/PR:write) is the github\n/// hook write-back shape — two tiers would force granting contents:write just\n/// to let the agent comment (agent-multi-repo-authorization.md decision 3).\nenum RepoAccess {\n read // contents/issues/PR all read — reference repo\n comment // contents:read, issues/PR write — watch + write-back\n write // all write — secondary working repo\n}\n\n/// Explicit grant of a GitHub repo to an agent (issue #457,\n/// agent-multi-repo-authorization.md). Anchored on the agent, NOT on hooks —\n/// creating a hook must never silently widen credentials. `repoId` (numeric,\n/// rename-immune) is the match key; `repoFullName` is display + the request\n/// fast-path. The covering installation is deliberately NOT bound here: minting\n/// re-resolves the live installation by repo owner (gitcred decision 7), so an\n/// uninstall→reinstall self-heals. Rows are mutable (add/remove) — the\n/// workspace-immutability convention is untouched.\nmodel AgentRepoAuthorization {\n id String @id @default(uuid()) @db.Uuid\n agentId String @db.Uuid\n repoId BigInt // GitHub numeric repo id — the match key\n repoFullName String // \"owner/repo\" — display + case-insensitive fast-path; refreshed on rename detection\n access RepoAccess\n createdByUserId String? // audit: who authorized (the identity-assertion subject)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"AgentRepoAuthCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n\n @@unique([agentId, repoId])\n @@index([agentId])\n @@map(\"agent_repo_authorization\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.7 Assignment — the routing table (session ownership + fencing) (C3)\n// ───────────────────────────────────────────────────────────────────────────\n\n// The persisted chat-platform id is TEXT, not an enum (integration-plugin-architecture.md\n// §11): a new platform id must be storable without a migration once the platform registry\n// lands. The closed-set guard moved to the application layer — `toDbPlatform`\n// (persistence/platform.ts) still refuses ids outside the served set, fail-closed, until\n// the registry replaces it. `session_meta.platform` was already text; these columns joined\n// it in the S1b migration.\n\nenum AssignmentState {\n active\n draining\n released // released = drain/done (reassignable under NEW epoch)\n frozen\n}\n\nmodel Assignment {\n id String @id @default(cuid())\n platform String\n channel String\n thread String?\n threadKey String @default(dbgenerated(\"(COALESCE(thread, ''::text))\")) // STORED generated col, added in migration SQL (§3.13); read-only\n agentId String @db.Uuid\n daemonId String? @db.Uuid // null while released/unplaced\n workspaceId String // opaque scope id on the wire (RouteAssign.workspaceId); now = agentId (workspace is inline)\n\n // ── fencing ──\n assignedEpoch BigInt @db.BigInt // Daemon.sessionEpoch at assign time (ControlExt.epoch)\n assignedSeq BigInt? @db.BigInt // per-agent seq of the route/assign frame\n routingEpoch BigInt @db.BigInt // table version this row reflects\n state AssignmentState @default(active)\n bindRules Json @default(\"[]\") @db.JsonB // RouteAssign.bindRules[]\n releasedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n\n @@index([daemonId, state]) // register/ok reconcile: active set for a daemon\n @@index([agentId])\n @@index([platform, channel, threadKey])\n @@map(\"assignment\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.8 SessionMeta — converged session milestones (NO bodies) (C6 + dashboard)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum SessionPhase {\n start\n plan\n problem\n end\n}\n\nenum ActivityState {\n thinking\n tool_call\n awaiting_permission\n idle\n}\n\n// Per-session visibility tier (docs/designs/session-visibility.md §1): 'org'\n// (default) = visible to every organization member; 'private' = session owner\n// (identity match) ONLY — no role override, org owners included; 'external' =\n// current provider audience. Session reads are independent from owning-Agent\n// Team visibility. Share-by-link is deliberately NOT a member of this enum (§8).\nenum SessionVisibility {\n private\n org\n external\n}\n\nenum ExternalResolution {\n pending\n settled\n invalid\n}\n\nenum ExternalAccessPolicyState {\n disabled\n enabling\n enabled\n degraded\n}\n\n// How a session's visibility was determined (session-visibility.md §3, §4.5).\n// The A2A reconciliation state machine: settlement flips inherited_pending →\n// inherited iff the row is still pending (CAS on this column); 'explicit' pins\n// the row against settlement but NOT against a tightening cascade (privacy wins).\nenum VisibilitySource {\n default // classified by the §4.2 ingest rules\n inherited_pending // A2A child awaiting parent resolution (§4.5)\n inherited // settled from (or cascaded by) its parent\n explicit // set by a human via §4.3\n}\n\nmodel SessionMeta {\n id String @id // EventSession.sessionId = ACP session id (agent-assigned string, NOT a UUID)\n parentSessionId String? // Parent ACP session id reported by the daemon; no FK because parent metadata may arrive later or live on another daemon\n agentId String @db.Uuid\n launchId String? @db.Uuid // CP launch fence; null for Slack/Discord-created sessions (no CP launch)\n platform String? // denormalized sessionKey echo for dashboard filters (free string so webchat can be listed)\n channel String?\n thread String?\n // Durable workspace/tenant scope (EventSession.transportScope — a Slack team\n // id, Feishu tenant key, or stable per-integration mint), persisted so the\n // conversation grouping key (merged-conversation-view.md §5.1) can tell\n // installations apart. NOT the daemon's credential-derived transport scope.\n tenantScope String?\n phase SessionPhase @default(start)\n link String? // deep-link (NOT a body)\n summary String? @db.Text // short milestone text (NOT the stream)\n title String? @db.Text // daemon-derived display title (NOT a body)\n status String?\n triggeredBy String?\n channelName String?\n triggeredByName String?\n threadUrl String? @db.Text\n // ── execution-config snapshot (what the session actually ran with) ──\n // Daemon-reported via event/session (session override ?? agent config at run\n // time); null ⇒ never reported / the runtime's own default. Recorded so the\n // console shows what a session USED, not the agent's config at view time.\n runtime String?\n model String?\n effort String? // reasoning effort level (runtime-owned vocabulary)\n fastMode Boolean?\n permissionMode String? // runtime permission/approval mode\n outputMode String? // daemon-side output verbosity (low/medium/high)\n daemonId String? @db.Uuid // first daemon that reported the session; immutable content owner, stamped by the CP from the authenticated WS conn, never daemon-echoed\n // Session-pinned checkout choice. Null is a legacy row whose daemon never\n // reported it; `session` identifies a daemon-local worktree eligible for the\n // authorized Workspace viewer.\n workspaceIsolation WorkspaceIsolation?\n activityState ActivityState @default(idle)\n // ── session visibility (docs/designs/session-visibility.md §3) ──\n orgId String // denormalized from agent.orgId at ingest so the org-wide list predicate/index never joins agent\n visibility SessionVisibility @default(org)\n ownerIdentity String? // §2 namespaced identity (`user:<id>` | `<platform>:<scope>:<uid>`); null for automation/legacy/unresolved-owner rows (NOT a §2 owner-orphan, whose tuple is stored but unmatched)\n visibilitySource VisibilitySource @default(default)\n visibilityRev Int @default(0) // dedicated monotonic counter, bumped in the same tx as any visibility change (§5.1)\n visibilityAckedRev Int @default(-1) // daemon-ack watermark: 'applied' once >= visibilityRev; -1 = never acked (rev 0 is a real revision)\n // Shared external input. The first trusted binding is immutable; ownerIdentity\n // is provenance only and never authorizes an external row.\n externalProvider String?\n externalScopeId String? @db.Uuid\n externalResolution ExternalResolution?\n classifiedPolicyRev BigInt? @db.BigInt\n // Provenance for the unresolved set: true when this row was ALREADY unresolved\n // when its policy was enabled. Such a scope only comes back if new trusted\n // activity rebinds the session, so it is expected rather than a fault, and\n // `state` degrades only while an unresolved row WITHOUT this mark exists (a\n // mere COUNT cannot tell the two apart: settling one marked row would silently\n // absolve a live post-enable failure). Stamped at enable and re-stamped on\n // every re-enable; A2A descendants inherit it with the audience they inherit.\n // \"legacy\" is relative to the org's enable, NOT to a release — this is live\n // behavior for any org that turns external access on, not pre-release cruft.\n legacyUnresolved Boolean @default(false)\n // Cursor tokens pass through JavaScript Date (millisecond precision), so the\n // complete keyset tuple must use the same precision in Postgres.\n // ── retention GC receipt (#485, `event/session-purged`) ──\n // When the owning daemon deleted this session's local row (and any per-session\n // worktree) after its retention window. The metadata row deliberately SURVIVES\n // — it is all that remains — so the console can say the transcript was deleted\n // instead of rendering the now-permanently-empty history as \"no messages\".\n // First-wins: an at-least-once re-report keeps the original stamp.\n contentPurgedAt DateTime? @db.Timestamptz(6)\n contentPurgedReason String? // SessionPurgeReason ('retention'); null while not purged\n lastActivityAt DateTime @db.Timestamptz(3)\n startedAt DateTime @default(now()) @db.Timestamptz(3)\n endedAt DateTime? @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n launch AgentLaunch? @relation(fields: [launchId], references: [id], onDelete: SetNull)\n daemon Daemon? @relation(fields: [daemonId], references: [id], onDelete: SetNull)\n externalScope ExternalScope? @relation(fields: [externalScopeId, orgId, externalProvider], references: [id, orgId, provider], onDelete: Restrict, onUpdate: Restrict)\n\n webchatCurrentFor WebchatConversation[] @relation(\"WebchatCurrentSession\")\n webchatAgentCurrentFor WebchatConversationAgent[] @relation(\"WebchatAgentCurrentSession\")\n\n @@index([agentId, startedAt])\n @@index([lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc), agentId], map: \"session_meta_activity_page_idx\")\n @@index([agentId, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_activity_page_idx\")\n @@index([agentId, platform, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_platform_page_idx\")\n @@index([agentId, channel, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_channel_page_idx\")\n @@index([agentId, triggeredBy, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_agent_trigger_page_idx\")\n @@index([orgId, visibility, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_org_visibility_page_idx\")\n @@index([parentSessionId])\n // Conversation grouping (merged-conversation-view.md §5.2): serves the\n // emit-at-max exists-newer probe, the member backfill, and the\n // conversationKey resolver. Replaces the unused bare (platform, channel).\n @@index([orgId, platform, tenantScope, channel, thread, lastActivityAt(sort: Desc), startedAt(sort: Desc), id(sort: Desc)], map: \"session_meta_conversation_key_idx\")\n @@index([launchId])\n @@index([daemonId])\n @@index([orgId, externalProvider, externalScopeId])\n @@map(\"session_meta\")\n}\n\n// Stable provider resource referenced by SessionMeta. Provider ACLs themselves\n// are never copied here; access is resolved at read time and only short-lived\n// decisions are cached in process memory.\nmodel ExternalScope {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n provider String\n realmKey String\n resourceKind String\n resourceKey String\n credentialKind String?\n credentialId String?\n aclRevision BigInt @default(0) @db.BigInt\n revokedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n sessions SessionMeta[]\n\n @@unique([id, orgId, provider])\n @@unique([orgId, provider, realmKey, resourceKind, resourceKey])\n @@index([credentialKind, credentialId])\n @@map(\"external_scope\")\n}\n\n// Organization/provider policy. Missing is never interpreted as disabled: the\n// ingest transaction ensures this row before creating a supported candidate.\nmodel SessionExternalAccessPolicy {\n orgId String\n provider String\n state ExternalAccessPolicyState @default(disabled)\n currentRev BigInt @default(0) @db.BigInt\n readFenceRev BigInt? @db.BigInt\n migrationCursor String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@id([orgId, provider])\n @@map(\"session_external_access_policy\")\n}\n\n// Durable ownership metadata for a browser webchat conversation. The message\n// stream and transcript remain daemon-local; this row only lets the CP prove\n// that a requested resume belongs to the authenticated human, agent, and org.\nmodel WebchatConversation {\n id String @id @db.Uuid\n orgId String\n agentId String @db.Uuid\n userId String\n delegationGeneration Int @default(0)\n // The exact ACP session currently installed for this conversation — the\n // current-session fence for remote MCP authorization. Maintained ONLY by the\n // session-milestone upsert, transactionally, under a lock on this row, so a\n // replacement-session insert serializes with every authorization read that\n // locks the conversation. `endedAt` cannot express this (\"end\" is stamped\n // after every turn); identity must be explicit. SetNull fails closed if the\n // session row disappears.\n currentSessionId String?\n currentSessionRev Int @default(0) // bumped in the same tx as any pointer change\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n currentSession SessionMeta? @relation(\"WebchatCurrentSession\", fields: [currentSessionId], references: [id], onDelete: SetNull)\n delegations WebchatMcpDelegation[]\n mcpOperations WebchatMcpOperation[]\n participants WebchatConversationAgent[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([userId])\n @@index([currentSessionId])\n @@map(\"webchat_conversation\")\n}\n\n// One participant agent of a webchat conversation (webchat-multi-agents.md §3.1).\n// The roster is fixed at creation; `WebchatConversation.agentId` always mirrors\n// the `role='primary'` row. `ord` preserves the pick order (primary is ord 0).\n// Each participant carries its OWN current-session pointer, maintained by the\n// session-milestone upsert exactly like the conversation-level fence.\nmodel WebchatConversationAgent {\n conversationId String @db.Uuid\n agentId String @db.Uuid\n role String @default(\"member\") // 'primary' | 'member'\n ord Int @default(0)\n addedByUserId String\n addedAt DateTime @default(now()) @db.Timestamptz(6)\n currentSessionId String?\n currentSessionRev Int @default(0)\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n currentSession SessionMeta? @relation(\"WebchatAgentCurrentSession\", fields: [currentSessionId], references: [id], onDelete: SetNull)\n\n @@id([conversationId, agentId])\n @@index([agentId])\n @@index([currentSessionId])\n @@map(\"webchat_conversation_agent\")\n}\n\n// Durable, generation-fenced authority for one browser conversation to invoke\n// curated AgentConnect MCP tools through its currently placed daemon.\nmodel WebchatMcpDelegation {\n id String @id @default(uuid()) @db.Uuid\n conversationId String @db.Uuid\n generation Int\n userId String\n orgId String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n revokedReason String?\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n grants WebchatMcpAccessGrant[]\n\n @@unique([conversationId, generation])\n @@index([conversationId, revokedAt])\n @@index([agentId, revokedAt])\n @@index([expiresAt])\n @@map(\"webchat_mcp_delegation\")\n}\n\nenum WebchatMcpOperationStatus {\n awaiting_confirmation\n executing\n completed\n failed\n ambiguous\n stale\n}\n\nenum WebchatMcpGrantStatus {\n pending\n active\n revoked\n expired\n}\n\n// Short-lived bearer credential for one exact runtime descriptor. Only the\n// peppered token hash is durable; plaintext exists only in the issuance reply.\nmodel WebchatMcpAccessGrant {\n id String @id @default(uuid()) @db.Uuid\n authorityId String @db.Uuid\n descriptorInstanceId String @db.Uuid\n grantRevision Int\n tokenHash String @unique\n status WebchatMcpGrantStatus @default(pending)\n pendingExpiresAt DateTime @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n activatedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6)\n revokedReason String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n authority WebchatMcpDelegation @relation(fields: [authorityId], references: [id], onDelete: Cascade)\n operations WebchatMcpOperation[]\n receipts WebchatMcpTransportReceipt[]\n\n @@unique([descriptorInstanceId, grantRevision])\n @@index([authorityId, status])\n @@index([descriptorInstanceId, status])\n @@index([status, pendingExpiresAt])\n @@index([status, expiresAt])\n @@map(\"webchat_mcp_access_grant\")\n}\n\n// Browser-confirmed logical write. Identity and terminal status live for the\n// conversation lifetime; the bounded response may be evicted independently.\nmodel WebchatMcpOperation {\n id String @id @default(uuid()) @db.Uuid\n conversationId String @db.Uuid\n createdAuthorityGeneration Int\n sourceGrantId String @db.Uuid\n userId String\n toolName String\n canonicalArguments Json @db.JsonB\n intentHash String\n status WebchatMcpOperationStatus @default(awaiting_confirmation)\n executionAttemptId String? @db.Uuid\n claimedAt DateTime? @db.Timestamptz(6)\n recoveryDeadline DateTime? @db.Timestamptz(6)\n boundedResponse Bytes?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n confirmationExpiresAt DateTime @db.Timestamptz(6)\n completedAt DateTime? @db.Timestamptz(6)\n\n conversation WebchatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n sourceGrant WebchatMcpAccessGrant @relation(fields: [sourceGrantId], references: [id], onDelete: Restrict)\n receipts WebchatMcpTransportReceipt[]\n\n @@index([conversationId, status, createdAt])\n @@index([status, recoveryDeadline, id])\n @@map(\"webchat_mcp_operation\")\n}\n\n// Standard JSON-RPC retry coordinate. It never authorizes or claims execution.\nmodel WebchatMcpTransportReceipt {\n grantId String @db.Uuid\n jsonRpcRequestId String\n conversationId String @db.Uuid\n requestHash String\n operationId String @db.Uuid\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n supersededAt DateTime? @db.Timestamptz(6)\n\n grant WebchatMcpAccessGrant @relation(fields: [grantId], references: [id], onDelete: Cascade)\n operation WebchatMcpOperation @relation(fields: [operationId], references: [id], onDelete: Restrict)\n\n @@id([grantId, jsonRpcRequestId])\n @@index([operationId])\n @@index([grantId, createdAt])\n @@map(\"webchat_mcp_transport_receipt\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// SessionUsage — per-session token accounting for the console's usage dashboard.\n//\n// Unlike the message stream (daemon-local, never on the CP), token counts + cost\n// ARE dashboard telemetry: the daemon reports each session's CUMULATIVE usage via\n// the `usage/report` EVT and the CP upserts one row per (agentId, sessionId). This\n// is the ONLY historical usage store — the `/usage` aggregates sum over it by\n// time window. `sessionId` is the agent-assigned ACP id (a string, NOT a wire\n// UUID), so the PK is composite with agentId. Latest-wins upsert = idempotent.\n// ───────────────────────────────────────────────────────────────────────────\nmodel SessionUsage {\n agentId String @db.Uuid\n sessionId String // ACP session id (agent-assigned; NOT a wire UUID)\n platform String? // denormalized sessionKey echo (free string, not the Platform enum)\n channel String?\n totalTokens Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n thoughtTokens Int @default(0)\n cachedReadTokens Int @default(0)\n cachedWriteTokens Int @default(0)\n contextUsed Int?\n contextSize Int?\n costAmount Float @default(0)\n costCurrency String?\n startedAt DateTime @default(now()) @db.Timestamptz(6)\n lastActivityAt DateTime @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, sessionId])\n @@index([agentId, lastActivityAt])\n @@map(\"session_usage\")\n}\n\n// SessionSpend — cumulative usage timeline backing range-scoped spend and the\n// durable per-model breakdown.\n//\n// session_usage is a latest-wins lifetime snapshot, so it can't answer \"how much\n// was spent inside this window\": a long-lived session collapses its entire cost\n// into its newest report. This table records the session's CUMULATIVE cost at each\n// report time together with the model observed for that interval. Readers derive\n// token/cost deltas by diffing consecutive cumulatives and attribute each delta to\n// this row's model. Storing cumulatives keeps writes idempotent on\n// (agentId, sessionId, at); null model is an observed or legacy unknown.\nmodel SessionSpend {\n agentId String @db.Uuid\n sessionId String // ACP session id (echo of the snapshot row)\n at DateTime @db.Timestamptz(6) // the report's lastActivityAt — the bucket time\n model String?\n cumulativeTotalTokens Int @default(0)\n cumulativeInputTokens Int @default(0)\n cumulativeOutputTokens Int @default(0)\n cumulativeThoughtTokens Int @default(0)\n cumulativeCachedReadTokens Int @default(0)\n cumulativeCachedWriteTokens Int @default(0)\n cumulativeCost Float // session's cumulative cost as of `at`; window spend = diff of consecutive cumulatives\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@id([agentId, sessionId, at])\n @@index([agentId, at])\n @@map(\"session_spend\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.9 AgentLaunch — launch fencing (C3)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum LaunchMode {\n long_lived\n per_turn\n}\n\nenum LaunchStatus {\n launching\n running\n stopped\n crashed\n}\n\nmodel AgentLaunch {\n id String @id @db.Uuid // AgentLaunched.launchId (the fence value)\n agentId String @db.Uuid\n daemonId String @db.Uuid\n runtime String\n mode LaunchMode @default(long_lived)\n acpSessionId String? // set iff long-lived ACP session\n // Web API launch provenance (session-visibility.md §4.4) — DISTINCT from the\n // fencing `id`: the CP mints it, the daemon echoes it on the session's\n // `event/session` frame, and ingest resolves it back to the launching user.\n correlationId String? @unique @db.Uuid\n createdByUserId String? // launching principal; raw scalar (no FK) so a deleted user never breaks provenance\n activeCapabilities String[] @default([]) // capability pin (AgentCapabilities.active)\n status LaunchStatus @default(launching)\n launchEpoch BigInt @db.BigInt // sessionEpoch the launch was issued under\n startedAt DateTime? @db.Timestamptz(6)\n stoppedAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n sessions SessionMeta[]\n\n @@index([agentId, status])\n @@index([daemonId])\n @@map(\"agent_launch\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.10 SecretLease — lease metadata (NO plaintext) (C5)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum LeaseStatus {\n active\n expired\n revoked\n}\n\nmodel SecretLease {\n id String @id @db.Uuid // SecretsGrant.leaseId\n daemonId String @db.Uuid\n scopePlatform String\n scopeWorkspaceId String @db.Uuid // SecretsGrant.scope.workspaceId — opaque scope id; now = agentId (workspace inline)\n ref String // Vault/KMS path/handle — NOT the secret\n ttlSec Int\n renewBeforeSec Int @default(60)\n status LeaseStatus @default(active)\n issuedAt DateTime @default(now()) @db.Timestamptz(6)\n renewedAt DateTime? @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6) // issuedAt+ttl; advanced on renew\n revokedReason String?\n\n daemon Daemon @relation(fields: [daemonId], references: [id], onDelete: Cascade)\n\n @@index([daemonId, status])\n @@index([status, expiresAt])\n @@map(\"secret_lease\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.11 CronDef — cron definitions (C6 + C3)\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel CronDef {\n id String @id @db.Uuid // CronUpsert.cronId\n orgId String\n agentId String? @db.Uuid // required at the API; null only via agent-delete SetNull (inert until re-assigned)\n name String? // console display name (\"weekly-deploy-report\"); pure console metadata, never on the daemon wire. Null for legacy/CLI rows.\n schedule String // croner expression interpreted in timezone\n timezone String // IANA timezone resolved by the CP before persistence\n targetPlatform String @default(\"slack\")\n targetChannel String? // CronUpsert.target.channel; null ⇒ headless fire (no platform output)\n targetIntegrationId String? @db.Uuid // CronUpsert.target.integrationId — the agent integration posting the anchor; null (legacy / uninstalled) ⇒ daemon falls back to the agent's first integration\n trigger String @db.Text // synthetic trigger text (control metadata)\n enabled Boolean @default(true)\n lastRunAt DateTime? @db.Timestamptz(6) // advisory (cron/report EVT, latest-wins); daemon authoritative\n createdByUserId String? // WebUI user who created the cron (immutable audit; stamped on create only)\n // Last-modification audit (human edits only): stamped on create AND on every\n // edit through the PUT upsert. Separate from `updatedAt`, which also bumps on\n // system writes (e.g. `lastRunAt` advanced by a daemon cron/report).\n lastModifiedByUserId String? // WebUI user who last edited the cron (null ⇒ never edited by a human)\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6) // defaults to createdAt; app-bumped on each upsert-edit\n // ── visibility / sharing (docs/designs/resource-visibility.md) ──\n visibility ResourceVisibility @default(org) // 'org' = all members; 'restricted' = the complete sharedWith audience\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)\n targetIntegration Integration? @relation(fields: [targetIntegrationId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"CronCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"CronModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n runs CronRun[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([targetIntegrationId])\n @@map(\"cron_def\")\n}\n\n// One fire of a schedule (`cron/report` EVT pairs keyed on (cronId, startedAt)):\n// the FIRE report opens the row (`running`), the COMPLETION report closes it\n// with outcome + duration + the ACP session to deep-link. `running` rows whose\n// completion report was lost (daemon crashed / CP down at turn end) are\n// reconciled to `failed` (orphaned) by the CronRunReaper once they age past\n// CRON_RUN_TTL_SEC — a late completion still overwrites that with the real\n// outcome (the upsert is last-writer-wins), the daemon remaining authoritative.\nenum CronRunStatus {\n running\n success\n failed\n}\n\nmodel CronRun {\n id String @id @default(cuid())\n cronId String @db.Uuid\n orgId String\n startedAt DateTime @db.Timestamptz(6) // CronReport.firedAt\n status CronRunStatus @default(running)\n durationMs Int?\n sessionId String? // ACP session id (console deep-link); null while running / on legacy reports\n reason String? // short failure text (status \"failed\")\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n cron CronDef @relation(fields: [cronId], references: [id], onDelete: Cascade)\n\n @@unique([cronId, startedAt])\n @@index([cronId, startedAt(sort: Desc)])\n @@map(\"cron_run\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// HookDef — inbound-webhook triggers (webhook-triggers-and-github-events.md)\n// A hook maps an inbound webhook delivery to ONE agent turn. The CP owns the\n// definition and compiles it into relay-side rules (rc/hook-assign, broadcast\n// to the whole pool); the relay is the public ingress — event payloads never\n// touch the CP, these rows are definitions + run metadata only.\n// ───────────────────────────────────────────────────────────────────────────\n\nenum HookKind {\n webhook // generic inbound webhook (POST /webhooks/in/:token on the relay)\n github // GitHub event subscription (P2)\n}\n\nenum HookSessionMode {\n perDelivery // new ACP session per delivery (webhook-kind default)\n perThread // source-thread affinity: github = repo#number (github-kind default & only)\n shared // the whole hook shares one session (webhook-kind opt-in)\n}\n\nenum HookReviewPolicy {\n off\n comment\n request_changes\n full\n}\n\nenum HookReportingMode {\n off\n check\n status\n}\n\nenum HookGateMode {\n informational\n required\n}\n\nmodel HookDef {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n agentId String? @db.Uuid // required at the API; nullable only for legacy inert rows\n kind HookKind\n name String\n enabled Boolean @default(true)\n // No trigger prompt: the agent's description is its standing context and the\n // delivery payload carries the caller's message (design security boundary 1).\n sessionMode HookSessionMode\n // ── kind=webhook ──\n urlToken String? @unique // ≥128-bit random ingress routing key (capability URL; canEdit-visible)\n // hmacSecret lives in HookSecret — never on this row (accidental-serialization guard)\n // ── kind=github (P2) ──\n repoId BigInt? // GitHub numeric repo id — the match key (rename-proof)\n repoFullName String? // \"owner/repo\" — display + create-time validation; never matched on\n githubSessionKey String? // immutable per-thread namespace; existing rows keep their pre-rename owner/repo\n events String[] @default([]) // \"issues:opened\" / \"pull_request:*\" / \"issue_comment:created\"\n commentFamilies String[] @default([]) // empty = legacy repo-wide comments; otherwise issues/pull_request scope\n labelFilter String[] @default([]) // non-empty ⇒ issue/PR must carry one of these labels\n mentionOnly Boolean @default(false) // P3 summon mode: authored event text must @<agent-name> or @<app-slug>\n // Durable configuration/dispatch fences. configRevision changes whenever\n // the compiled definition changes; dispatchRevision additionally changes\n // when the owning agent is re-placed.\n configRevision BigInt @default(1)\n dispatchRevision BigInt @default(1)\n // Changes only when the review projection binding/lifecycle changes\n // (enablement, agent/repo binding, reporting transport, or gate mode). It is\n // part of the projection natural key, so a one-way tombstone from an older\n // lifecycle can never suppress a later explicit re-enable on the same SHA.\n projectionEpoch BigInt @default(1)\n reviewPolicy HookReviewPolicy @default(off)\n reportingMode HookReportingMode @default(off)\n gateMode HookGateMode @default(informational)\n requiredAcknowledgedAt DateTime? @db.Timestamptz(6)\n requiredAcknowledgedByUserId String?\n requiredAcknowledgedConfigRevision BigInt?\n // ── output anchoring (same trio as CronDef.target*; null channel ⇒ headless) ──\n targetPlatform String @default(\"slack\")\n targetChannel String?\n targetIntegrationId String? @db.Uuid\n lastFiredAt DateTime? @db.Timestamptz(6) // advisory; bumped on rc/run-report\n // A hook is subordinate to ONE agent (like an Integration, unlike a CronDef):\n // it is only ever listed under that agent, so it carries NO visibility of its\n // own — access is gated by the owning agent's visibility. createdBy is audit.\n createdByUserId String?\n lastModifiedByUserId String?\n lastModifiedAt DateTime @default(now()) @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)\n targetIntegration Integration? @relation(fields: [targetIntegrationId], references: [id], onDelete: SetNull)\n createdBy User? @relation(\"HookCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n lastModifiedBy User? @relation(\"HookModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n secret HookSecret?\n\n @@index([orgId])\n @@index([agentId])\n @@index([kind, repoId]) // github rule-compile main query (P2)\n @@map(\"hook_def\")\n}\n\n// Per-hook HMAC signing key, 1:1 with HookDef. BotSecret discipline: list/DTO\n// queries never join it, it is read only through the HookSecretStore, and it is\n// echoed exactly once in the create response. The stored value passes through\n// SecretCipher: plaintext under `none`, ciphertext under an encrypting provider.\nmodel HookSecret {\n hookId String @id @db.Uuid\n hmacSecret String // stored SecretCipher representation; read ONLY via HookSecretStore\n\n hook HookDef @relation(fields: [hookId], references: [id], onDelete: Cascade)\n\n @@map(\"hook_secret\")\n}\n\n// One webhook delivery that fired (or failed to fire) an agent turn. Two-stage\n// lifecycle: the relay's rc/run-report opens the row (`running` on accepted, or\n// `failed` outright on a delivery failure), the daemon's hook/report completion\n// EVT closes it. `running` rows whose completion was lost are reconciled to\n// `failed` (orphaned) by the HookRunReaper; a late completion still overwrites\n// (last-writer-wins). (hookId, deliveryKey) is the idempotency key absorbing\n// GitHub redeliveries and reconcile re-posts.\nmodel HookRun {\n id String @id @default(cuid())\n hookId String @db.Uuid\n orgId String\n deliveryKey String // X-GitHub-Delivery GUID / X-AC-Delivery-Key / relay-minted uuid\n event String? // github: \"issues:opened\"; webhook kind: null (metadata, never a body)\n startedAt DateTime @db.Timestamptz(6) // RcRunReport.firedAt (relay ingest time)\n // Exact accepted dispatch snapshot. Nullable only for legacy rows whose\n // relay/daemon predates the R1 protocol additions.\n agentId String? @db.Uuid\n configRevision BigInt?\n dispatchRevision BigInt?\n projectionEpoch BigInt?\n dispatchDaemonId String? @db.Uuid\n reviewPolicySnapshot HookReviewPolicy?\n reportingModeSnapshot HookReportingMode?\n gateModeSnapshot HookGateMode?\n projectionIntent String?\n repoId BigInt?\n repoFullName String?\n sourceInstallationId BigInt?\n subjectKind String?\n pullNumber Int?\n headSha String?\n baseSha String?\n reportSha String?\n isDraft Boolean?\n baseChanged Boolean?\n turnStartedAt DateTime? @db.Timestamptz(6)\n completedAt DateTime? @db.Timestamptz(6)\n orphanedAt DateTime? @db.Timestamptz(6)\n projectionId String? @db.Uuid\n projectionGeneration BigInt?\n reviewAttemptId String? @unique @db.Uuid\n reviewAttemptState String?\n reviewErrorCode String?\n reviewId String?\n reviewEvent String?\n verdict String?\n reviewCommitId String?\n publishedCommentKind String?\n publishedCommentId String?\n status CronRunStatus @default(running)\n durationMs Int?\n sessionId String? // ACP session id (console deep-link); null while running\n reason String? // short failure text: daemon_offline / dispatch_timeout / orphaned / turn failure\n // Durable, metadata-only GitHub redelivery schedule. These fields are used\n // only for delivery-stage failures that are explicitly classified as safe\n // to retry; no webhook payload is stored in the control plane.\n redeliveryAttempts Int @default(0)\n redeliveryLastRequestedAt DateTime? @db.Timestamptz(6)\n redeliveryNextAttemptAt DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@unique([hookId, deliveryKey])\n @@index([orgId])\n @@index([hookId, startedAt(sort: Desc)])\n @@index([hookId, repoId, reportSha, startedAt(sort: Desc)])\n @@index([projectionId, projectionGeneration])\n @@index([status, startedAt])\n @@index([deliveryKey, status, redeliveryNextAttemptAt])\n @@index([status, redeliveryNextAttemptAt, redeliveryAttempts], map: \"hook_run_redelivery_due_idx\")\n @@map(\"hook_run\")\n}\n\n// Durable GitHub Checks/status projection. It deliberately has no FK to\n// HookDef/Agent/Org: cleanup must still run after an owner row is deleted.\nmodel HookReviewProjection {\n id String @id @default(uuid()) @db.Uuid\n hookId String @db.Uuid\n orgId String\n agentId String @db.Uuid\n // Stable, single-line agent slug displayed in the external Check summary.\n // Snapshotted because this projection must survive Agent deletion for cleanup.\n agentName String?\n lastResolvedInstallationId BigInt?\n repoId BigInt\n repoFullName String\n headSha String\n reportSha String\n projectionEpoch BigInt\n\n generation BigInt @default(0)\n currentHookRunId String?\n externalId String @unique\n checkRunId String? @unique\n\n mode HookReportingMode\n gateMode HookGateMode\n desiredState String\n observedState String?\n sealedThrough BigInt @default(0)\n\n // Live commit -> current PR association is evaluated once per generation\n // before a terminal informational Check write. The canonical desired state\n // remains untouched when association fails closed.\n subjectSyncGeneration BigInt @default(0)\n subjectSyncErrorCode String?\n\n leaseOwner String?\n leaseUntil DateTime? @db.Timestamptz(6)\n nextAttemptAt DateTime? @db.Timestamptz(6)\n attempts Int @default(0)\n lastErrorCode String?\n pendingIntent Json? @db.JsonB\n writeMarker String? @unique\n writePhase String?\n writeStartedAt DateTime? @db.Timestamptz(6)\n tombstonedAt DateTime? @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n subjects HookReviewSubject[]\n\n @@unique([hookId, repoId, reportSha, projectionEpoch])\n @@index([orgId])\n @@index([nextAttemptAt, leaseUntil])\n @@index([agentId, repoId])\n @@index([lastResolvedInstallationId])\n @@map(\"hook_review_projection\")\n}\n\nmodel HookReviewSubject {\n projectionId String @db.Uuid\n pullNumber Int\n headSha String\n baseSha String?\n isOpen Boolean @default(true)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n projection HookReviewProjection @relation(fields: [projectionId], references: [id], onDelete: Cascade)\n\n @@id([projectionId, pullNumber])\n @@index([headSha])\n @@map(\"hook_review_subject\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Relay — one registered ingress-relay instance (shared-bot-relay.md §6)\n// The relay pool is the platform's unified inbound plane: webchat now,\n// shared-bot + webhook with milestone B. Instances register dynamically over\n// the relay WS (`rc/register`); `lastSeenAt` is bumped by `rc/heartbeat` and\n// drives the liveness sweeper (stale rows are swept, and — milestone B —\n// their bots reassigned). No org column: relays are deployment-level infra\n// serving every tenant. No FKs and no secret material.\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Relay {\n id String @id @db.Uuid // minted by the CP on rc/register → rc/registered\n // Deployment-side identity (pod name etc.) — THE upsert key: a relay is\n // stateless, so after a restart `name` is its only stable identity and\n // re-registration reclaims the same row (and relayId). Unique so the upsert\n // is atomic (no duplicate rows for one pod racing the sweeper).\n name String @unique\n // The address daemons dial for THIS relay instance. MUST route to this\n // specific instance (per-pod DNS, or one hostname with relay-id-sticky\n // paths) — a pool-level random LB here breaks the no-cross-pod-forwarding\n // topology (design §5). The pool-level public ingress (browser/webhook) is\n // env-level PUBLIC_RELAY_URL and never stored per relay.\n daemonUrl String\n lastSeenAt DateTime? @db.Timestamptz(6) // rc/heartbeat; drives the failover sweeper\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([lastSeenAt])\n @@map(\"relay\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.15 Bot + Integration — a platform bot identity and its install (C6/C5)\n// `bot` is the durable identity (name + token material): it OUTLIVES the\n// integration that installs it, so uninstalling frees the bot for reuse from\n// the console picker instead of forcing a re-create. `bot_secret` holds the\n// stored SecretCipher representation behind the BotSecretStore seam — the ONLY\n// read/write path for tokens. `none` stores plaintext; an encrypting provider\n// stores ciphertext. `integration` is the install: it binds ONE bot to\n// ONE agent (`botId` unique ⇒ a bot serves at most one agent at a time) and\n// is what the owning daemon opens the socket from. The metadata read paths\n// (GET /bots, GET /integrations) never select the secret table.\n// ───────────────────────────────────────────────────────────────────────────\n\nmodel Bot {\n id String @id @db.Uuid\n orgId String\n platform String @default(\"slack\")\n name String // Slack app / display name\n prebuilt Boolean @default(false) // provisioned by AgentConnect, not a console user\n slackAppId String? // Slack app id (A…), parsed from the pasted xapp token — deep-links the console to api.slack.com/apps/{id}. Public metadata, NOT secret material.\n // Slack workspace id (\"T…\", == Events API `team_id`), persisted by the platform\n // (distributed) app's OAuth callback. Load-bearing for relay demux: every install\n // of a distributed app shares one app id + signing secret, so only the composite\n // (slackAppId, teamId) identifies a workspace's Bot. NULL for legacy /\n // single-workspace bots. Public metadata, NOT secret material.\n teamId String?\n // External workspace identity used only to label/group bot rows in the Console.\n // Slack fills this from auth.test/OAuth for every bot kind. It is deliberately\n // separate from teamId, whose non-null value marks a distributed platform-app\n // install and participates in relay demux/admission behavior.\n workspaceId String?\n workspaceName String?\n // Slack bot user id (\"U…\"/\"B…\"), from the OAuth exchange (`bot_user_id`). Saves\n // the relay an auth.test round-trip and backs echo suppression. Public metadata.\n botUserId String?\n // Stamped when the workspace uninstalled the app or revoked its tokens\n // (`app_uninstalled` / `tokens_revoked` via rc/bot-revoked). A revoked bot is\n // dead credential-wise; a platform-app re-install clears it (fresh token).\n revokedAt DateTime? @db.Timestamptz(6)\n // Install GENERATION of the bot's CURRENT credential. Slack does not guarantee\n // the ordering of `app_uninstalled` / `tokens_revoked`, so a delayed event from\n // a PRIOR install can land after the workspace re-installed — applying it would\n // revoke the fresh token and its live integrations. Both fields advance together\n // every time a new credential lands (`BotRepo.bumpCredential`): the revision is\n // echoed through rc/bot-assign → rc/bot-revoked so revocation can CAS on it, and\n // the timestamp lets the CP reject an event that HAPPENED before the credential\n // it would kill (the case where the relay already holds the newer assignment).\n credentialRevision Int @default(1)\n credentialInstalledAt DateTime? @db.Timestamptz(6)\n // ── generic bot demux identity (integration-plugin-architecture.md D6/§11) ──\n // Generalizes (slackAppId, teamId): the platform's app-scoped id plus its tenant\n // scope. NULL is reserved for LEGACY rows (pre-capture Slack installs keep\n // today's NULLs-distinct semantics; backfilled tenantless rows keep NULL too); a\n // NEW row on a tenantless platform writes the '-' sentinel so the composite\n // unique below enforces (platform, externalAppId) uniqueness declaratively.\n // Dual-write window: reads still ride the legacy per-platform columns; these are\n // written alongside them until the legacy columns fold away.\n externalAppId String?\n externalTenantId String?\n // Display-only per-platform bag (discordAppId / feishuAppId / feishuRegion fold\n // here when reads switch). Never demux identity, never secret material.\n platformConfig Json?\n discordAppId String? // Discord application (client) id, decoded from the bot token's first segment — lets the console offer a ready-made \"Add to Discord\" invite URL. Public metadata (it IS the bot's user id), NOT secret material.\n feishuAppId String? // Feishu/Lark app id (cli_…), copied from the create request so Settings can deep-link to this app without reading bot_secret. Public metadata, NOT secret material.\n feishuRegion String? // Feishu/Lark open-platform gateway for this app: 'feishu' (open.feishu.cn) | 'lark' (open.larksuite.com). NULL ⇒ 'feishu'. DURABLE home (survives uninstall) so a freed Lark bot reinstalls against the right gateway. Public config, NOT secret.\n // Shared-bot opt-in (shared-bot-relay.md §4.1). false ⇒ classic 1-bot:1-agent\n // (daemon owns the whole socket, zero behaviour change). true ⇒ the bot's\n // INBOUND migrates to a relay and it may serve MULTIPLE agents (one Integration\n // row per agent). Per-bot, not per-install — a uniform rule, no mode switching.\n shareable Boolean @default(false)\n // IM ingress transport. `socket` means the daemon owns the platform's outbound\n // long connection (Slack Socket Mode or Feishu WSClient). `http` means the relay\n // receives callbacks and the daemon gets a send-only spec. This is the\n // direct-vs-shared axis; `shareable` is the multi-agent-within-http sub-flag.\n transport SlackTransport @default(socket)\n createdByUserId String? // WebUI user who registered it (audit)\n lastUsedAt DateTime? @db.Timestamptz(6) // stamped when its integration is removed\n lastAgentName String? // agent the bot was last freed from (console display hint)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret BotSecret?\n // A shareable bot backs MANY installs (one per agent); a classic bot backs ≤1.\n // The 1-install cap for classic bots is enforced in the install route, not by a\n // unique FK (dropped so shared bots can fan out).\n integrations Integration[]\n\n // One Bot per workspace install of a distributed app — the relay demux key.\n // NULL teamIds (legacy bots) are distinct under Postgres semantics, so existing\n // rows are unaffected. Cross-org global: a workspace binds to exactly one org.\n @@unique([slackAppId, teamId])\n // The generic successor of the fence above (D6): one Bot per external app\n // identity per platform. Legacy rows carry NULLs (distinct, unaffected); new\n // tenantless rows carry the '-' sentinel, which makes this enforce\n // (platform, externalAppId) uniqueness. Coexists with the Slack fence during\n // the dual-write window.\n @@unique([platform, externalAppId, externalTenantId])\n @@index([orgId])\n @@map(\"bot\")\n}\n\n/**\n * Inbound transport for a bot. The historical enum name is retained to avoid a\n * destructive PostgreSQL enum rename; see Bot.transport.\n */\nenum SlackTransport {\n socket\n http\n}\n\nmodel BotSecret {\n botId String @id @db.Uuid\n botToken String // xoxb-… / Telegram token in stored SecretCipher form; read ONLY via BotSecretStore\n appToken String? // xapp-… for Slack Socket Mode; Feishu/Lark reuses this slot for its app id; NULL for single-token platforms\n // Slack signing secret — verifies inbound Events API POSTs (HMAC). Lives ONLY here\n // + is shipped to the relay in rc/bot-assign (http transport); daemons never get it.\n signingSecret String? // Slack signing secret (http transport); NULL for socket/Telegram\n // Feishu callback credentials. The verification token is required in HTTP mode;\n // encryptKey is optional and enables signed/encrypted callback bodies. Neither is\n // sent to daemons, while the Feishu app secret remains daemon-only.\n verificationToken String?\n encryptKey String?\n\n bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)\n\n @@map(\"bot_secret\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// SharedThreadAgent (slack-http-mode §10) — durable per-sessionKey thread affinity\n// for http-transport shared bots. A relay reports (botId, sessionKey)→{agentId,\n// daemonId} the first time it routes a thread (rc/thread-assign); the CP persists it\n// here (single writer) and broadcasts it to every relay (rc/assign), and answers a\n// pull-on-miss lookup (rc/thread-lookup). Relay-opaque `sessionKey`; no FKs (daemon/\n// agent may churn — mirrors relay / github_install_state FK-less infra).\n// ───────────────────────────────────────────────────────────────────────────\nmodel SharedThreadAgent {\n botId String @db.Uuid\n sessionKey String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@id([botId, sessionKey])\n @@index([botId])\n @@map(\"shared_thread_agent\")\n}\n\n// Durable multi-agent room membership for relay-pooled HTTP bots. Unlike\n// SharedThreadAgent, this is a set and never changes the compatibility owner.\n// It is routing metadata only: no message body or transcript content is stored.\nmodel SharedThreadParticipant {\n botId String @db.Uuid\n sessionKey String\n agentId String @db.Uuid\n daemonId String @db.Uuid\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n @@id([botId, sessionKey, agentId])\n @@index([botId])\n @@map(\"shared_thread_participant\")\n}\n\nenum IntegrationStatus {\n active\n revoked\n}\n\nmodel Integration {\n id String @id @db.Uuid // IntegrationSpec.integrationId\n orgId String\n agentId String @db.Uuid // owner ⇒ delivery daemon (agent.daemonId)\n // The identity this install runs as. NO @unique: a shareable bot fans out to\n // one Integration row per agent (shared-bot-relay.md §4.1). A classic\n // (non-shareable) bot is still capped at ≤1 install by the create route.\n botId String @db.Uuid\n platform String @default(\"slack\")\n name String // Slack app / display name (mirrors bot.name at install time)\n status IntegrationStatus @default(active)\n // Credential generation whose uninstall/token-revocation flipped this row.\n // Null for active rows and user-freed rows (which are deleted, not revoked).\n revokedCredentialRevision Int?\n feishuRegion String? // Feishu/Lark open-platform gateway: 'feishu' (open.feishu.cn) | 'lark' (open.larksuite.com). NULL ⇒ 'feishu' (default / non-feishu integrations). Public config, NOT secret.\n createdByUserId String? // WebUI user who installed it (audit)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n // Restrict: the bot is the durable identity — it must never vanish underneath a\n // live install. Uninstall deletes the integration row and FREES the bot.\n bot Bot @relation(fields: [botId], references: [id], onDelete: Restrict)\n createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)\n channels IntegrationChannel[]\n // Crons/hooks whose target anchor posts through this integration (SetNull on uninstall).\n targetedByCrons CronDef[]\n targetedByHooks HookDef[]\n\n @@index([orgId])\n @@index([agentId])\n @@index([botId])\n @@map(\"integration\")\n}\n\n// How the bot activates in one conversation: not at all (conversation gating,\n// resource-visibility.md §14), only when @-mentioned, or on any message.\nenum ChannelTrigger {\n off\n mention\n any\n}\n\n// Conversation kind (resource-visibility.md §14.3): a member channel vs a direct\n// conversation. Direct rows are observed incrementally for every integration.\n// `mpim` is a Slack multi-person DM — reported on observation like `im` (Slack\n// never lists them as bot membership), but mention-gated like a channel.\nenum ConversationKind {\n channel\n im\n mpim\n}\n\n// One conversation the integration's bot participates in. `integration/channels`\n// carries either an authoritative membership snapshot or a partial observed-\n// conversation report; DM rows and rows absent from partial reports are retained.\n// The operator's per-conversation trigger survives both forms. Channel id/name are\n// control metadata — never message content (§1/§12).\nmodel IntegrationChannel {\n integrationId String @db.Uuid\n channelId String // platform conversation id (Slack \"C…\" / DM \"D…\")\n name String? // \"#deploys\" without the hash (or DM counterpart); null if lookup failed\n // Enclosing space the conversation lives in — a Discord guild. One bot spans several\n // servers, each with its own \"#general\", so the console needs it to tell the rows\n // apart. `spaceId` (the guild snowflake) is the identity — two guilds may share a\n // name — and `space` is the display label. Null on single-container platforms, on DM\n // rows, and until the daemon resolves them.\n spaceId String?\n space String?\n isPrivate Boolean @default(false)\n kind ConversationKind @default(channel)\n // Relay-backed channel rows repeat the effective trigger across each active\n // integration so deleting the canonical owner does not discard channel state.\n // Direct rows remain independently configurable per integration.\n trigger ChannelTrigger @default(mention)\n // Per-channel default/owning agent for a SHARED bot (shared-bot-relay.md §10.1\n // channel ownership — the primary disambiguation path). Exactly one active\n // integration row per shared channel carries the owner; sibling rows are null.\n // Irrelevant for a classic integration, where its agent is the only target.\n agentId String? @db.Uuid\n firstSeenAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n @@id([integrationId, channelId])\n @@index([agentId])\n @@map(\"integration_channel\")\n}\n\n// Pending Slack auto-install session (docs/designs/slack-install-smoothing.md §Tier B).\n// A short-lived bridge for the config-token funnel: the app is created via\n// apps.manifest.create and its client credentials + the OAuth-obtained bot token\n// live here until the operator pastes the app-level token and `finalize` creates\n// the real bot + integration (then this row is DELETED). `clientSecret`/`botToken`\n// pass through SecretCipher with the same discipline as `bot_secret` — plaintext\n// under `none`, ciphertext under an encrypting provider; NEVER logged or included\n// in a DTO. No FKs (mirrors github_install_state): a dangling row after an\n// org/agent delete is harmless and TTL-reaped. The `id` doubles as the\n// unforgeable OAuth `state`.\nmodel SlackInstall {\n id String @id @db.Uuid // == OAuth state\n orgId String\n agentId String @db.Uuid // install target (owner ⇒ delivery daemon)\n appId String // A… — manifest-created app id (deep links + association)\n clientId String\n clientSecret String // sensitive — secret discipline, never logged/DTO'd\n botToken String? // xoxb-…, backfilled by the OAuth callback\n name String? // operator-chosen app name; null ⇒ derived at finalize\n // slack-http-mode quick-install: the finalize path + the http signing secret that\n // apps.manifest.create returns at start (the browser never sees it), so an http\n // auto-install finalizes with no manual paste. `shareable` is NOT stored — it's a\n // non-secret choice the console re-sends in the finalize body.\n transport SlackTransport @default(socket)\n signingSecret String? // http: captured at app-create; used at finalize\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"slack_install\")\n}\n\n// Pending install of the PLATFORM-published (distributed) Slack app\n// (preset-agents.md §5.3). Unlike SlackInstall, no per-app credentials live here —\n// the app id / client secret / signing secret are deployment env config\n// (SLACK_PLATFORM_*). The row binds the OAuth `state` to either {org, target\n// agent, initiating user} or {org, expected bot, initiating user}. The `id`\n// doubles as the unforgeable OAuth `state`; rows are TTL-reaped alongside\n// slack_install. No FKs (mirrors slack_install): a dangling row after an\n// org/agent/bot delete is harmless.\nmodel SlackPlatformInstall {\n id String @id @db.Uuid // == OAuth state\n orgId String\n // Generic installs bind a target agent. A bot-bound Settings reauthorization\n // leaves this null so a freed bot stays free.\n agentId String? @db.Uuid\n // Terminal state of the OAuth round trip — the row IS the console's completion\n // signal. It must survive the callback (not be deleted) because a successful\n // RE-authorization of a workspace this agent already has need not create any\n // new integration: \"a new integration appeared\" cannot distinguish success\n // from a still-pending tab. `failureReason` carries the same short code the\n // callback's close page shows, so the console can report WHY it failed.\n status SlackPlatformInstallStatus @default(pending)\n failureReason String?\n // For a Settings reauthorization this is populated while pending and fences\n // the OAuth callback to that exact Bot/workspace. Generic installs fill it on\n // completion for the console deep-link.\n botId String? @db.Uuid\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n settledAt DateTime? @db.Timestamptz(6)\n\n @@map(\"slack_platform_install\")\n}\n\n/**\n * Terminal state of a platform Slack app install (see SlackPlatformInstall.status).\n */\nenum SlackPlatformInstallStatus {\n pending\n completed\n failed\n}\n\n// Durable Feishu/Lark one-click app registration. The browser polls this row,\n// so any Control Plane replica can continue the provider device flow after a\n// request is load-balanced or a process restarts. `deviceCode` and `appSecret`\n// pass through SecretCipher in PgFeishuAppRegistrationStore and are cleared on\n// every terminal outcome. No FKs: the route re-validates the agent immediately\n// before installation, and abandoned rows are TTL-reaped.\nmodel FeishuAppRegistration {\n id String @id @db.Uuid\n // Non-null only while open. The unique slot prevents two users from creating\n // two apps for the same target; terminal settlement clears it.\n targetKey String? @unique\n orgId String\n agentId String @db.Uuid\n requestedName String?\n fallbackRegion String\n transport SlackTransport @default(socket)\n authorizationUrl String\n providerDomain String\n deviceCode String? // sensitive — sealed by SecretCipher\n intervalMs Int\n nextPollAt DateTime @db.Timestamptz(6)\n expiresAt DateTime @db.Timestamptz(6)\n status FeishuAppRegistrationStatus @default(pending)\n failureReason String?\n appId String?\n appSecret String? // sensitive — sealed by SecretCipher\n resolvedRegion String?\n // Pre-reserved IDs make finalization restart-idempotent.\n botId String @db.Uuid\n integrationId String @db.Uuid\n createdByUserId String?\n claimToken String? @db.Uuid\n claimedUntil DateTime? @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n settledAt DateTime? @db.Timestamptz(6)\n\n @@index([status, nextPollAt])\n @@map(\"feishu_app_registration\")\n}\n\nenum FeishuAppRegistrationStatus {\n pending\n authorized\n completed\n failed\n}\n\n// One user's stored Slack App Configuration Token, scoped to an org (composite key\n// orgId+userId) — docs/designs/slack-install-smoothing.md §Tier B. PER-USER on purpose:\n// the app that `apps.manifest.create` builds is owned by whoever's config token\n// created it, and only that user can then generate the app's app-level (xapp) token\n// on api.slack.com. So each initiator stores their OWN token and installs entirely\n// on their own — no shared token that pins every app to one person. When the caller\n// has one AND the funnel is enabled, the console FORCES auto-install; absent ⇒ the\n// manual flow. The access token expires ~12h after issue, so we persist the refresh\n// token and rotate via `tooling.tokens.rotate` when it is stale (each rotate returns\n// a NEW pair — last write wins). `accessToken`/`refreshToken` pass through\n// SecretCipher with the same discipline as `bot_secret`: plaintext under `none`,\n// ciphertext under an encrypting provider; NEVER logged or included in a DTO.\nmodel SlackUserConfig {\n orgId String\n userId String\n accessToken String // xoxe.xoxp-… — the App Configuration access token\n refreshToken String? // xoxe-… — used to rotate a fresh access token; null ⇒ access-only (expires ~12h, then re-enter)\n accessExpiresAt DateTime @db.Timestamptz(6) // when accessToken expires (~12h; drives rotation / re-entry)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([orgId, userId])\n @@map(\"slack_user_config\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// §3.12 AuditEvent — audit log / events feed (C6 + C7)\n// ───────────────────────────────────────────────────────────────────────────\n\nenum AuditKind {\n daemon_auth\n daemon_register\n daemon_unreachable\n route_assign\n route_release\n drain\n agent_launch\n agent_stop\n scope_denied\n secret_grant\n secret_revoke\n cron_change\n hook_change\n agent_repo_change\n org_invite_change\n protocol_error\n api_key_create\n api_key_rotate\n api_key_revoke\n mcp_tool_call\n}\n\nmodel AuditEvent {\n id BigInt @id @default(autoincrement()) @db.BigInt\n orgId String?\n kind AuditKind\n daemonId String? @db.Uuid\n agentId String? @db.Uuid\n sessionId String? @db.Uuid\n actorUserId String? // set when action came from WebUI\n frameType String? // \"route/assign\", \"error\", …\n frameCorr String? @db.Uuid // Envelope.id / corr (tracing)\n message String? @db.Text // redacted, human-readable\n details Json? @db.JsonB // {expected:<seq>}, {capability}, …\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@index([orgId, createdAt])\n @@index([daemonId, createdAt])\n @@index([kind, createdAt])\n @@map(\"audit_event\")\n}\n\n// ── GitHub App (github-app workspaces; docs/designs/github-app-git-credentials.md §Configuration and Data Model) ──\n// The App identity itself (id/slug/private key) is DEPLOYMENT CONFIG (GITHUB_APP_* env),\n// never persisted. Only its installations are rows, claimed to an org.\n// Visibility taxonomy: infrastructure, like `bot` — always org-visible, never restricted.\n\nenum GitAccess {\n read\n write\n}\n\n/// One installation of the deployment GitHub App on a GitHub org/user account,\n/// claimed by an AgentConnect org. Rows are only ever MARKED dead (`revokedAt`)\n/// — never deleted — because agents keep `installationId` provenance pointers;\n/// minting resolves live installations by account login, not by row liveness.\nmodel GithubInstallation {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n installationId BigInt @unique // GitHub-side installation id\n accountLogin String // e.g. \"example-org\"\n accountType String // \"Organization\" | \"User\"\n repositorySelection String // \"all\" | \"selected\"\n // Installation-effective permissions as returned by GitHub. Unknown/legacy\n // rows use {}, which is intentionally fail-closed for review/check effects.\n permissions Json @default(\"{}\") @db.JsonB\n suspendedAt DateTime? @db.Timestamptz(6)\n revokedAt DateTime? @db.Timestamptz(6) // sync found it uninstalled ⇒ mark, don't delete\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@index([orgId])\n @@map(\"github_installation\")\n}\n\n/// One-shot HMAC-signed install-state nonces (the `state` on the GitHub install\n/// deep link / setup callback). Consumed exactly once — replay ⇒ reject. Expired\n/// rows are garbage; consumption deletes, and expiry is also embedded in the\n/// signed state itself so stale rows can never be replayed.\nmodel GithubInstallState {\n nonce String @id // random 128-bit, base64url\n orgId String\n expiresAt DateTime @db.Timestamptz(6)\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n @@map(\"github_install_state\")\n}\n\n// ───────────────────────────────────────────────────────────────────────────\n// Centralized tool management — MCP provider registry (docs/designs/\n// centralized-tool-management.md). CP owns MCP provider definitions; the relay\n// reverse-proxies agent calls so the upstream credential never reaches the\n// daemon/agent. Mirrors the Bot / BotSecret split: metadata table + secret\n// side-table read only via McpProviderSecretStore.\n// ───────────────────────────────────────────────────────────────────────────\nenum McpTransport {\n http\n sse\n}\n\n// How a provider row was created / what manages it. `custom` = an operator-entered\n// upstream MCP server (url + headers). `open_connector` = a connection provisioned\n// through the open-connector integration (docs: connectors); the url is the\n// open-connector /mcp endpoint and the upstream header carries the connection\n// profile alias. Display + create-flow discriminator only — the wire (rc/mcp-assign,\n// daemon proxy def) is identical for both.\nenum McpProviderKind {\n custom\n open_connector\n}\n\nmodel McpProvider {\n // uuid (not cuid): providerId rides the wire as rc/mcp-assign.providerId (z.string().uuid()).\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String // reference key; the agent enable-list + probe facts key on it\n kind McpProviderKind @default(custom) // custom upstream vs open-connector connection\n transport McpTransport @default(http) // v1 accepts http (Streamable HTTP) only\n url String // upstream endpoint (non-secret; may appear in DTOs)\n // Console visibility — same Shareable model as Agent/Daemon/Cron\n // (docs/designs/resource-visibility.md): 'org' = every member sees it; 'restricted'\n // = the complete sharedWith audience. Governs console access only — an agent that\n // already enabled a provider keeps reaching it regardless (never crosses the wire).\n visibility ResourceVisibility @default(org)\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"McpProviderCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret McpProviderSecret?\n grants McpGrant[]\n\n @@unique([orgId, name])\n @@index([orgId])\n @@map(\"mcp_provider\")\n}\n\n// Upstream auth headers pass through SecretCipher: plaintext under `none`,\n// ciphertext under an encrypting provider. Read ONLY via\n// McpProviderSecretStore, never in a DTO, and never pushed to a daemon (same\n// discipline as bot_secret). Shipped to the relay in rc/mcp-assign, injected on\n// forward.\nmodel McpProviderSecret {\n mcpProviderId String @id @db.Uuid\n headers Json @default(\"[]\") @db.JsonB // {name,value}[] — upstream apikey etc.\n\n provider McpProvider @relation(fields: [mcpProviderId], references: [id], onDelete: Cascade)\n\n @@map(\"mcp_provider_secret\")\n}\n\n// A proxy grant: the bearer key the daemon injects into the agent and the relay\n// validates. v1 = one active grant per provider (shared identity). The CP re-pushes\n// the plaintext key to daemons on every reconcile, so the persisted value must be\n// recoverable through SecretCipher. `none` stores plaintext; an encrypting provider\n// stores ciphertext. The key remains store-only and never enters a DTO; the relay\n// receives only sha256(key) via rc/mcp-assign.\nmodel McpGrant {\n id String @id @default(cuid())\n mcpProviderId String @db.Uuid\n key String @unique // bearer grant key in stored SecretCipher form; store-only\n status String @default(\"active\") // active | revoked\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n provider McpProvider @relation(fields: [mcpProviderId], references: [id], onDelete: Cascade)\n\n @@index([mcpProviderId])\n @@map(\"mcp_grant\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// Shared skills registry (docs/designs/shared-skills.md). The CP records only a\n// bounded public GitHub SOURCE, its numeric repository identity, and optional\n// ref/subdir/skill filter — skill CONTENT never touches the CP. The daemon\n// acquires a commit-bound local snapshot and installs it with its bundled exact\n// CLI before the ACP host spawns. There is no secret side-table or repo grant.\n//\n// The per-agent enable-list is NOT a relation here; like mcpServers it lives as\n// a string[] of \"<sourceName>/<skillName>\" (or \"<sourceName>/*\") inside the\n// agent's runtimeOverrides JSON bag. The CP resolves those into self-contained\n// AgentSpec.skills entries when it assembles the spec (agentSpecAssembler).\nmodel SkillSource {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String // reference key; the agent enable-list keys on it. @@unique([orgId, name])\n source String // bounded GitHub acquisition input; the CLI sees only a local snapshot\n githubRepoId BigInt? // nullable for migration; unbound rows never project to AgentSpec\n ref String? // optional branch/tag/commit; composed into the source (design §5)\n subDir String? // optional repo-relative install dir\n skills String[] @default([]) // empty ⇒ install every skill; else only these (passed as -s)\n visibility ResourceVisibility @default(org)\n sharedWith String[] @default([]) // complete app_user.id audience when visibility='restricted'\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"SkillSourceCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n\n @@unique([orgId, name])\n @@index([orgId])\n @@map(\"skill_source\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// Organization Knowledge + managed Agent Skills bundles\n// (docs/designs/organization-knowledge.md).\n// Pending candidate bodies stay daemon-local; only their metadata is indexed\n// here. Accepted revisions are immutable product-owned shared content.\n// ───────────────────────────────────────────────────────────────────────\n\n// ── organization environment registry (organization-secrets-and-variables.md) ──\n\nenum OrganizationEnvironmentKind {\n variable\n secret\n}\n\n/// How an entry enrolls agents. `all` is an automatic-ENROLLMENT policy, not an\n/// authorization bypass: every effective assignment is still an explicit\n/// OrganizationEnvironmentAssignment row created under a `resource.edit` decision\n/// for that agent (design §3.4).\nenum OrganizationEnvironmentAudience {\n all\n selected\n}\n\n/// One organization-owned variable or secret. Metadata only — a secret's value\n/// lives in the sibling OrganizationEnvironmentSecret row so list and human-DTO\n/// queries can never join it (the AgentSecret discipline).\n///\n/// `key` and `kind` are IMMUTABLE after creation: renaming or converting is an\n/// explicit delete-and-create, which prevents an edit from silently changing the\n/// meaning of the same organization-owned credential (design §3.1).\nmodel OrganizationEnvironmentEntry {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n key String // env var name, validated at the API edge\n kind OrganizationEnvironmentKind\n variableValue String? // non-null ONLY for kind=variable\n audience OrganizationEnvironmentAudience\n version Int @default(1) // editor-conflict fence for PATCH (expectedVersion)\n createdByUserId String?\n lastModifiedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"OrgEnvCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n modifiedBy User? @relation(\"OrgEnvModifiedBy\", fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)\n secret OrganizationEnvironmentSecret?\n assignments OrganizationEnvironmentAssignment[]\n\n // ONE organization keyspace: an org cannot hold both a variable and a secret\n // with the same key (design §3.1).\n @@unique([orgId, key])\n // Referenced by the assignment FK so a binding cannot cross organizations.\n @@unique([id, orgId])\n @@index([orgId, audience])\n @@map(\"organization_environment_entry\")\n}\n\n/// The write-only value of an organization secret. Every value passes through the\n/// injected SecretCipher via OrganizationEnvironmentSecretStore — the ONLY\n/// value-reading seam — so at-rest encryption stays a wiring change.\nmodel OrganizationEnvironmentSecret {\n entryId String @id @db.Uuid\n value String // passes through the SecretCipher seam (plaintext under the identity cipher)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n entry OrganizationEnvironmentEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)\n\n @@map(\"organization_environment_secret\")\n}\n\n/// One entry→agent binding: the durable delegation created when a request\n/// authorized for `resource.edit` on that agent enrolled it (design §4). Once it\n/// exists, `organization.manage` may rotate or delete the entry without gaining\n/// any visibility into the agent.\nmodel OrganizationEnvironmentAssignment {\n orgId String\n entryId String @db.Uuid\n agentId String @db.Uuid\n authorizedByUserId String? // the actor whose resource.edit decision created the delegation\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n // Both FKs are COMPOSITE on orgId: a cross-organization binding is impossible\n // even for an internal caller, not merely rejected in application code.\n entry OrganizationEnvironmentEntry @relation(fields: [entryId, orgId], references: [id, orgId], onDelete: Cascade)\n agent Agent @relation(fields: [agentId, orgId], references: [id, orgId], onDelete: Cascade)\n authorizedBy User? @relation(\"OrgEnvAuthorizedBy\", fields: [authorizedByUserId], references: [id], onDelete: SetNull)\n\n @@id([entryId, agentId])\n @@index([orgId, agentId])\n @@map(\"organization_environment_assignment\")\n}\n\nenum OrganizationArtifactSource {\n manual\n dream\n}\n\nenum OrganizationSuggestionKind {\n knowledge\n skill\n}\n\nenum OrganizationSuggestionOperation {\n create\n update\n}\n\nenum OrganizationSuggestionState {\n pending\n accepted\n rejected\n}\n\nmodel OrganizationKnowledge {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n title String\n currentRevision Int @default(1)\n archivedAt DateTime? @db.Timestamptz(6)\n archivedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n revisions OrganizationKnowledgeRevision[]\n\n @@index([orgId, archivedAt, updatedAt])\n @@map(\"organization_knowledge\")\n}\n\nmodel OrganizationKnowledgeRevision {\n knowledgeId String @db.Uuid\n revision Int\n content String @db.Text\n summary String? @db.Text\n tags String[] @default([])\n digest String\n source OrganizationArtifactSource\n sourceAgentId String? @db.Uuid\n sourceDreamId String?\n sourceCandidateId String? @db.Uuid\n sourceSessionIds String[] @default([])\n createdByUserId String?\n reviewedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n knowledge OrganizationKnowledge @relation(fields: [knowledgeId], references: [id], onDelete: Cascade)\n\n @@id([knowledgeId, revision])\n @@index([createdAt])\n @@map(\"organization_knowledge_revision\")\n}\n\nmodel ManagedSkill {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n name String\n description String\n currentRevision Int @default(1)\n archivedAt DateTime? @db.Timestamptz(6)\n archivedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n revisions ManagedSkillRevision[]\n\n @@unique([orgId, name])\n @@index([orgId, archivedAt, updatedAt])\n @@map(\"managed_skill\")\n}\n\nmodel ManagedSkillRevision {\n managedSkillId String @db.Uuid\n revision Int\n archive Bytes\n digest String\n compressedBytes Int\n expandedBytes Int\n fileCount Int\n manifest Json @db.JsonB\n source OrganizationArtifactSource\n sourceAgentId String? @db.Uuid\n sourceDreamId String?\n sourceCandidateId String? @db.Uuid\n sourceSessionIds String[] @default([])\n createdByUserId String?\n reviewedByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n skill ManagedSkill @relation(fields: [managedSkillId], references: [id], onDelete: Cascade)\n\n @@id([managedSkillId, revision])\n @@index([createdAt])\n @@map(\"managed_skill_revision\")\n}\n\nmodel OrganizationSuggestion {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n sourceAgentId String @db.Uuid\n sourceDaemonId String? @db.Uuid\n dreamId String\n candidateId String @db.Uuid\n kind OrganizationSuggestionKind\n operation OrganizationSuggestionOperation\n targetArtifactId String? @db.Uuid\n targetRevision Int?\n title String\n summary String? @db.Text\n tags String[] @default([])\n digest String\n contentBytes Int\n sessionIds String[] @default([])\n state OrganizationSuggestionState @default(pending)\n reviewedByUserId String?\n reviewedAt DateTime? @db.Timestamptz(6)\n reviewReason String? @db.Text\n acceptedArtifactId String? @db.Uuid\n acceptedArtifactRevision Int?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n\n @@unique([sourceAgentId, dreamId, candidateId])\n @@index([orgId, state, createdAt])\n @@index([sourceDaemonId, state])\n @@map(\"organization_suggestion\")\n}\n\n// ───────────────────────────────────────────────────────────────────────\n// External-memory plugin control plane (docs/designs/memory-evolution.md M-5A).\n// Purpose-separated from the model-facing MCP registry. Upstream secret values\n// and daemon grant keys live only in side tables whose values pass through the\n// configured SecretCipher.\n// ────────────────────────────────────────────────────────────────────────\nenum MemoryPluginTransport {\n streamable_http\n stdio\n}\n\nenum ExternalMemoryConnectionStatus {\n probing\n ready\n degraded\n invalid\n}\n\nmodel MemoryPluginInstallation {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n pluginId String\n transport MemoryPluginTransport @default(streamable_http)\n endpoint String?\n commandRef String?\n pinnedProfileMajor Int @default(1)\n expectedManifestDigest String?\n // Reviewed logical-secret → upstream-header mapping. Header names are\n // configuration; values live only in ExternalMemoryConnectionSecret.\n secretHeaders Json @default(\"[]\") @db.JsonB\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n createdBy User? @relation(\"MemoryPluginInstallationCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n connections ExternalMemoryConnection[]\n\n // One plugin may have multiple independently reviewed immutable pins: another\n // endpoint, manifest digest, or secret contract must not overwrite agents\n // still using the old installation.\n @@index([orgId, pluginId])\n @@map(\"memory_plugin_installation\")\n}\n\nmodel ExternalMemoryConnection {\n id String @id @default(uuid()) @db.Uuid\n orgId String\n installationId String @db.Uuid\n config Json @default(\"{}\") @db.JsonB\n status ExternalMemoryConnectionStatus @default(probing)\n revision Int @default(1)\n probedRevision Int?\n pluginVersion String?\n profile String?\n manifestDigest String?\n capabilities Json? @db.JsonB\n declaredEgressHosts String[] @default([])\n reasonCode String?\n createdByUserId String?\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n updatedAt DateTime @updatedAt @db.Timestamptz(6)\n\n org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)\n installation MemoryPluginInstallation @relation(fields: [installationId], references: [id], onDelete: Restrict)\n createdBy User? @relation(\"ExternalMemoryConnectionCreatedBy\", fields: [createdByUserId], references: [id], onDelete: SetNull)\n secret ExternalMemoryConnectionSecret?\n grants ExternalMemoryGrant[]\n\n @@index([orgId])\n @@index([installationId])\n @@map(\"external_memory_connection\")\n}\n\nmodel ExternalMemoryConnectionSecret {\n connectionId String @id @db.Uuid\n values Json @default(\"{}\") @db.JsonB\n\n connection ExternalMemoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)\n\n @@map(\"external_memory_connection_secret\")\n}\n\nmodel ExternalMemoryGrant {\n id String @id @default(cuid())\n connectionId String @db.Uuid\n key String @unique\n status String @default(\"active\")\n createdAt DateTime @default(now()) @db.Timestamptz(6)\n\n connection ExternalMemoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)\n\n @@index([connectionId])\n @@map(\"external_memory_grant\")\n}\n",
|
|
56422
56422
|
"runtimeDataModel": {
|
|
56423
56423
|
"models": {},
|
|
56424
56424
|
"enums": {},
|