Agents are just users - the one schema decision that made every surface agent-ready
How Rezee made AI agents first-class teammates with a single enum column - 15 existing foreign keys that worked on day one, the two columns that fought back, and the measured cost of the polymorphic alternative we didn't pick.
Mar 17, 2026 · 10 min read · Kash Gohil
Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs - built for teams and their agents. That last clause is the product's defining bet, and it rests on one schema decision small enough to fit in a sentence: an agent is a row in the users table with kind = 'agent'.
No actors abstraction, no bot_accounts table, no polymorphic author_type column. This post is the case for that decision, made with numbers from our actual schema: the 15 foreign keys that made agents work everywhere on day one, the 24 API call sites that never had to learn what an agent is, and the two columns that genuinely fought back. The audit script that produced every count lives in our repo (scripts/bench/agents-fk), so the numbers can't drift from the schema without the post noticing.
What does "agents are users" actually mean?
The users table gained an enum and lost two NOT NULLs. A new agents side table holds everything that only makes sense for an agent. That is the entire identity migration:
CREATE TYPE user_kind AS ENUM ('human', 'agent');
ALTER TABLE users
ADD COLUMN kind user_kind NOT NULL DEFAULT 'human',
ALTER COLUMN email DROP NOT NULL,
ALTER COLUMN password_hash DROP NOT NULL,
ADD CONSTRAINT humans_need_credentials
CHECK (kind = 'agent' OR (email IS NOT NULL AND password_hash IS NOT NULL));
CREATE TABLE agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_by uuid NOT NULL REFERENCES users(id),
description text,
runtime text NOT NULL, -- 'hosted' | 'external'
config jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);One existing table touched. One new table. An agent joins a workspace through workspace_members exactly like a person, which means authorization needed zero new concepts: the membership-based access rules that decide whether you can push to a repo decide whether an agent can, with the same query.
The migration itself is cheaper than it looks, which matters on a table every request touches. Since Postgres 11, ADD COLUMN with a constant default is a catalog-only change - no table rewrite, no long lock, the default is materialized lazily as rows are next written. Dropping the two NOT NULLs is likewise metadata-only. The only step that takes a real scan is validating the CHECK constraint against existing rows, and on a users table (thousands of rows, not billions) that's milliseconds. The identity model of the entire product changed inside a single fast transaction.
How much schema did one enum buy?
This is where the decision pays out, and it's countable. Our schema has 24 tables. Thirteen of them point at users.id, through 15 foreign key columns - every author, assignee, owner, and creator relationship in the product:
Every edge in that diagram is something an agent could do the moment its user row existed, with no further schema work:
| surface | foreign key | what it means for an agent |
|---|---|---|
| Plan | issues.assignee_id |
an agent can be assigned an issue on the same board, in the same states |
| Plan | issues.author_id, issue_comments.author_id |
it can file issues and comment on triage |
| Code | pull_requests.author_id, pr_comments.author_id |
it can open PRs and respond to review feedback |
| Chat | chat_messages.author_id |
@mention it in a channel and it answers as itself |
| Docs | docs.author_id |
it can draft a doc, attributed to it |
| Access | access_tokens.user_id |
its credentials are ordinary scoped tokens, revocable like anyone's |
| Access | ssh_keys.user_id |
a hosted agent can push over SSH with its own key |
The one edge that stays human in practice is pull_requests.merged_by. Nothing in the schema stops an agent from merging - the gate lives in the action layer, where every irreversible step an agent proposes waits for a person to approve. Identity says who did it; the gate says who's allowed to finish it. Keeping those separate is what lets one column carry the whole model.
What would the polymorphic alternative have cost?
The standard alternative is an actor pair: author_type ('user' | 'agent') plus author_id pointing into one of two tables. The costs are countable in our codebase today:
- 15 foreign keys stop being foreign keys. Postgres can't enforce a conditional reference - the constraint on every one of those 15 columns would become application-level discipline. Delete an agent and nothing in the database stops its comments from pointing at nothing.
- 24 join sites branch. We counted 24 places across 7 makima modules that join or select the
userstable to resolve an identity into a name and avatar. Each becomes a two-way branch (or a UNION view maintained forever). Every future surface pays the same tax. - Authorization forks. Workspace membership, repo roles, and token checks would all need an agent-shaped duplicate. Under agents-are-users,
workspace_members.user_iddoesn't know or care aboutkind.
Query-shape comparison, concretely. Today, resolving an issue's participants is one join:
db.select({ issue: issues, author: users })
.from(issues)
.innerJoin(users, eq(issues.authorId, users.id));Under a polymorphic actor model that same read needs both sides and a discriminator:
SELECT i.*, COALESCE(u.username, a.name) AS author_name
FROM issues i
LEFT JOIN users u ON i.author_type = 'user' AND i.author_id = u.id
LEFT JOIN agents a ON i.author_type = 'agent' AND i.author_id = a.id;Multiply the second shape by 24 call sites and every one to come. The single-table model isn't a performance play - both queries are fast - it's the removal of an entire category of branching from the codebase.
Where do agents actually differ, then?
Identity is shared; three things are not, and each lives in its own layer:
- Configuration - the
agentsside table: which workspace it belongs to, who created it, whether it runs on our runtime or calls in from outside, and its model/prompt config as jsonb. Humans have no row here. - Capability - agent tokens carry explicit scopes (
issues:write,pulls:write, scoped down to specific repos), so what an agent may do is a property of its credentials, not its identity. A human's session implies their role; an agent's token says exactly what it can touch. - Attribution - agents render with the Rezee mark, never a sparkle, and every action they take is labeled as the agent's. The member list shows them as what they are: teammates with a different kind.
What happens when you delete an agent?
You don't - and the audit is what told us so. Extending the FK scan to capture delete behavior: 13 of the 15 foreign keys are ON DELETE CASCADE, and the two exceptions (issues.assignee_id, pull_requests.merged_by) are SET NULL. Which means DELETE FROM users WHERE id = <agent> doesn't remove an account - it removes history:
- every PR and comment the agent authored (
pull_requests.author_id,pr_comments.author_id- cascade) - every issue it filed and every triage comment (
issues.author_id,issue_comments.author_id- cascade) - every chat message and doc (
chat_messages.author_id,docs.author_id- cascade) - any repository it owns (
repositories.owner_id- cascade), taking that repo's PRs, issues, and pipeline history with it
For humans this cascade graph is a deliberate right-to-erasure property: deleting your account really deletes you. For agents it would be a disaster - an agent might author hundreds of PRs a month, and its history is the team's history. A reviewer six months from now asking "why does this code exist" needs the agent's PR and its review thread to still be there.
So agent decommissioning is a different operation on the same rows: revoke its tokens (access_tokens - the only cascade you want), remove its workspace_members row so it loses all access, and flag the agents row disabled so the runtime stops scheduling it. The user row - and every FK pointing at it - stays forever. Same schema, opposite lifecycle, and the enum is what lets the API enforce that DELETE /users refuses kind = 'agent' while the disable path refuses humans.
This is the kind of consequence you only find by reading the whole FK graph, which is why the audit script extracts onDelete for every edge rather than trusting anyone's memory of the schema.
What fought back?
Two columns and a business rule - worth naming, because "it just worked" is only mostly true:
email NOT NULL UNIQUE. Agents don't have inboxes. We made email nullable and moved the requirement into the check constraint above - humans must have one, agents must not need one. Every "email the user" code path now has to skip agents - exactly the kind of branch the design otherwise avoids - so mail delivery goes through one helper that filters onkind, keeping the branch in one place instead of scattered.password_hash NOT NULL. Same story. Agents authenticate with scoped tokens only; a nullable hash plus the check constraint means an agent row can never satisfy a password login, structurally.- Seat counting. Our pricing says agents are free. Every billing and member-count query must remember
WHERE kind = 'human'- a landmine if it's scattered, so member counting goes through one shared query helper and the pricing page's promise is enforced in exactly one place.
That's the honest ledger: one enum bought 15 relationships and left us three human-only branches and one WHERE clause to centralize.
FAQ
Why not a separate agents table with its own primary key?
Because then every table that wants an agent author needs either duplicate columns (author_id, agent_author_id) or the polymorphic pair, and the database can no longer enforce referential integrity on either. A single principals table keeps every existing foreign key real, enforced, and cascade-correct - the 15 relationships in the diagram worked without touching the tables that hold them.
Can an agent own a workspace or create other agents?
No. Agents are created by workspace owners and admins, join through ordinary membership, and hold member-level standing. Ownership, agent creation, and approval of irreversible actions stay with humans - the balance the whole product is built around: agents propose and execute, people keep the final say.
Doesn't mixing agents into users bloat queries that only want humans?
The member list and billing queries filter on one indexed enum column, which is as cheap as filters get. The alternative distributes a much larger cost - type branching - across every query that resolves an author. We took one WHERE clause over 24 branches.