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 |
article_revisions |
What a page said before each edit. The text is a file beside the document, in {ulid}.history/ |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it, state deciding both who may read it and whether it is in the tree |
timers |
One running stopwatch per person, user_id unique. Server-side so it survives a closed tab |
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 (owner, admin, developer, member, management — a string, not an enum column, so a new role needs no migration), 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_*, enabled_issue_types. No target_date: a project is a system and does not finish — dates belong to epics |
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, nightwatch_issue_id (unique per workspace — their grouping, so a reopen finds the issue that exists rather than filing a second), closed_at (not fillable — maintained by the observer on every status change, because a column a form could write is one somebody can use to hide an issue from every board) |
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 |
article_revisions |
What a page said before each edit. The text is a file beside the document, in {ulid}.history/ |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it, state deciding both who may read it and whether it is in the tree |
timers |
One running stopwatch per person, user_id unique. Server-side so it survives a closed tab |
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 |
time_entries |
issue_id, meeting_id and absence — exactly one is set. Work has an issue or a meeting and no kind; absence has a kind and neither, so the two are mutually exclusive by construction rather than by a rule somebody has to remember |
milestones |
issue_id (an epic, checked at every door rather than in the schema — a foreign key cannot say "only when this row's type is Epic"), workspace_id (not redundant: every report scopes by workspace first), name, target_on, status (planned, at_risk, reached — missed is derived, never stored), reached_on, note, created_by |
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 |
article_revisions |
What a page said before each edit. The text is a file beside the document, in {ulid}.history/ |
articles |
Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it, state deciding both who may read it and whether it is in the tree |
timers |
One running stopwatch per person, user_id unique. Server-side so it survives a closed tab |
time_entries |
minutes, spent_on, actor morph, source — the truth under issues.spent_minutes, which is a cached sum. issue_id and meeting_id are both nullable and exactly one is set: an hour belongs to an issue or to a meeting |
cycle_days |
One reading per cycle per day. Never recalculated, so a burndown cannot be redrawn by a later estimate |
meeting_series |
The recurrence pattern, kept apart from the occurrences so a meeting is always one meeting |
meetings |
Agenda, notes and minutes per project. minutes_sent_at is both the record of circulation and the guard against a second one. all_day changes the wording, not the arithmetic — the length is still stored in duration_minutes |
meeting_attendees |
People, not actors — an agent does not attend a meeting |
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 (nullable), issue_id. claim_check holds where the run's summary and its diff disagreed; claim_checked_at exists so "checked, nothing to report" and "never checked" are distinguishable |
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 |
|---|---|
skill_proposals |
A correction that changed a run's outcome, offered as a skill. Unique on the comment, so a lesson is suggested once |
skills |
slug, content |
skill_files |
path, content |
agent_skill |
enabled. What an agent has today |
skill_task |
What a run was given, written when the work left for the runtime. Deliberately not derived from agent_skill: measuring whether a skill still earns its place means finding attachments that should change, and inferring past runs from present attachments answers using the thing under review |
silent_actions |
Interactions that changed nothing and said nothing, aggregated by component and method rather than logged per occurrence. workspace_id is nullable, because a dead button on the password gate is exactly the kind that strands somebody |
Integrations
| Table | Notable columns |
|---|---|
nightwatch_hooks |
One per workspace. token is in the URL, secret is encrypted and never sent. default_project_id is where an application Félagi has never seen puts its first problem |
nightwatch_applications |
One Nightwatch application, its project and the developer who picks its problems up. Learned on first sight, not configured in advance |
github_hooks |
token, secret, active, app_id, private_key, installation_id, last_received_at, last_event, last_error, last_reported_at, last_report_error |
subscribers |
email, name, token, unsubscribed_at |
subscriptions |
subscriber_id, subscribable_type, subscribable_id |
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_views |
surface, 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.