Data model
Fifteen tables. The shape that matters most is the polymorphic actor, which appears in five of them.
The polymorphic actor
Anywhere the application records who did this, the columns are a type and an id:
| Table | Columns |
|---|---|
issues |
assignee_type + assignee_id, creator_type + creator_id |
whiteboards / whiteboard_elements |
One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it |
time_entries |
actor_type + actor_id — an agent's hour is an hour |
comments |
author_type + author_id |
activities |
actor_type + actor_id (nullable — the system has no id) |
projects |
lead_type + lead_id |
tasks |
initiator_type + initiator_id |
A morph map keeps user and agent in the database rather than fully qualified class names, so
the columns stay readable and a namespace refactor does not rewrite data:
Relation::enforceMorphMap(['user' => User::class, 'agent' => Agent::class]);
This is the design the whole product rests on. It is why an agent can be assigned work, create issues, comment and appear in the audit log through exactly the same API as a person — and why there is no "run AI" button anywhere in the interface.
Tables
Identity and tenancy
| Table | Notable columns |
|---|---|
users |
is_admin, email_verified_at, seen_version, email_notifications, two_factor_*, identity_provider_id + external_subject (unique — matched on the provider's sub, never on email) — the first two guarded |
workspace_invitations |
email, role, token_hash, expires_at, accepted_at |
workspaces |
slug, issue_prefix, issue_counter, context, repositories |
workspace_members |
role, weekly_hours |
identity_providers |
issuer, client_id, client_secret (encrypted), domains, enforced, satisfies_two_factor — platform-wide, because an account belongs to a person rather than a workspace |
ip_allowlist_entries |
label, range, last_matched_at — platform-wide, not per workspace |
daemon_tokens |
token_hash, token_prefix, last_used_at, revoked_at |
Work
| Table | Notable columns |
|---|---|
projects |
status, priority, lead_*, target_date, enabled_issue_types |
issues |
cycle_id (nullOnDelete — deleting a window must not delete the work), carried_over (how many closed windows it has survived), source + external_key (unique per workspace — a second import updates), number, type, status, priority, parent_id, project_id, position, start_date, due_date, estimate_minutes, spent_minutes |
comments |
parent_id, source_task_id, body |
activities |
action, properties, task_id, issue_id |
whiteboards / whiteboard_elements |
One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it |
time_entries |
actor_*, minutes, spent_on, source, task_id |
issue_subscriber |
user_id, source — including opted_out |
inbox_items |
type, summary, actor_*, read_at, archived_at |
issue_dependencies |
predecessor_id, successor_id, type, lag_days |
imports |
source, filename, column_map, value_map, counts, warnings |
webhooks |
url, secret, events, consecutive_failures, disabled_at |
attachments |
attachable_type + attachable_id (morph, so nothing cascades — deletion is explicit in the model), path never derived from the uploaded name, checksum |
whiteboards / whiteboard_elements |
One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it |
time_entries |
minutes, spent_on, actor morph, source — the truth under issues.spent_minutes, which is a cached sum |
cycles |
number, starts_on, ends_on, closed_at — generated, never created by hand |
labels / issue_label |
name unique per workspace, color a token name rather than a hex |
artifacts |
type, state, url, url_hash, reference, title, task_id, issue_id |
Execution
| Table | Notable columns |
|---|---|
agents |
provider, runtime_id, instructions, custom_env, custom_args, mcp_config, max_concurrent_tasks, visibility, archived_at |
runtimes |
ulid, daemon_id, provider, last_seen_at, client_capabilities |
tasks |
ulid, status, session_id, work_dir, lease_expires_at, attempts, cancel_requested_at |
task_messages |
seq, type, tool, content, payload |
Knowledge
| Table | Notable columns |
|---|---|
skills |
slug, content |
skill_files |
path, content |
agent_skill |
enabled |
Automation and reporting
| Table | Notable columns |
|---|---|
autopilots |
trigger, cron_expression, timezone, next_run_at, webhook_token_hash, issue_title |
autopilot_runs |
trigger, status, reason, payload, issue_id, task_id |
saved_reports |
report, name, filters, visibility |
scenarios |
name, visibility, status, applied_at, applied_by |
scenario_proposals |
subject_*, field, value, baseline |
Decisions worth knowing
Issue numbers are reserved under a row lock. Workspace::reserveIssueNumber() locks the
workspace row inside a transaction, so two people creating issues at the same instant cannot claim
ACME-42 twice.
Positions are fractional. Dropping a card between two others sets its position to the midpoint, so a move never renumbers a column.
Public identifiers are ULIDs. runtimes and tasks carry a ulid beside their auto-increment
key. Daemon endpoints address the ULID, so internal ids never leave the server.
Deletions preserve work. Deleting a project nulls issues.project_id; deleting an epic nulls
its children's parent_id; removing a member deletes the membership, not the account. Agents are
archived rather than deleted, because their name appears on history that must keep making sense.
spent_minutes is a cache, not a source. The truth is one row per piece of
work in time_entries, each with an actor, a date and a source. A single
integer on the issue can be shown and cannot answer who or when, which is the
only thing a time report ever asks. The cache exists so an issue page costs one
query.
No column may be named after an Eloquent internal. PHP lets a sibling
subclass read another instance's protected members directly, so a column called
original, previous or changes is read out of the framework's dirty-tracking
arrays instead of the database — silently, and only from inside another model. A
test walks every model's table and fails on any of them.
Sequence numbers make the output stream idempotent. task_messages is unique on
(task_id, seq), so a daemon retrying after a network failure cannot double a line.
JSON columns have no database defaults. MySQL 8 refuses them. Defaults live in each model's
$attributes, which also means a freshly created model answers questions about itself correctly
before it has been read back — exactly when an observer asks.
Why artifacts are a table
They could have been a key inside tasks.result, which is already a JSON column and already
free-form. One requirement settles it: the return path looks an artifact up by URL. A webhook
saying "this pull request merged" arrives with nothing but a URL and a state, and has to find the
issue it belongs to. A JSON column cannot be indexed for that question.
url_hash exists because the column it summarises cannot carry an index either: MySQL caps an index
key at 3072 bytes, and a utf8mb4 varchar(2048) is 8192. The hash is of a normalised URL — scheme
and host lowercased, trailing slash removed — so the same pull request written two ways still
matches.
issue_id is denormalised from the task. An artifact is read from the issue far more often than from
the run that produced it, and the issue page should not walk every task to find its own links.