Skip to content

Column-by-Column Schema Reference

Every column for every table, derived from the migration files (migrations/*.js) and cross-checked against the DAL reader functions in lib/dal.postgres.js. The migration files are the schema-of-record; this doc is the working reference — keep both in sync per the Schema-change protocol in CLAUDE.md.

When writing SQL in specs or tests, check here first. The three most common hallucination traps:

  • users has status ('active' / 'inactive'), not is_active
  • company_calendar has no status column — only id, date, name, type
  • time_entries date column is date, not entry_date

All tables include tenant_id uuid NOT NULL as part of their composite primary key (tenant_id, id). It is omitted from the per-column tables below to reduce noise; assume it is always present and always the first primary key component.


time_entries

ColumnTypeDefaultNullableNotes
idintegerNOPK component (with tenant_id)
datedateNOEntry date. Column is named date, not entry_date.
user_idintegerNOusers.id
project_idintegerNOprojects.id
task_idintegerYEStasks.id. Nullable — some overhead entries have no task.
hoursnumeric(5,2)NO
notestextYES
budget_idintegerYESbudgets.id. Nullable, no backfill — names the specific envelope this entry belongs to (issue #2044). Null for every entry that predates this column and for every internal-project entry; no CHECK.
billable_hoursnumeric(5,2)YESNullable, no backfill, no CHECK — a PM's approved billable figure for this entry, part of the T&M-only time-approval arc (issue #2179). Null means "bill every recorded hour" (today's behavior, every pre-existing entry). 0 is a distinct, legitimate value ("bill none of this") — never conflated with null. This column is never modified by anything that changes hours itself; recorded time stays immutable.

The person column from the legacy Sheets schema was not migrated to Postgres. The DAL reader synthesises person: '' on every row for Sheets-compatibility — it is not a real column.


projects

ColumnTypeDefaultNullableNotes
idintegerNOPK component
nametextNO
client_idintegerYESclients.id
typetextNO'Retainer' | 'retainer_period' | 'agile' | 'sprint' | 'T&M' | 'Fixed Price' | 'Pro Bono' | 'Internal'
is_billablebooleanfalseNO
contract_valuenumeric(12,2)YES
hours_budgetnumeric(8,2)YESEmpty on Retainer/Agile parents — lives on children.
billing_ratenumeric(10,2)YES
alert_thresholdinteger80NOPercentage.
statustext'active'NO'active' | 'completed' | 'cancelled'
pm_user_idintegerYESusers.id
project_categorytext'client'NO'client' | 'internal' | 'pto' | 'sick' | 'leave'
notestextYES
deadlinedateYES
parent_project_idintegerYESprojects.id. Set on period/sprint children; null on parents.
period_numberintegerYESNull on parents.
period_statustextYES'planned' | 'active'. Null on parents.
period_startdateYESNull on parents.
period_enddateYESNull on parents.
renewal_cadencetextYES'monthly' | 'quarterly' | 'biannual' | 'annual'. Null on non-retainer parents.
start_datedateCURRENT_DATENOAdded in migration 0020. Backfilled from first time entry date on existing rows.
billing_schedulejsonbYESOptional plan for how a Fixed Price project's contract value is billed across the months of its life — sparse array of { month, amount } (month is YYYY-MM, amount a positive dollar figure). Null means unscheduled. Added in migration 0045. Describes billing only, never revenue recognition.

users

ColumnTypeDefaultNullableNotes
idintegerNOPK component
slack_user_idtextYESNullable since migration 0018 — contractors/vendors may have no Slack presence.
nametextNO
roletext'user'NO'owner' | 'manager' | 'user' | 'admin' (migration 0023). admin has owner-level access but is excluded from team-delivery statistics (see lib/roles.js); it is never normalized to 'owner'. Legacy Sheets value 'pm' is normalized to 'manager' by the DAL reader.
statustext'active'NO'active' | 'inactive'. Not a boolean — never use is_active.
discipline_idintegerYESdisciplines.id
employment_typetext'fte'NO'fte' | 'contractor' | 'vendor'
hours_per_weeknumeric(5,2)40NOFTE defaults 40. Contractors require explicit value.
emailtextYESEmail address for OAuth console login (migration 0024). Lowercase-stored; used for cross-tenant email lookup.
levelintegerYESCompensation level 1–4, CHECK (level IS NULL OR level BETWEEN 1 AND 4). Added in migration 0034. Unlevelled people simply are not benchmarked.

clients

ColumnTypeDefaultNullableNotes
idintegerNOPK component
nametextNO
statustext'active'NO'active' | 'inactive'
notestextYES

tasks

ColumnTypeDefaultNullableNotes
idintegerNOPK component
nametextNO
is_billablebooleanfalseNO
statustext'active'NO'active' | 'archived'. Added in migration 0019.
discipline_idintegerYESWhich discipline this task's work belongs to (many tasks → one discipline; id → disciplines.id, soft reference). Added in migration 0033; backfilled from the discipline whose default_task_id pointed at this task, so day-one values are byte-identical to the old inversion.
notestextYESFree-text elaboration on the task, separate from its short name — surfaced on hover (_taskNameWithNotes) rather than as its own column. Added in migration 0044 (issue #2150), no backfill. Console-only, owner-only, audit-silent — set via set_task_notes, deliberately absent from MCP_TOOLS. Max 200 characters, validated at the endpoint.

project_tasks

ColumnTypeDefaultNullableNotes
project_idintegerNOPK component. → projects.id
task_idintegerNOPK component. → tasks.id
budget_hoursnumeric(8,2)YES
alert_thresholdinteger80NOPercentage.

This table has no id column. PK is (tenant_id, project_id, task_id). Presence of a row means the task is enabled for that project; there is no enabled flag.


budgets

ColumnTypeDefaultNullableNotes
idintegerNOPK component
user_idintegerNOusers.id
project_idintegerNOprojects.id
allocated_hoursnumeric(8,2)NO
statustext'active'NO'active' | 'cancelled' | 'transferred'
created_byintegerYESusers.id. Nullable — older envelopes predate this column.
created_attimestamptznow()NO
task_idintegerYEStasks.id. Optional work-category tag on the envelope.
start_datedateYESOptional envelope window start. Null means project-lifetime (no window). Added in migration 0036.
end_datedateYESOptional envelope window end. Null means project-lifetime (no window). Added in migration 0036.
week_hoursjsonbYESOptional shape of the envelope's hours across the weeks of its window: a sparse array of { week, hours }, week always a Monday (YYYY-MM-DD). Null means unshaped. Requires a window — cannot be set without start_date/end_date. Added in migration 0037.
labeltextYESOptional free-text wayfinder distinguishing several envelopes on the same (user, project, task) — e.g. two "Photography" envelopes for one person, one per shoot location. Max 40 characters, validated in the endpoint (not the database). Console-only — absent from MCP_TOOLS, both for setting and display. Added in migration 0041.

transferred_from_allocation_id was dropped in migration 0021 — it was never populated by any tool. Do not reference it in SQL. start_date/end_date are a bound, not a schedule — storage only as of migration 0036; no proration, capacity, or availability calculation reads them yet. week_hours is a forecast the PM owns, never an instruction handed to the person whose hours they are — storage only as of migration 0037; no calculation, alert, chip, or signal anywhere derives from a deviation between this shape and logged time. See CLAUDE.md "week_hours". label is a name, not an attribution key — it does not make logged time attributable per envelope (time entries still carry only (user_id, project_id, task_id)); several same-(user, project, task) envelopes still merge into one usage bucket, and the label is carried per constituent envelope on the merged bucket, not promoted to a bucket-level field. Never rendered in the Slack morning briefing.


disciplines

ColumnTypeDefaultNullableNotes
idintegerNOPK component
nametextNO
statustext'active'NO'active' | 'inactive'
default_task_idintegerYESOptional default task for this discipline (id → 05. Tasks); display/convenience only
soc_codetextYESO*NET-SOC occupation code (e.g. 15-1252.00), explicitly chosen by an owner from the taxonomy committed at data/onet-soc-occupations.json; soft reference, never inferred. Added in migration 0035.
colortextYESOwner-chosen identity color token name (e.g. accent-azure), never a raw CSS value — validated against the DISCIPLINE_PALETTE allowlist in lib/discipline-palette.js on write and again at render time. Drives the small dot rendered beside the discipline's name across the console. Added in migration 0040.

discipline_levels

ColumnTypeDefaultNullableNotes
idintegerNOPK component
discipline_idintegerNOdisciplines.id, soft reference (no FK) — archiving a discipline must never be blocked by a band pointing at it.
levelintegerNOCHECK (level BETWEEN 1 AND 4).
salary_minnumeric(12,2)YESEmpty means "not set".
salary_maxnumeric(12,2)YESEmpty means "not set".

One row = one (discipline, level) salary band, UNIQUE (tenant_id, discipline_id, level). Added in migration 0034 (issue #1675). Owner-only, console-only — the endpoints that read/write this table are deliberately absent from MCP_TOOLS (Slack-unreachable) and never post to the audit channel. Compensation is always derived from rate_history.hourly_cost; this table only records the bands it's compared against.


conversations

ColumnTypeDefaultNullableNotes
row_idbigserialNOSurrogate PK (single column). Not the conversation ID.
idtextNOConversation ID — not unique; multiple rows share the same id (one per exchange).
timestamptimestamptzNO
user_idtextNOSlack user ID (text). No FK to users.
user_nametextYES
messagetextNO
responsetextYES
summarytextYES
entitiesjsonb'[]'NOTyped entity references from this turn's tool calls. Added in migration 0022. Shape: array of entity objects consumed by the Phase 1 entity-resolver.

conversations uses row_id bigserial as its primary key, not (tenant_id, id) like other tables. tenant_id is still present and indexed for isolation.


pending_pto

ColumnTypeDefaultNullableNotes
idintegerNOPK component
user_idintegerNOusers.id
datedateNO
hoursnumeric(5,2)NO
statustext'pending'NO'pending' | 'approved' | 'rejected'
created_attimestamptznow()NO
approved_bytextYESSlack user ID of the approving manager (text, not internal user id).
resolved_attimestamptzYESSet when status moves to approved or rejected.
notestextYES

unresolved_items

ColumnTypeDefaultNullableNotes
idintegerNOPK component
typetextNOFreeform category string (e.g. 'time_entry').
descriptiontextNO
affected_project_idintegerYESprojects.id. May be null if item is user-scoped only.
affected_user_idintegerYESusers.id. May be null if item is project-scoped only.
created_attimestamptznow()NO
statustext'open'NO'open' | 'resolved' | 'dismissed'
resolved_attimestamptzYES
resolved_bytextYESSlack user ID of whoever resolved/dismissed the item (text).

company_calendar

ColumnTypeDefaultNullableNotes
idintegerNOPK component
datedateNO
nametextNOHoliday or event name.
typetext'holiday'NOCurrently always 'holiday'. CHECK kept loose to allow future values without a migration.

There is no status column on company_calendar. The full column list is: tenant_id, id, date, name, type.


rate_history

ColumnTypeDefaultNullableNotes
idintegerNOPK component
user_idintegerNOusers.id
hourly_costnumeric(10,2)NO
effective_fromdateNOEffective-date versioning. The rate in effect for a given entry date = MAX(effective_from) WHERE effective_from <= entry_date.

Append-only table — no UPDATE or DELETE in the DAL. New rates are always new rows.


liabilities

ColumnTypeDefaultNullableNotes
idintegerNOPK component
project_idintegerNOprojects.id
vendor_user_idintegerNOusers.id. Must reference a user with employment_type = 'vendor' (enforced at application layer, not schema).
descriptiontextNO
agreed_amountnumeric(12,2)NOFixed payment commitment. Accrues pro-rata to deadline.
statustext'active'NO'active' | 'cancelled'
created_bytextNOSlack user ID of the creating owner (text, not internal user id).
created_attimestamptznow()NO
agreed_hoursnumeric(10,2)0NOContracted effort in hours. Added in migration 0017. Accrues linearly from created_at to project deadline as implied hours in burn-down calculations. Rows predating the column have agreed_hours = 0 (inert).

proposal_roles

Sales-side staffing lines on a proposal (console-only, owner/admin, unreachable from Slack — see CLAUDE.md "Proposals Phase 1"). No operational query reads this table.

ColumnTypeDefaultNullableNotes
idintegerNOPK component (app-assigned, MAX(id)+1)
proposal_idintegerNOproposals.id, ON DELETE CASCADE
discipline_idintegerYESSoft reference (no FK) → a discipline. One of discipline_id/user_id costs the line; a specific person overrides the discipline for costing.
user_idintegerYESSoft reference (no FK) → users.id.
planned_hoursnumeric(10,2)0NO
billing_ratenumeric(10,2)YEST&M proposals only.
tasktextYESFree-text label, not a tasks.id reference.
created_attimestamptznow()NO
start_datedateYESOptional window start, mirroring budgets.start_date. Null means unwindowed (today's behavior). Added in migration 0038.
end_datedateYESOptional window end, mirroring budgets.end_date. Null means unwindowed. Added in migration 0038.
week_hoursjsonbYESOptional shape of this line's planned hours across the weeks of its window: a sparse array of { week, hours }, week always a Monday (YYYY-MM-DD). Null means unshaped. Requires a window — cannot be set without start_date/end_date. Capped at planned_hours. Added in migration 0038.

start_date/end_date/week_hours share validation (lib/week-shape.js) with an allocation envelope's identical fields on budgets — storage only as of migration 0038; lib/proposal-capacity.js does not read them yet. The shape is a forecast, never an instruction — see CLAUDE.md "week_hours".


user_attribute_history

Audit trail for users.level and users.discipline_id changes (console-only, owner/admin, unreachable from Slack — absent from MCP_TOOLS). Audit-silent — never posts to the audit channel (same reasoning as set_rate's rule-21 suppression). Forward-only: nothing was backfilled, history starts at migration 0039. No operational query reads this table.

ColumnTypeDefaultNullableNotes
idintegerNOPK component (app-assigned, MAX(id)+1)
user_idintegerNOSoft reference (no FK) → users.id, same precedent as discipline_levels.discipline_id.
fieldtextNO'level' | 'discipline_id'. No CHECK constraint — validated at the endpoint.
old_valuetextYESThe value before the change, stringified. discipline_id is stored as the id, never the name — names are resolved at render time so a later discipline rename doesn't rewrite history. Null means "was unset".
new_valuetextYESThe value after the change, stringified. Null means "cleared".
changed_byintegerYESSoft reference (no FK) → users.id of the acting owner/admin. Null when unresolvable.
changed_attimestamptznow()NO

One row per field actually changed — a save that resubmits the same level/discipline writes zero rows. Written from update_user_role (mcp-server.js) as a best-effort side effect: a write failure is logged and swallowed, never failing the user update itself (same pattern as checkExceptionQueue's post-write hook on log_time). Displayed as the "Other changes" section of the rate-history accordion on the console Rates tab.


message_log

Scheduled-DM delivery log — one row per sendDM attempt (morning summary, evening reminder, renewal alert), success or failure (console-only, owner/admin, unreachable from Slack — absent from MCP_TOOLS). No operational query reads this table.

ColumnTypeDefaultNullableNotes
idintegerNOPK component (app-assigned, MAX(id)+1)
user_idintegerYESSoft reference (no FK) → users.id, resolved via a subquery against slack_user_id at write time. Null when the Slack ID doesn't match any current user row.
slack_user_idtextNOThe Slack ID the DM was addressed to.
kindtextNO'morning' | 'evening' | 'renewals'. No CHECK constraint — validated at the call site.
bodytextNOThe exact message text sent (or attempted).
statustextNO'sent' | 'failed'. No CHECK constraint — validated at the call site.
errortextYESThe Slack error string when status = 'failed'. Null on a successful send.
sent_attimestamptznow()NO

Recorded from the single choke point, sendDM (reminders.js) — never at its three callers. The write is best-effort and wrapped so it can never affect delivery: a logging failure is logged and swallowed, never breaking the DM itself (same pattern as checkExceptionQueue's post-write hook on log_time). Pruned to the last 90 days once per morning run (not on every write) via pruneMessageLog. Owner/admin-only (isOwnerLevel) — a manager sees percentages and their own projects, and these messages carry a person's own envelope figures and logged hours. Displayed at /console/messages.


time_approvals

T&M time approval — one row per (project_id, period), a scoped amendment to business rule 28 (issue #2179; see CLAUDE.md). Console-only, owner/admin or the project's PM-of-record — absent from MCP_TOOLS, audit-silent (rule 27 — an approval is neither a create nor a retroactive change to recorded time). No operational query reads this table.

ColumnTypeDefaultNullableNotes
idintegerNOPK component (app-assigned, MAX(id)+1)
project_idintegerNOprojects.id. Must be a T&M project — enforced at the endpoint, not a CHECK.
periodtextNOYYYY-MM. Unique together with project_id (and tenant_id) — re-approving a period updates this row in place rather than creating a second one.
approved_byintegerYESusers.id — the owner/admin or PM who approved.
approved_attimestamptzYESSet on every approve/re-approve.
total_hoursnumeric(7,2)YESRecorded hours in the period at the moment of approval (a snapshot, not a live query).
billable_hoursnumeric(7,2)YESApproved billable total for the period — the sum of each entry's time_entries.billable_hours where set, else its full recorded hours.

UNIQUE (tenant_id, project_id, period). Written by upsertTimeApproval — looks up the existing row for (project_id, period) and updates it in place, or computes MAX(id)+1 and inserts. time_entries.hours is never modified by this arc; time_entries.billable_hours (see above) is the separate, per-entry judgment this table's totals are computed from.