@specfork/projects
Project management with work items, workflow states, labels, cycles, modules, saved views, comments, activity history, notifications and webhooks.
Projects
A team plans and tracks work in projects. Each project has work items with a key (WEB-42),
a workflow of states, labels, priorities, assignees and estimates. Items are planned into
time-boxed cycles (sprints) and feature-sized modules, found again through filters and saved
views, discussed in comments, and every change lands in an activity history that drives
notifications and webhooks.
Behaviour derived from Plane's open-source code (AGPL-3.0), written fresh and stack-free.
Features Plane offers only in paid plans (work item types, custom properties, time tracking,
epics) are not described here.
Not affiliated with or endorsed by Plane Software, Inc. The name is used only to credit the source.
| Area | Prefix | Covers |
|---|---|---|
| projects.md | PROJ | Project fields, identifier, visibility, membership, features, archive, delete |
| work-items.md | ITEM | Item fields, keys, priority, assignees, dates, position, archive, auto-archive and auto-close, bulk actions |
| properties.md | PROP | Workflow states and groups, labels, estimates |
| hierarchy.md | REL | Parent and sub-items, relations, links, attachments |
| cycles.md | CYC | Cycles: dates, status, items, transfer, progress, burndown, archive |
| modules.md | MOD | Modules: status, lead, members, items, links, progress, archive |
| query.md | QRY | Filter language, sorting, grouping, pagination, search |
| views.md | VIEW | Layouts, saved views, built-in views, remembered display settings, favorites |
| comments.md | CMT | Comments, reactions, mentions, subscriptions |
| activity.md | ACT | Change history, description versions |
| notifications.md | NOTE | Notification types, recipients and defaults on @specfork/notifications |
| permissions.md | PERM | Team roles (with viewer) and project membership applied to every resource |
| events.md | EVN | Webhook events and payloads |
A workspace is a team (@specfork/teams); all data belongs to exactly one team.
All times are stored in UTC; API names are snake_case. Deletes are soft: a deleted record is
hidden at once and destroyed 60 days later, unless a row says otherwise.
Activity
Status: Draft · Prefix: ACT
Purpose
An append-only history of every change to a work item, merged with its comments.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| ACT-1 | Entry: {item, actor? (null = system), verb: created|updated|deleted, field?, old_value?, new_value?, old_ref?, new_ref?, created_at}. Values are display text (state name, item key, user display name, date YYYY-MM-DD); *_ref hold ids. Entries are never edited by users. |
rename → updated name old→new |
| ACT-2 | Fields recorded: name, description, state, priority, assignees, labels, start_date, target_date, estimate, parent, cycle, modules, link, attachment, relation (with the type), archived, comment. Writes that change nothing write nothing; failed writes write nothing. |
PATCH same priority → no entry |
| ACT-3 | Set fields (assignees, labels, modules) write one entry per element added (new_value) or removed (old_value). |
+2 labels → 2 entries |
| ACT-4 | Item created: one created entry with the creator and creation time, then entries for initial assignees. Item deleted: one deleted entry. Comment added, edited or deleted: comment entries with created, updated, deleted. |
create with 1 assignee → 2 entries |
| ACT-5 | Cycle: add → created, move → updated old→new cycle, remove → deleted. Relation: one entry on each item, each with the type seen from that side. Archive: new_value manual, automatic or restored; system changes have actor null and automatic: true. |
A blocks B → A blocking, B blocked_by |
| ACT-6 | Consecutive description edits by the same actor within 10 minutes update the latest description entry (new new_value, new time) instead of adding one. |
3 quick edits → 1 entry |
| ACT-7 | GET items/{id}/history?kind=all|changes|comments&since= returns entries and non-deleted comments merged, oldest first; comments carry their reactions. Reactions and subscriptions are not entries. |
comment + rename → both, by time |
| ACT-8 | Description versions: each description change stores {body, saved_by, saved_at}; saves by the same user within 10 minutes replace the latest version; the newest 20 per item are kept. Any reader lists versions; a member restores one, which is a new change. |
25 edits an hour apart → 20 kept |
| ACT-9 | History stays readable after a cycle, module, label or user it names is deleted: the stored display text is shown. | delete label → entry still names it |
Out of scope
- Field-level history for cycles, modules and projects; audit logs of team settings.
Decisions
- Removing an estimate is recorded; the source failed while logging it.
- Description edits merge only within 10 minutes; the source merged any run of edits by the same actor.
- Relation entries store the related item in
new_ref; the source used the wrong reference slot.
Comments
Status: Draft · Prefix: CMT
Purpose
Discussion on work items: comments, emoji reactions, @-mentions, and who follows an item.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| CMT-1 | Comment: {item, author? (null = system), body (rich text, sanitised, 1–50 000 chars of text), created_at, edited_at?}. Empty or invalid markup → 400. |
empty body → refused |
| CMT-2 | Any project member comments; only the author edits. edited_at is set only when the body text changes. |
author edits → edited_at set; other member → 403 |
| CMT-3 | Delete: author or project manager; soft; the comment leaves the history (ACT-7) and its reactions go with it. | manager deletes → hidden |
| CMT-4 | Reaction: {target: item|comment, user, emoji}; emoji is one Unicode emoji (≤ 32 bytes); (target, user, emoji) unique (already_reacted). Users remove only their own. Responses group reactions by emoji with counts and whether I reacted. |
same emoji twice → already_reacted |
| CMT-5 | Mention: a rich-text node naming a user id in an item description or comment. Only project members are recognised; others render as plain text and notify no one. | mention non-member → no notification |
| CMT-6 | Item mentions are recomputed only when the description changes: newly mentioned members are recorded and notified, removed ones dropped. Other edits keep them. | edit priority → mentions unchanged |
| CMT-7 | Subscriber: (item, user) unique, user must be a project member. POST/DELETE items/{id}/subscription for myself; GET answers {subscribed}. Project members may add or remove other members (POST/DELETE items/{id}/subscribers/{user_id}); GET items/{id}/subscribers lists subscribers. |
subscribe twice → already_subscribed |
| CMT-8 | Auto-subscribe: the creator, anyone who changes or comments on the item, newly added assignees, and newly mentioned members. System changes and deletes subscribe no one. Unsubscribing sticks until the user acts on the item again. | comment → I'm subscribed |
Out of scope
- Threaded replies, comment visibility levels for external viewers, votes.
Decisions
- Only authors edit comments through every API; the source's token API let any project member edit or delete any comment.
- Mentions survive edits that don't touch the description; the source dropped them.
- The subscriber list returns subscribers; the source returned all project members.
Cycles
Status: Draft · Prefix: CYC
Purpose
Time-boxed iterations (sprints): which items a team commits to between two dates, and how much got done.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| CYC-1 | Cycle: {project, name (1–255, not unique), description?, start_date?, end_date?, owner → member (default creator), position, archived_at?, snapshot?, created_by}. Dates are calendar dates. A new cycle goes to the top of the manual order. |
create → owner = me |
| CYC-2 | start_date and end_date are both set or both null, on create and update (dates_incomplete); start_date ≤ end_date (invalid_dates). A cycle runs from 00:00 on its start date to 23:59:59 on its end date in the project time zone. |
PATCH end_date null only → dates_incomplete |
| CYC-3 | Dated cycles of one project never overlap, both ends inclusive, archived cycles included (cycle_overlap). POST cycles/check_dates {start_date, end_date, cycle_id?} answers {available} without saving. |
1–14 May exists, create 14–28 May → cycle_overlap |
| CYC-4 | Status, derived at read time in the project time zone: draft (no dates), upcoming (start in future), current (now within range), completed (end passed). GET cycles?status=draft|upcoming|current|completed|incomplete filters (incomplete = draft, upcoming or current). |
dates next week → upcoming |
| CYC-5 | A completed cycle accepts only position, archive and delete; any other change → cycle_completed. |
rename completed cycle → cycle_completed |
| CYC-6 | An item is in at most one cycle (item.cycle). POST cycles/{id}/items {item_ids} (non-empty) adds items; an item already in another cycle moves (one activity: old → new). Archived items → refused. Adding to a completed cycle → cycle_completed; draft and upcoming accept items. |
item in C1 added to C2 → only in C2 |
| CYC-7 | Removing an item from any cycle, completed included, is allowed and logged. | remove from completed cycle → allowed |
| CYC-8 | Transfer: POST cycles/{id}/transfer {target_cycle_id} from a completed cycle to another cycle of the project that is not completed and not archived (invalid_target). It moves every non-archived item in a backlog, unstarted or started state; completed and cancelled items stay. |
3 open + 2 done → 3 moved |
| CYC-9 | Before its first transfer, a cycle's snapshot is written once: counts per state group and total, points per state group, per-assignee and per-label totals, and the burndown. From then on progress and burndown of that cycle come from the snapshot. |
transfer, then GET progress → pre-transfer counts |
| CYC-10 | Progress: item counts per state group plus total, and estimate points per group plus total (points estimates only, else omitted); archived and deleted items don't count. Breakdown per assignee and per label: {total, completed, pending}. |
2 done of 5 → completed 2, total 5 |
| CYC-11 | Burndown for dated cycles, ?unit=items|points: one entry per day from start to end; value = total − items (or points) completed on or before that day; days after today are null. Draft cycles → no_dates. |
10 items, 3 done day 2 → day 2 = 7 |
| CYC-12 | Archive: completed cycles only (not_completed); archived cycles are read-only, hidden from cycle lists except ?archived=true, and all favorites of them are removed. Unarchive restores. |
archive current cycle → not_completed |
| CYC-13 | Delete: soft; its items stay and lose the cycle (each gets an activity entry); all favorites of it are removed. | delete cycle → items have no cycle |
| CYC-14 | Cycle list: not archived, my favorites first, then current, upcoming (by start), draft (by name), completed (newest end first). Each carries status, counts of CYC-10, assignee ids and is_favorite. |
current listed before upcoming |
Out of scope
- Automatic recurring cycles, cycle-level capacity planning.
Decisions
- Overlap, both-or-neither dates and the completed-cycle lock are enforced on every write; the source checked overlap only in its UI, both-or-neither only on create, and let any field through when
positionwas sent. - Transfer requires a completed source cycle, as the source's public API did (its app API did not).
- A snapshot covers points too; the source mixed snapshot counts with live points.
status=currentwith no current cycle returns an empty list; the source fell back to all cycles.
Events
Status: Draft · Prefix: EVN
Purpose
What the app tells the outside world through webhooks when projects, items, cycles, modules and comments change.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| EVN-1 | Event names: project., work_item., cycle., module., comment. each followed by created, updated or deleted. Archive, unarchive and auto-close are updated; soft delete is deleted. |
archive item → work_item.updated |
| EVN-2 | Webhook subscriptions (@specfork/webhooks:HOOK-1) have scope = team and list names or patterns (@specfork/webhooks:HOOK-12) from EVN-1; no filters are defined. |
subscribe cycle.created only → no item events |
| EVN-3 | One event per committed write per record: data = {object, record (state after; last state for deleted), changes? {field: {before, after}} (updated only), actor? (null = system)}. Items show key, state, labels and assignees expanded to {id, name}. A failed write emits nothing. |
PATCH priority+state → 1 event, 2 changes |
| EVN-4 | Adding or removing items in a cycle or module emits work_item.updated per item with change cycle or modules; bulk actions emit one event per changed item. |
add 3 items to cycle → 3 events |
| EVN-5 | Records the subscription's team can't see (other teams) never appear; events for private projects go only to that team's subscriptions. | manual: payload team = subscription team |
Out of scope
- Live updates to open browsers, events for labels, states, links, attachments and reactions.
Decisions
- Deletes emit events for every object; the source emitted them only for projects.
- One event carries all changed fields; the source sent one event per changed field.
Hierarchy
Status: Draft · Prefix: REL
Purpose
How work items connect: parents and sub-items, typed relations, external links and attached files.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| REL-1 | parent is an item of the same project; an item can't be its own parent or an ancestor of its parent (parent_loop). Depth is unlimited. |
A parent of B, set B parent of A → parent_loop |
| REL-2 | Deleting or archiving a parent leaves children in place; deleting clears their parent (each child gets an activity entry). |
delete parent → children top-level |
| REL-3 | GET items/{id}/sub_items returns direct children (not archived or deleted) plus state_distribution {group: [item ids]}. POST with sub_item_ids re-parents them (same project, not archived, REL-1 applies to each, all-or-nothing). Removing a sub-item sets its parent to null. |
add 2 sub-items → both have parent |
| REL-4 | Relation types, each with its inverse: blocked_by/blocking, start_before/start_after, finish_before/finish_after, implemented_by/implements; relates_to and duplicate are their own inverse. |
A blocked_by B → B shows A as blocking |
| REL-5 | POST items/{id}/relations {type, item_ids}: targets may be in any project of the team the caller can read; one relation per pair of items in either direction (already_related); self → refused; unknown type → invalid_relation. |
relate A to A → refused |
| REL-6 | GET items/{id}/relations returns every type key (all ten names), each a list of items, seen from this item's side. |
A blocked_by B → A: blocked_by [B]; B: blocking [A] |
| REL-7 | Removing a relation by {item_id} deletes it whichever side created it; both items get an activity entry. Deleting an item removes its relations. |
remove from B's side → gone on A |
| REL-8 | Link: {item, url, title? (≤ 255), created_by}. A url without scheme gets https://; only http/https with a valid host (invalid_url); unique per item (duplicate_link). When title is empty the server may fetch the page title in the background, subject to @specfork/webhooks:HOOK-2 address rules. |
example.com → https://example.com |
| REL-9 | Attachment: {item, name, size, mime_type, uploaded_by, uploaded_at?}. Upload is two-step: request (size ≤ 5 MB default, instance-configurable; mime type on an instance allowlist) returns a one-time upload target; confirm marks it uploaded. Only confirmed files list or download; unconfirmed ones are removed after 24 h. |
6 MB → refused |
| REL-10 | Uploading needs member+ (PERM-3) and read access to the item; deleting an attachment needs its uploader or a project manager (PERM-3). | member deletes other's file → 403 |
Out of scope
- Dependency scheduling (auto-shifting dates along relations), link previews beyond the title.
Decisions
- Parent loops and self-relations are refused; the source allowed both.
implementskeeps its direction and every relation type is listed; the source flipped it and never listedimplemented_by/implements.- Uploads require read access to the item; the source let restricted guests attach to items they couldn't see.
Modules
Status: Draft · Prefix: MOD
Purpose
Feature-sized groupings of work items with their own lead, members, dates and manually set status.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| MOD-1 | Module: {project, name (1–255, unique per project, case-insensitive), description? (rich text), start_date?, target_date?, status: backlog|planned|in_progress|paused|completed|cancelled (default planned), lead? → member, members [member], position, archived_at?, created_by}. A new module goes to the top. |
create → status planned |
| MOD-2 | start_date ≤ target_date when both are set, checked against stored values (invalid_dates); either may be null alone. |
stored target 1 May, PATCH start 3 May → refused |
| MOD-3 | lead and members must be member+ project members (PROJ-5, invalid_member); sending members replaces the set. |
non-member lead → invalid_member |
| MOD-4 | Status is set by hand only; dates never change it. Completed and cancelled modules stay editable. | target date passes → status unchanged |
| MOD-5 | An item can be in any number of modules (item.modules). POST modules/{id}/items {item_ids} (non-empty) adds non-archived items of the project; items already in it are skipped without a new activity entry. |
add twice → one link, one activity |
| MOD-6 | POST items/{id}/modules {add: [ids], remove: [ids]} changes one item's modules in one write; removing an absent link succeeds. |
remove absent → 204 |
| MOD-7 | Progress and breakdown as CYC-10, always live; burndown as CYC-11 from start_date to target_date, only when both are set and the module has items. |
no dates → no burndown |
| MOD-8 | Module link: {module, url, title? (≤ 255)} with the rules of REL-8; unique per module. Editing only title keeps the url as is. |
PATCH title → allowed |
| MOD-9 | Archive: only completed or cancelled modules (not_closed); archived modules are read-only and hidden except ?archived=true; all favorites removed. Unarchive restores. |
archive in_progress → not_closed |
| MOD-10 | Delete: soft; items stay and lose the module (activity per item); all favorites removed. | delete → items' modules shrink |
| MOD-11 | Module list: not archived, my favorites first, then newest first; each with counts, points, lead, members and is_favorite. |
favorite listed first |
Out of scope
- Module-level pages and updates feed.
Decisions
- Lead and members must be project members; the source accepted any user.
- Modules allow many per item; the source's public API wrongly moved an item between modules.
- Module names compare case-insensitively; the source compared them case-sensitively.
Notifications
Status: Draft · Prefix: NOTE
Purpose
Which changes notify whom. Inbox, grouping, read/archive/snooze, email and retention come from @specfork/notifications.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| NOTE-1 | Types (@specfork/notifications:NTFY-1): mentioned, assigned, comment, state_change, work_item_completed, property_change, item_deleted, webhook_disabled. Subject {kind: item, id}, except item_deleted ({kind: project, id}) and webhook_disabled ({kind: webhook, id}, sent to team admins+ per @specfork/webhooks:HOOK-7). title = <key> <item name>; body = the change (<field>: <old> → <new>) or the comment text. |
comment → type comment, title WEB-42 Fix login |
| NOTE-2 | Each activity entry (ACT-1) notifies the item's subscribers (CMT-7) who can still read it. Comment entries → comment; state into the completed group → work_item_completed; other state changes → state_change; any other field of ACT-2 → property_change. Cycle and module membership entries notify no one. |
priority change → subscribers get property_change |
| NOTE-3 | One notification per receiver per entry: mentioned when the entry newly mentions them (CMT-6; subscribed or not), else assigned when it adds them as assignee, else the entry's type from NOTE-2. |
assign + mention Ann in one save → Ann gets mentioned only |
| NOTE-4 | Preferences (@specfork/notifications:NTFY-7): every type is required in-app; email defaults to instant for every type; each type's email follows its own setting only. |
comment email off, property_change on → no comment email |
| NOTE-5 | Inbox additions to @specfork/notifications:NTFY-4: filters type (list) and project_id; the unread count also returns unread_mentions; mark all read takes the same filters. |
filter type=mentioned → mentions only |
| NOTE-6 | Deleting an item sends its subscribers item_deleted naming its key; the item's other notifications follow @specfork/notifications:NTFY-13. |
delete item → subscribers get item_deleted |
Out of scope
- Push and chat notifications, per-project preferences, notifications for pages and intake.
Decisions
- Inbox mechanics, grouping per
(user, type, subject), snooze and email delivery come from the base; the source's 5-minute per-item email batch becomes per-typeinstantordaily. - Each type's email has its own setting; the source let
property_changeoverridecommentandstate_change. assignedapplies to any newly added assignee; the source also required the creator not to be assigned.- Item deletion notifies; the source tried and failed.
Permissions
Status: Draft · Prefix: PERM
Purpose
Team roles (@specfork/teams:TEAM-2) and project membership (PROJ-5) applied to every resource.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PERM-1 | Read a project and its contents: its members (viewers included), and team admins+. Public projects are listed to every team member, who must join (PROJ-6) before reading items. Private projects are invisible to others: 404, never 403. | member GET private project → 404 |
| PERM-2 | Project managers: the project's lead and every team admin+ (members or not). Managers update project settings, features, states, labels, estimates and auto-archive/close; add and remove project members; archive, unarchive and delete the project. |
member renames project → 403 |
| PERM-3 | Resource rows (@specfork/teams:TEAM-10). Create project: member+. Inside a project, any project member with role member+: create and update items, bulk actions except delete, archive and unarchive items, cycles, modules and their items, links, attachments, others' subscriptions (CMT-7), saved views. Creator or manager: delete items, cycles, modules. Uploader or manager: delete attachments. Owner: update views; owner or manager: delete project views; owner or team admin+: delete team-wide views. Manager: bulk delete. Webhook subscriptions (scope = team): admin+. |
member deletes others' item → 403 |
| PERM-4 | Rows open to viewer (@specfork/teams:TEAM-11): any project member, viewers included, reads what PERM-1 allows, comments, edits and deletes own comments, reacts, subscribes or unsubscribes themselves, favorites, and keeps remembered settings (VIEW-8). Viewers get 403 on every other write. |
viewer comments → ok; viewer edits item → 403 |
| PERM-5 | The same checks apply on every path: UI, API (keys per @specfork/auth:AUTH-20), search, filters, favorites, notifications, history and webhooks payload selection. |
non-member search → no private items |
| PERM-6 | Personal data stays personal whatever the role: private views, remembered settings (VIEW-8), favorites, notifications and preferences. | admin reads my favorites → 404 |
Out of scope
- Plane's guest default of seeing only items they created; per-project roles.
Decisions
- Fixed team roles plus project membership instead of the source's per-project admin/member/guest roles; the lead stands in for a project admin, and the guest maps to team
viewer(read and comment). - Viewers read every item of projects they belong to; the source limited guests to their own items unless a project setting widened it.
- Private projects answer 404 to outsiders; the source said 403, or 409 for public ones.
Projects
Status: Draft · Prefix: PROJ
Purpose
A project groups a team's work items under one identifier, workflow and member list.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PROJ-1 | Project: {id, team, name (1–255, unique per team, case-insensitive), identifier, description? (rich text, sanitised), visibility: public|private (default public), lead? → member, default_assignee? → member, icon?, cover_image?, time_zone (IANA, default the team's, @specfork/teams:TEAM-13), features {cycles, modules, views} (each default false), archive_after_months (0–12, default 0 = off), close_after_months (0–12, default 0 = off), auto_close_state? → state, estimate? ([PROP-8](#PROP-8)), archived_at?, created_by, created_at}. Ordered newest first. |
create without visibility → public, features all off |
| PROJ-2 | identifier is trimmed and upper-cased before any check; 1–12 chars of [A-Z0-9]; unique per team among non-deleted projects. Taken → identifier_taken; other chars → invalid_identifier. GET identifier_available?identifier= answers the same check. |
web when WEB exists → identifier_taken |
| PROJ-3 | Changing identifier re-keys every item of the project (WEB-7 → SITE-7) and frees the old identifier at once. Deleting a project frees its identifier at once. |
rename WEB→SITE, then create WEB → allowed |
| PROJ-4 | Creating a project, in one transaction: the creator becomes a project member; lead defaults to the creator; the default states of PROP-2 are added; auto_close_state is set to the seeded cancelled state. |
create → 5 states, creator is member and lead |
| PROJ-5 | Project member: (project, user) unique, {joined_at}. Only accepted team members (@specfork/teams:TEAM-2), viewers included, can be project members. lead, default_assignee, item assignees and module leads and members must be project members with team role member+ (invalid_member). |
add non-team user → refused; viewer as lead → invalid_member |
| PROJ-6 | Joining: a team member joins a public project by themselves; a private project only by being added (PERM-2). A member can leave, unless they are the lead (change the lead first → lead_cannot_leave). |
member joins private project → refused |
| PROJ-7 | Leaving the project, and the team's on-member-removed step (@specfork/teams:TEAM-15), end project access at once. Existing assignments and authored records stay; the user can no longer be assigned, mentioned or subscribed in that project; default_assignee pointing at them is cleared. |
removed member → default_assignee null |
| PROJ-8 | Features: while cycles, modules or views is off, creating that kind of record is refused with feature_disabled and it is hidden from lists; existing records are kept and reappear when turned on. |
cycles off, create cycle → feature_disabled |
| PROJ-9 | Archive (archived_at = now) and unarchive. An archived project is read-only (any write → project_archived), hidden from project lists except ?archived=true, its items are left out of every item query and search, and all favorites of it are removed. |
archive → item search no longer finds its items |
| PROJ-10 | Delete is soft: the project and everything in it disappear from all reads at once, its notifications deleted (@specfork/notifications:NTFY-13), favorites removed; destroyed after 60 days. |
delete → GET 404, favorite gone |
| PROJ-11 | time_zone decides cycle day boundaries (CYC-2) and auto-archive/close days; changing it re-evaluates cycle status, never stored dates. |
tz UTC→Asia/Tokyo → cycle ends 9 h earlier in UTC |
| PROJ-12 | Each member orders their own project list: per-user position; a project a user joins goes to the top of their list. |
join → project first in my list |
| PROJ-13 | Project responses include next_sequence (the number the next item gets, ITEM-2), member count and the caller's is_member. |
3 items → next_sequence 4 |
Out of scope
- Project invites by email (team invites cover new people), public read-only project pages, intake/triage queue, pages and docs, analytics, import and export.
Decisions
- Identifier uniqueness is checked after upper-casing; the source checked before, so
abcslipped pastABCand failed later. - A renamed identifier is freed; the source kept it reserved.
- Project admins are not a separate role: team admins and the lead manage a project (PERM-2).
Properties
Status: Draft · Prefix: PROP
Purpose
The per-project vocabularies a work item draws from: workflow states, labels and estimates.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PROP-1 | State: {project, name (1–255, unique per project, case-insensitive), description?, color (#RRGGBB), group: backlog|unstarted|started|completed|cancelled, position, default (bool)}. States list by position; ?grouped=true returns them keyed by group in the order above. |
two states named QA → refused |
| PROP-2 | A new project gets five states, in order: Backlog (backlog, default), Todo (unstarted), In Progress (started), Done (completed), Cancelled (cancelled). |
new project → 5 states, Backlog default |
| PROP-3 | Exactly one state per project is the default. POST states/{id}/make_default sets it and clears the others in one write; default can't be set by update. |
make Todo default → Backlog not default |
| PROP-4 | A state can be deleted only if it is not the default (default_state), no item, archived or deleted but not yet destroyed, uses it (state_in_use), it is not the project's auto_close_state, and its group keeps another state (last_in_group). |
delete Done while items in it → state_in_use |
| PROP-5 | A new state goes last in the list; reorder sets position to the midpoint of neighbours. Changing a state's group is allowed and re-evaluates completed_at (ITEM-7) of its items. |
Done moved to started → its items' completed_at null |
| PROP-6 | Label: {project, name (1–255, unique per project, case-insensitive), description?, color (#RRGGBB, default random from a 10-colour palette), parent? → label, position}. Two levels only: a label with a parent can't have children, and a label with children can't get a parent (label_depth); parent in the same project. |
child of a child → label_depth |
| PROP-7 | Deleting a label removes it from every item (each item gets a labels activity) and deletes its child labels the same way. Renaming or recolouring needs no change on items. |
delete label → items lose it |
| PROP-8 | Estimate: {project, name (unique per project), type: points|categories, points [{key (int ≥ 0, unique, contiguous from 0), value (1–20 chars; a number for points), description?}]} with 2–6 points. A project uses at most one estimate (project.estimate); estimate_point must belong to it. |
7 points → refused; abc in points → refused |
| PROP-9 | Deleting an estimate point takes an optional replace_with point of the same estimate: items on it move there, or have their estimate cleared; points with a higher key shift down by one. |
delete key 1 of 0..3 → keys 0..2 |
| PROP-10 | Switching or deleting a project's estimate clears estimate_point on all its items, each with an activity entry. |
switch estimate → items unestimated |
| PROP-11 | Bulk label create [{name, color?, parent?}] (≤ 100) skips names that already exist and returns what was created. |
3 names, 1 exists → 2 created |
Out of scope
- Team-wide labels shared across projects, triage states, time-based estimates.
Decisions
- The triage state group is dropped with intake.
- The source let the UI alone block deleting the last state of a group and nesting labels deeper than two levels; both are enforced here.
- State, label and estimate-point names are compared case-insensitively everywhere; the source mixed sensitive and insensitive checks.
Query
Status: Draft · Prefix: QRY
Purpose
One way to list work items anywhere (project, cycle, module, view, team): filter, sort, group, page, and search.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| QRY-1 | Item lists take filter (JSON, QRY-2), order_by, group_by, sub_group_by, sub_items (default true; false = only items without parent), cursor, per_page. Project, cycle, module, saved-view and team-wide lists share them; a cycle or module list adds its own scope. |
sub_items=false → no children listed |
| QRY-2 | Filter node: a leaf object whose keys are ANDed, or exactly one of {"and": [nodes]}, {"or": [nodes]} (non-empty), {"not": node}. Depth ≤ 5. Operators are lower-case. |
{"or":[{"priority":"high"},{"priority":"urgent"}]} → both |
| QRY-3 | Leaf keys: state_id, state_group, priority, assignee_id, label_id, cycle_id, module_id, mention_id, subscriber_id, created_by_id, project_id, parent_id, estimate_point_id (<key> exact, <key>__in list); start_date, target_date, completed_at, created_at, updated_at (<key> exact date, <key>__range "YYYY-MM-DD,YYYY-MM-DD" inclusive); is_archived (bool); name__contains (case-insensitive). Value null means "has none" for id keys and dates. |
{"cycle_id": null} → items in no cycle |
| QRY-4 | Timestamps compare by their date in the project time zone. Many-valued keys (assignee, label, module, mention, subscriber) match if any value matches. | assignee_id__in [A,B] → items of A or B |
| QRY-5 | Invalid filter → 400 invalid_filter with the JSON path: bad JSON, unknown key or operator, operator mixed with fields, empty and/or, depth > 5, non-scalar list item, wrong value type. |
{"colour":"red"} → invalid_filter at colour |
| QRY-6 | order_by, optional - prefix: created_at, updated_at, sequence_id, position, start_date, target_date, completed_at, archived_at, priority, state_name, state_group, assignee_name, label_name, module_name, estimate_point. Default -created_at; unknown → invalid_order. |
order_by=colour → invalid_order |
| QRY-7 | Sorting: priority uses the rank of ITEM-3 (-priority = urgent first); state_group uses group order backlog→cancelled; many-valued keys sort by their smallest value; nulls go last both ways; ties break by -created_at. |
-priority → urgent first, no-date items last on dates |
| QRY-8 | group_by / sub_group_by: state, state_group, priority, assignees, labels, cycle, modules, project, created_by, start_date, target_date. sub_group_by needs group_by and must differ (invalid_grouping). Items with several values appear in each group; items with none go to a none group. Each group returns {key, total_count, results}; show_empty lists groups with no items too. |
group_by=assignees, item with 2 → in both groups |
| QRY-9 | Default scope: not deleted, not archived, project not archived, only projects the caller can read (PERM-1). is_archived: true in the filter lists archived items. |
archived item absent unless filtered |
| QRY-10 | Pagination: opaque cursor; per_page default 100, max 1000 (invalid_per_page); bad cursor → invalid_cursor. Response: {results | groups, total_count, next_cursor?, prev_cursor?}. Grouped lists page each group separately (cursor per group key). |
per_page 1001 → invalid_per_page |
| QRY-11 | Search GET search?q=&types=project,item,cycle,module,view: case-insensitive substring of name; items also match their exact key (WEB-12) or bare number within a project scope. ≤ 100 results per type, only what the caller can read. |
web-12 → that item only |
| QRY-12 | Picker search for an item ?for=parent|relation|sub_item|cycle|module&item_id=: parent excludes the item and its descendants; relation excludes the item and already-related items; sub_item excludes the item and its ancestors; cycle excludes items already in a cycle; module excludes items already in that module. |
parent picker for A → none of A's descendants |
Out of scope
- Relative date tokens ("last 2 weeks") stored in filters, full-text search inside descriptions and comments.
Decisions
- One JSON filter language replaces the source's two (query-string filters and JSON filters, both applied).
- Unknown sort keys are refused instead of falling back silently.
- Default page size 100 instead of 1000.
- A key search matches that key; the source matched the number in every project.
Views
Status: Draft · Prefix: VIEW
Purpose
How items are shown: layouts and display settings, saved views, built-in views, remembered settings, favorites.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| VIEW-1 | Display settings: {layout: list|board|calendar|spreadsheet|timeline (default list), group_by?, sub_group_by?, order_by (default -created_at), sub_items (default true), show_empty_groups (default true), properties [names]}. properties ⊆ key, state, priority, assignees, labels, start_date, target_date, estimate, modules, cycle, sub_item_count, link_count, attachment_count, created_at, updated_at; default all. |
new view → list, all properties |
| VIEW-2 | Board needs group_by (default state); calendar places items by target_date and takes no grouping; spreadsheet and timeline take no grouping; timeline places items from start_date to target_date. Dropping a board card into another group sets that field (ITEM-8 for position). |
board drag to Done → state Done |
| VIEW-3 | Saved view: {team, project? (null = team-wide), name (1–255), description?, filter ([QRY-2](#QRY-2)), display (VIEW-1), visibility: private|public (default public), locked (default false), owner, position, icon?}. |
create → public, unlocked |
| VIEW-4 | Private views are visible only to their owner; public project views to readers of the project; public team-wide views to all team members, their results limited to projects each reader can read. | other member GET private view → 404 |
| VIEW-5 | Only the owner updates a view, sets visibility or locks it; a locked view refuses every change except unlock (view_locked). Anyone who can read it may save its current settings as a new view of their own. |
owner edits locked view → view_locked |
| VIEW-6 | Delete: owner, or a project manager (project views) / team admin+ (team-wide views); removes all favorites of it. | admin deletes member's public view → ok |
| VIEW-7 | Built-in team views, not stored or editable: all, assigned (assignee = me), created (created_by = me), subscribed (subscriber = me). |
assigned → only my items |
| VIEW-8 | Remembered settings: per user and per project, cycle, module and built-in view, {filter, display}; GET creates defaults if missing, PATCH replaces only the keys sent. Opening that list again uses them. |
change layout to board, reload → board |
| VIEW-9 | Views list by position; a new view goes last; my favorites first. |
reorder → kept |
| VIEW-10 | Favorite: {user, kind: project|cycle|module|view|item|folder, target?, name? (folders), folder?, position}; one per (user, kind, target), adding again returns the existing one; folders don't nest; deleting a folder deletes its favorites. A favorite whose target is deleted, archived or no longer readable is removed or hidden. |
favorite twice → one |
Out of scope
- Gantt dependencies, dashboards and widgets, view sharing by link outside the team.
Decisions
- Creating and updating views is checked like any write; the source let any signed-in user create views and edit public ones through one route.
- Owners can lock and unlock; the source stored the flag but had no way to set it.
- Board, calendar and timeline settings are kept on the server for built-in views too; the source kept them in the browser.
Work items
Status: Draft · Prefix: ITEM
Purpose
The unit of work in a project: what it is, who does it, when, how urgent, and its life from creation to archive.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| ITEM-1 | Work item: {id, project, sequence_id, key, name (1–255), description? (rich text, sanitised; invalid markup → 400), state, priority, assignees [member], labels [label], start_date?, target_date?, estimate_point?, parent? ([REL-1](#REL-1)), position, completed_at?, archived_at?, created_by, created_at, updated_at}. Dates are calendar dates without time. |
create with name only → defaults filled |
| ITEM-2 | sequence_id = highest number ever used in the project + 1, assigned atomically; numbers of deleted items are never reused. key = <identifier>-<sequence_id>. GET items/by_key/{key} matches the identifier case-insensitively; a malformed key → invalid_key, unknown → 404. |
delete WEB-3, create → WEB-4 |
| ITEM-3 | priority: urgent|high|medium|low|none, default none; rank urgent > high > medium > low > none. |
create → none |
| ITEM-4 | Without state, the project's default state is used (PROP-3). state, labels, estimate_point and parent must belong to the same project, else invalid_reference. |
label of other project → invalid_reference |
| ITEM-5 | Assignees must be member+ project members (PROJ-5), else invalid_assignee. With no assignees given on create, default_assignee is assigned if set. Sending assignees or labels replaces the whole set. |
create, no assignees, default set → assigned |
| ITEM-6 | start_date ≤ target_date, checked against the stored value when only one is sent; else invalid_dates. Either may be null. |
stored target 1 May, PATCH start 2 May → refused |
| ITEM-7 | completed_at = now when the state changes into the completed group, null when it changes to any other group; set on create too. |
move to Done → completed_at set; back to Todo → null |
| ITEM-8 | position (float) orders items in manual order. A new item goes after the last item in its state (max + 10000; first item 65535); a moved item takes the midpoint of its new neighbours, or first − 10000 / last + 10000 at the ends. |
drop between 100 and 200 → 150 |
| ITEM-9 | Archive: only items in the completed or cancelled group, else not_closed; sets archived_at = now. Archived items are read-only (writes → item_archived), left out of default queries (QRY-9), still openable by key; unarchive clears archived_at. |
archive item in started → not_closed |
| ITEM-10 | Auto-archive, daily: in projects with archive_after_months = N > 0, items in completed/cancelled not updated for N × 30 days are archived by the system, except items in a cycle that has not ended or a module whose target_date is today or later. |
N=1, done 31 days ago, no cycle → archived |
| ITEM-11 | Auto-close, daily: with close_after_months = N > 0, items in backlog/unstarted/started not updated for N × 30 days move to the project's auto_close_state (always a cancelled-group state of the same project), with the same cycle and module exceptions as ITEM-10. |
N=2, untouched 61 days → cancelled state |
| ITEM-12 | Delete: soft; the item leaves every list, cycle, module and relation at once; its children lose their parent (REL-2); an activity entry and event are written. | delete parent → child has no parent |
| ITEM-13 | Bulk actions on ≤ 100 item ids of one project: delete, archive, set dates ([{id, start_date, target_date}]), set state/priority/assignees/labels/cycle/modules. All-or-nothing: one invalid item → nothing changes and the error names it. Each changed item gets its own activity and event. |
bulk archive with one open item → none archived |
| ITEM-14 | Copy: creates a new item from an existing one with name <name> (copy) and the same description, state, priority, assignees, labels, dates, estimate, parent, cycle and modules; not comments, relations, links, attachments or history. |
copy → new key, same labels |
| ITEM-15 | Every change bumps updated_at, including links, attachments, comments, cycle and module membership. |
add comment → item updated_at changes |
Out of scope
- Work item types and custom properties, drafts saved outside a project, intake/triage, votes, time tracking.
Decisions
- Date order is checked against stored values; the source compared only dates sent together.
- Auto-close uses a per-project
auto_close_state; the source fell back to any cancelled state, possibly another project's. - Cross-project labels and non-member assignees are refused; the source dropped them silently.
- Deleting a parent detaches its children instead of deleting them.
- Bulk actions log activity per item; the source's bulk delete logged none.
From Specfork/projects at 585a325, licence CC-BY-4.0.