Specfork

@specfork/projects

Project management with work items, workflow states, labels, cycles, modules, saved views, comments, activity history, notifications and webhooks.

saasproject-management Version 0.1.1 130 requirements Licence CC-BY-4.0 Specfork/projects

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 position was 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=current with 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.
  • implements keeps its direction and every relation type is listed; the source flipped it and never listed implemented_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-type instant or daily.
  • Each type's email has its own setting; the source let property_change override comment and state_change.
  • assigned applies 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 abc slipped past ABC and 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.