← All posts

Why Bugspot has a fixed schema (and won't ship custom fields)

Custom fields look like flexibility and feel like flexibility for the first six weeks. Then they become the thing your operations person quits over. A walk through why we drew the line where we drew it — and what it cost us.

cover · hero

1. The story we keep hearing

Every team I talk to about Bugspot has roughly the same story. They started on something thin — Trello, GitHub Issues, a Notion database somebody spun up on a Friday — and it worked great until it didn't. Then somebody migrated them to Jira, or they migrated themselves to Linear, and the pain rotated rather than disappeared.

The Jira teams complain about the admin screen: who can move what, into which state, under which conditions, with which custom field requirements. The Linear teams complain about the opposite: a beautiful tool that has opinions about how software ships, some of which are not theirs. Both complaints are about the same axis. One tool let process eat schema; the other fixed both at the same time.

!
The thesis of this post. Schema and process are different things, and a tracker that respects the difference is calmer than one that doesn't. Bugspot fixes the schema and lets you configure the process. We think that's the right line; this post is why.

2. What 'fixed schema' really means

When we say "fixed schema" we mean it precisely. Every Entry in Bugspot — bug, task, story, epic, milestone — has the same set of top-level fields, and that set is closed. You don't add to it. We don't add to it. The shape of an Entry is part of the contract.

The shape:

  • title — a short string, never empty
  • description — markdown, optional
  • type — one of bug, task, story, epic, milestone, initiative
  • status — workspace-defined; pluggable per-workspace
  • priority, severity — closed enums, mapped per-workspace label
  • assignee, reporter, labels, links, comments

That's it. There is no due_date_2_for_compliance. There is no "Customer Impact (Q1 framework)" picklist. If you need to track that, the answer is a label, a link, or a different tool. We are honest about which one.

What you get for free

Because the schema is fixed, the rest of the product can lean on it. Search, views, the API, exports, integrations — none of them have to ask the per-tenant schema service "what does an Entry look like in this workspace today?" They know. That changes more about the product than I expected when we drew the line.

3. The line between schema and process

Process, by contrast, is wide open. You define your statuses, your transitions, your role-based permissions, your QA gates, your release rules. The structure of an Entry doesn't change, but the way an Entry moves through your team is entirely yours.

This is the part that I've found people miss when they hear "fixed schema": they assume we mean a fixed workflow. We don't. Workflow is the part that actually differs between teams. Fixing it would be the wrong move.

The schema is a contract. The process is a configuration. Trackers that confuse the two end up either too rigid (Linear, on process) or too loose (Jira, on schema). The correct answer is to be strict about one and generous about the other.

4. A workflow, drawn

Here's the default workflow we ship with the SaaS template. Every node is a status; every edge is a transition with optional permission rules. You can edit it freely — but the schema underneath stays exactly the same.

start work request review approve needs changes reopened triaged status: open in_progress status: open in_review status: open shipped status: closed reopened status: open
fig. 1 — default workflow on the SaaS template. Five statuses, five transitions, one reopen edge. Editable. Schema unchanged.

Notice what isn't in the diagram. There's no implicit field that gets set when you cross an edge. No "QA-Approved-By" field that mysteriously appears in the in_review column. The transition is the event; the schema is the noun. The tracker that mixes them ends up with a hundred shadow fields.

5. How it's enforced

The contract is enforced at three layers: the database, the API, and the client. The database is the load-bearing one. Here's a stripped-down version of the entries table — actual migration, mostly:

sql db/migrate/20251114_create_entries.sql
-- Entries are the universal noun in Bugspot.
-- Every list, board, search, and saved filter is over this table.
CREATE TYPE entry_type AS ENUM (
  'bug', 'task', 'story', 'epic', 'milestone', 'initiative'
);

CREATE TABLE entries (
  id           bigserial     PRIMARY KEY,
  workspace_id bigint        NOT NULL REFERENCES workspaces(id),
  type         entry_type    NOT NULL,
  title        text          NOT NULL CHECK (length(title) <= 200),
  description  text,
  status_id    bigint        NOT NULL REFERENCES statuses(id),
  priority     smallint      NOT NULL CHECK (priority BETWEEN 0 AND 4),
  assignee_id  bigint        REFERENCES users(id),
  created_at   timestamptz   NOT NULL DEFAULT now()
);

-- No JSONB "custom_fields" column. On purpose.

That's the whole story. There's no custom_fields jsonb sitting quietly at the bottom waiting for someone to plumb it through. The thing that doesn't exist in the migration won't exist in the API, the search index, the export, or the third-party integration that rebuilds the schema for you. We removed a lot of code by not writing it.

On the API side, the contract is just as plain. Here's how a client creates an entry — and what the response looks like:

ts src/api/entries.ts
import { z } from "zod";

export const EntryInput = z.object({
  type:        z.enum(["bug", "task", "story", "epic", "milestone", "initiative"]),
  title:       z.string().min(1).max(200),
  description: z.string().optional(),
  priority:    z.number().int().min(0).max(4),
  labels:      z.array(z.string()).max(12).default([]),
});

export async function createEntry(input: unknown) {
  const parsed = EntryInput.parse(input);
  return db.entries.insert(parsed);
}

The schema lives in three places: the migration, the validator, and the type. Three sources of truth, all aligned, none of which has a "..." escape hatch. That's the property worth keeping.

What a request looks like over the wire

http POST /v1/entries
POST /v1/entries HTTP/1.1
Authorization: Bearer bgs_live_e8c3...
Content-Type: application/json

{
  "type": "bug",
  "title": "PDF export drops the cover page on Safari 17",
  "priority": 2,
  "labels": ["export", "safari"]
}

6. What we gave up

This is the honest part. Fixing the schema closes some doors. We do not get to sell to teams who want their tracker to also be their CRM, their compliance log, their content calendar, or their non-software intake form. Every quarter we lose a deal because somebody wants a "Region (EMEA / NA / APAC)" picklist and we won't add it.

What we lose
The "we'll model anything" market

Agencies, ops teams, marketing, anyone who needs the tracker to also be a generic database. Jira and ClickUp keep that crowd; we don't try.

What we gain
An interface that fits in your head

The product is small enough that a new engineer can be productive in an afternoon, and the schema is small enough that you can read it on one screen and trust it.

That's the trade. We are choosing to be a sharper tool that fits a smaller number of teams, rather than a generic one that fits everyone slightly badly.

7. Common pushback

"What if I need just one extra field?"

Use a label. Labels are first-class — searchable, filterable, color-coded — and they cover 90% of the cases that look like "we need a custom field." If the answer is "I need it to be a date" or "I need it to be a number," that's usually a sign the thing belongs in a different system.

"What about per-tenant required fields?"

Required transitions, not required fields. You can configure a transition (say, in_review → shipped) to require a linked PR, an approver from a specific role, or a non-empty release note. The constraint sits on the edge in the workflow, not on the entry, and it doesn't multiply your schema.

"Will you ever change your mind?"

On this, no. We've turned down two acquisition conversations and one large customer over it. The product's identity is the line we drew. If we ever feel the urge to ship custom fields, we'll fork the product and call the new one something else.


If this position resonates, you're probably the right shape of team for Bugspot. See pricing → or read the positioning. If it doesn't — honestly, thank you for reading this far. The right tool is the one your team will still respect a year from now.

#schema #process #positioning #postgres #api-design
V
Vladimir Petrov

Solo developer behind Bugspot. Previously eight years on developer tools at companies you've heard of. Writes here about the parts of building a bootstrapped product that don't get a hashtag.