Designing a Polymorphic Assignee Model for Humans and AI Agents
How BugMojo models assignee as a (assignee_type, assignee_id) pair so a human teammate and an AI agent are first-class owners across the Prisma schema, the tRPC API, and the MCP tool surface.

Most bug trackers were designed when the only thing that could own a ticket was a person. Now AI coding agents triage, analyze, and fix bugs. If your data model treats an agent as a special case — a flag here, a side table there — every query, every API response, and every UI dropdown grows a branch. BugMojo took the opposite bet: an agent is a peer of a human at the row level. The mechanism is a polymorphic assignee, and it is small enough to fit on one screen of schema. This post walks the actual implementation: the shared enum, the inline columns, the discriminated-union resolver, and the loop where assigning to an agent queues real work.
What is a polymorphic assignee, in one paragraph?
A polymorphic assignee stores ownership as two fields: a discriminant assignee_type with values like MEMBER or AGENT, and a single assignee_id pointing at whichever actor table that type names. One column declares the kind, one column declares the identity. Exactly one logical owner exists, or both are null for unassigned. The same pair models humans and AI agents identically across the database, the API, and the agent tool surface.
The naive alternative is two nullable foreign keys, member_id and agent_id, with the convention that only one is set. The database cannot enforce that convention. It will happily store a row with both set, or neither, and every read has to COALESCE across the columns to figure out who owns the thing. Add a third actor type and you add a third column and a third partial index. The pair model collapses all of that into one discriminant plus one id.
The Prisma schema: single-table inheritance
Prisma has no native union type, so it documents this shape as Single-Table Inheritance: one table, a discriminator column, and variant-specific fields marked optional with ?. BugMojo applies that pattern directly. A shared ActorType enum names the kinds of owner; assignee_id and assignee_type live inline on the Issue row; a composite index makes ownership queries a single B-tree lookup.
enum ActorType {
MEMBER
AGENT
SYSTEM
}
model Issue {
id String @id @default(cuid())
project_id String
title String
status IssueStatus @default(OPEN)
// Polymorphic assignee: one discriminant + one id.
// Null/null means unassigned. Not a DB-level FK —
// the resolver hydrates the right table by type.
assignee_id String?
assignee_type ActorType?
project Project @relation(fields: [project_id], references: [id])
created_at DateTime @default(now())
// 'open bugs owned by this actor' is one index scan.
@@index([project_id, assignee_id])
@@index([project_id, status])
}The honest cost is in the comment: assignee_id is not a foreign key Postgres can validate, because it points at different tables depending on assignee_type. Referential integrity for the member-or-agent link moves into application code. We accept that because the read path — list a project's bugs and who owns each — is the hot path, and inline columns keep it join-free.
Resolving the assignee: a discriminated union
On the read side, the discriminant becomes a TypeScript discriminated union. The Handbook describes the rule precisely: when every member of a union shares a common property with literal types, the compiler treats it as a discriminated union and narrows the members on a switch. assignee_type is that discriminant. Better still, you can lean on never for exhaustiveness — add a new ActorType and the build breaks until you handle it.
type Assignee =
| { type: "MEMBER"; id: string; name: string; avatarUrl: string | null }
| { type: "AGENT"; id: string; name: string; agentKind: AgentKind }
| { type: "SYSTEM"; id: string; name: string };
async function hydrateAssignee(
assigneeType: ActorType | null,
assigneeId: string | null,
): Promise<Assignee | null> {
if (!assigneeType || !assigneeId) return null; // unassigned
switch (assigneeType) {
case "MEMBER": {
const m = await db.member.findUniqueOrThrow({ where: { id: assigneeId } });
return { type: "MEMBER", id: m.id, name: m.name, avatarUrl: m.avatar_url };
}
case "AGENT": {
const a = await db.agent.findUniqueOrThrow({ where: { id: assigneeId } });
return { type: "AGENT", id: a.id, name: a.name, agentKind: a.type };
}
case "SYSTEM":
return { type: "SYSTEM", id: assigneeId, name: "BugMojo" };
default: {
// Exhaustiveness: a new ActorType fails to compile here.
const _exhaustive: never = assigneeType;
return _exhaustive;
}
}
}Doing one lookup per issue is the obvious N+1 trap. Batch it instead: collect every MEMBER id and every AGENT id from a page of issues, run two IN queries, then stitch the results back by id. A 50-row board costs two queries, not fifty.
Why this is AI-first: the same discriminant reaches MCP
Here is where the model pays off. The Model Context Protocol defines tools as model-controlled: per the spec, "the language model can discover and invoke tools automatically based on its contextual understanding and the user's prompts." Each tool is a JSON-RPC 2.0 message with a name, a description, and an inputSchema, discovered via tools/list and invoked via tools/call. So assign_bug is just another tool the model selects — and its inputSchema carries the very same assignee_type enum the database uses.
// Exposed identically to Claude Code and Cursor via tools/list.
export const assignBugTool = {
name: "assign_bug",
description: "Assign a bug to a team member or an AI agent.",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
assignee_id: { type: "string" },
assignee_type: { type: "string", enum: ["MEMBER", "AGENT"] },
},
required: ["project_id", "assignee_id", "assignee_type"],
},
} as const;
// When assignee_type === 'AGENT', the server closes the loop:
// it spawns an AgentTask so the assignment queues real work.
async function onAssign(input: AssignBugInput) {
await db.issue.update({
where: { id: input.bug_id },
data: { assignee_id: input.assignee_id, assignee_type: input.assignee_type },
});
if (input.assignee_type === "AGENT") {
await db.agentTask.create({
data: { agent_id: input.assignee_id, bug_id: input.bug_id, task_type: "FIX_BUG" },
});
}
}The architecture spec builds every interaction on JSON-RPC 2.0 with capability negotiation at initialization, so the same tool surface (assign_bug, list_team, get_bug) is exposed identically to every client — no per-client assignee logic. An agent calling assign_bug with assignee_type: 'AGENT' is doing exactly what a human does clicking a dropdown. And BugMojo closes the loop: an AGENT assignment auto-spawns an AgentTask (FIX_BUG or BUG_ANALYSIS), so the assignment queues work rather than just painting a label.
Two-nullable-FKs vs. the polymorphic pair
| Feature | Polymorphic pair (BugMojo) | Two nullable FKs |
|---|---|---|
| Illegal states representable | No — one type, one id, or both null | Yes — both set or neither |
| Adding a third actor (SYSTEM) | One enum value | New column + new partial index |
| DB-enforced foreign key | No — app-level integrity | Yes — real FK per column |
| Read path for 'who owns this bug' | One index scan, no join | COALESCE across columns |
| MCP / AI-agent assignment as a peer | Same MEMBER/AGENT enum end to end | Agent path diverges from member path |
Migration checklist for adopting the pair
- Define a shared
ActorTypeenum (MEMBER,AGENT, optionallySYSTEM) once, in the schema package. - Add
assignee_id String?andassignee_type ActorType?inline on the owned model; add@@index([project_id, assignee_id]). - Backfill: set
assignee_type = 'MEMBER'for every existingmember_id, copy the id, then drop the old FK columns. - Write one
hydrateAssigneeresolver with aswitchand aneverdefault for exhaustiveness. - Batch hydration — two
INqueries per page, never one per row. - Expose
assign_bugwith aninputSchemawhose enum matchesActorType; spawn anAgentTaskonAGENTassignment.
Frequently asked questions
Frequently asked questions
Sources
- Model Context Protocol — Tools (spec 2025-06-18) — Anthropic / MCP (2025-06-18)
- Model Context Protocol — Architecture (spec 2025-06-18) — Anthropic / MCP (2025-06-18)
- TypeScript Handbook — Narrowing (Discriminated Unions & Exhaustiveness) — Microsoft / TypeScript (2024)
- Prisma ORM Docs — Table Inheritance (Single-Table Inheritance) — Prisma (2025)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

