Skip to content

The data model

The Ushabti data modelUsers join projects through project_members and may be humans or agents. A project holds properties, views and tasks. A property holds options. Every task value is one row in task_values, keyed by task and property. A task also holds checklist items, comments, activity and agent runs, and a run holds its plan and its log. The dashed lines are the two halves of a value: every row in task_values names a task and a property, and a select value holds the id of one of that property's options.usershuman or agentproject_membersowner or memberprojectsone boardagent_tokenssha-256, one projectsessions30 dayspropertiesname + typeviewsname + groupingtaskstitle + rankproperty_optionsname + colourtask_valuestask × property → JSONchecklist_itemstext + donecommentsauthor → usersactivityactor → usersagent_runsone open per taskagent_run_stepsthe planagent_run_logappend only
Two boxes carry the whole idea. users holds people and agents alike, which is why an assignee, a comment author and an activity actor all work for a machine.task_values is one row per task and property — so nothing on a task is a column, and nothing is hardcoded.

One row per task and property, with the value shape decided by the property’s type:

Property type What the row holds
select an option id
iteration an option id — a sprint, a select whose options always carry dates
multi_select a list of option ids
person a user id — of a human or an agent
text a string, truncated at 2000
number a number
date "YYYY-MM-DD"
checkbox true or false
link a list of http / https URLs, [] when empty

Because there is no status column anywhere, there is nothing to work around when a team’s status is not a status. Adding a whole new property type is one branch in the value rules and one control in the task panel.

A property with no value written has no row, which is why a task’s values object in the API is sparse and why “missing” and “null” mean the same thing.

kind is "human" or "agent". An agent has no email and no password, and joins one project through project_members exactly like a person.

Nothing else in the schema knows agents exist. Look at what that buys:

  • Assignee is a person property; its value is a user id; an agent has one.
  • A comment has an author_id; an agent writes comments.
  • Activity has an actor_id; an agent appears in the log.
  • A run has an agent_id, which is the only table that cares.

Resist a parallel identity table. The moment agents get their own table, every one of those four needs a second code path and a nullable column beside the first.

Table Holds
projects name, key prefix, owner, the counter that gives each task its number, and the card_view every card is drawn from
project_members who is in a project, and their role: owner, admin or member
sessions a random id and an expiry. A person’s sign-in
agent_tokens a SHA-256 digest, a visible 12-character prefix, one project, and revoked_at
properties name, type, rank, and a small config. Where it sits on a card is the project’s card_view, not this
property_options name, colour, rank. Select types only
views name, kind (board, list or roadmap), grouping property, rank, and the filter rules and sort in config
tasks title, description, number, rank, and archived_at. One order per project
checklist_items text, done, rank
comments body and author. Never edited
activity one row per thing that happened, with a free-form data object
agent_runs goal, step, status, control, and the clocks
agent_run_steps the plan, each step todo, active or done
agent_run_log append-only lines. The panel shows the last 40
Column Answers Written by
updated_at when did the agent last say something? a report — never a beat
beat_at when did we last know the process is alive? a report and a beat

The lease that closes a silent run counts updated_at. Let a beat write it and a heartbeat left behind by a killed session holds a card open all day, which is the exact fault the lease exists to fix.

A third column, report_due_at, is null on almost every run. A report may name how long the next word takes — reportFor, at most an hour — and then the lease reads that moment instead. Only a report writes it, and the next report sets it back to null, so a long build is covered once and a timer still cannot hold a card open.

Deleting a project takes everything with it. Deleting a task takes its values, checklist, comments and runs. Deleting a property takes its options and every value written for it — which is why the confirmation counts them out loud.

Deleting a user takes their tokens and runs, but their comments and activity survive with a null author, so the record of what happened does not develop holes.