BugMojoBugMojoBugMojo
FeaturesPricingBlogGuidesAbout
Log inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

A product of Softech Infra.

Product

  • Features
  • Pricing
  • Get started
  • Log in

Resources

  • Blog
  • Guides
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Privacy
  • Sitemap
  • Engineering
  • Playbooks
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Engineering
  4. Designing a Polymorphic Assignee Model for Humans and AI Agents
Engineering

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.

Hrishikesh BaidyaHrishikesh Baidya·Jun 5, 2026·6 min read
Engineering
Isometric line-art of a bug record whose assignee field forks into MEMBER and AGENT nodes on a dark canvas
TL;DR

Model the assignee as a pair: a discriminant assignee_type (MEMBER or AGENT) plus one assignee_id, stored inline on the Issue row. One enum, one nullable id, indexed as @@index([project_id, assignee_id]). The same MEMBER/AGENT discriminant flows through the Prisma schema, the tRPC API, and the MCP tool surface, so an AI agent is a first-class owner — not a bot bolted onto a human board.

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.

packages/db/prisma/schema.prismaprisma
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.

apps/web/src/server/routers/issue.tstypescript
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.

packages/mcp-server/src/tools/assign-bug.tstypescript
// 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

FeaturePolymorphic pair (BugMojo)Two nullable FKs
Illegal states representableNo — one type, one id, or both nullYes — both set or neither
Adding a third actor (SYSTEM)One enum valueNew column + new partial index
DB-enforced foreign keyNo — app-level integrityYes — real FK per column
Read path for 'who owns this bug'One index scan, no joinCOALESCE across columns
MCP / AI-agent assignment as a peerSame MEMBER/AGENT enum end to endAgent path diverges from member path
BugMojo honestly loses the database-enforced-FK row: with one id pointing at two tables, Postgres can't validate the link.

Migration checklist for adopting the pair

  • Define a shared ActorType enum (MEMBER, AGENT, optionally SYSTEM) once, in the schema package.
  • Add assignee_id String? and assignee_type ActorType? inline on the owned model; add @@index([project_id, assignee_id]).
  • Backfill: set assignee_type = 'MEMBER' for every existing member_id, copy the id, then drop the old FK columns.
  • Write one hydrateAssignee resolver with a switch and a never default for exhaustiveness.
  • Batch hydration — two IN queries per page, never one per row.
  • Expose assign_bug with an inputSchema whose enum matches ActorType; spawn an AgentTask on AGENT assignment.
Key takeaway

One discriminant plus one id is enough to make AI agents first-class owners. Keep the MEMBER/AGENT enum identical across Prisma, tRPC, and MCP, lean on TypeScript exhaustiveness so a new actor can't silently slip through, and accept the one honest tradeoff: integrity for the polymorphic link lives in your code, not in Postgres.

See the assignee enum travel the full MCP wire
Read how the BugMojo MCP server works

Frequently asked questions

Frequently asked questions

Sources

  1. Model Context Protocol — Tools (spec 2025-06-18) — Anthropic / MCP (2025-06-18)
  2. Model Context Protocol — Architecture (spec 2025-06-18) — Anthropic / MCP (2025-06-18)
  3. TypeScript Handbook — Narrowing (Discriminated Unions & Exhaustiveness) — Microsoft / TypeScript (2024)
  4. Prisma ORM Docs — Table Inheritance (Single-Table Inheritance) — Prisma (2025)
Share:
Hrishikesh Baidya
Hrishikesh Baidya· Chief Technology Officer

Hrishikesh Baidya is the CTO at Softech Infra. He is drawn to architecture that is invisible — systems that simply work — and leads the engineering behind BugMojo.

On this page

  • What is a polymorphic assignee, in one paragraph?
  • The Prisma schema: single-table inheritance
  • Resolving the assignee: a discriminated union
  • Why this is AI-first: the same discriminant reaches MCP
  • Two-nullable-FKs vs. the polymorphic pair
  • Migration checklist for adopting the pair

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.