Technical Design TD-0006 — Issue Tracking Collaboration¶
| Field | Value |
|---|---|
| ID | TD-0006 |
| Title | Issue Tracking Collaboration |
| Status | Draft |
| Owner | Loryth Team |
| Audience | contributors |
| Created | 2026-02-17 |
| Last Updated | 2026-02-17 |
| Reviewers | — |
| Approvers | — |
| Tracking | — |
| Related | ADR-0016, TDD-0004, TDD-0002, TDD-0003 |
1. Summary¶
- The issue tracking service needs collaboration features: threaded comments, attachments, labels, issue links, checklists, and @mentions
- This document specifies the data model, API contracts, and integration points for all collaboration entities
- Impact: transforms issues from static records into collaborative workspaces
2. Context and Problem Statement¶
The current issue model is a flat record with title, description, status, and parent reference. There is no way to discuss issues, attach files, link related issues, categorize with labels, track acceptance criteria, or notify mentioned users.
Effective project management requires rich collaboration features that allow teams to discuss, reference, track, and coordinate work within the context of individual issues.
2.1 Goals¶
- Threaded comments with arbitrary nesting depth and edit history
- File attachment metadata (backed by TDD-0002 file storage)
- Labels/tags with categories and single-select enforcement
- Directed and symmetric issue links
- Ordered checklists for acceptance criteria
- @mention parsing with notification dispatch to loryth-notifications
2.2 Out of Scope¶
- File storage backend implementation (uses TDD-0002 interfaces)
- Notification delivery mechanics (uses TDD-0003 via RabbitMQ)
- Rich text rendering and editor (client-side concern)
- Real-time comment updates (future WebSocket/SSE integration)
- Reactions/emoji on comments (future)
2.3 Assumptions and Constraints¶
- Comments store plain text or Markdown — no rich text AST in the database
- Attachment file content is stored externally; only metadata lives in loryth-issues
- @mentions reference users by UUID; display names are resolved at render time
- All collaboration entities are scoped to a single issue (no cross-issue comments)
- Label categories and tags are scoped to a project
3. Design Overview¶
3.1 Key Components¶
| Component | Purpose |
|---|---|
| Comments | Threaded discussion on issues with edit audit trail |
| Attachments | File metadata linking issues/comments to file storage |
| Labels/Tags | Categorization with single-select category support |
| Issue Links | Directed and symmetric relationships between issues |
| Checklists | Ordered acceptance criteria items with check/uncheck |
| @Mentions | User notification dispatch on mention in text content |
3.2 Architecture¶
erDiagram
Issue ||--o{ Comment : "issue_id"
Comment ||--o{ Comment : "parent_comment_id"
Comment ||--o{ CommentEditHistory : "comment_id"
Issue ||--o{ Attachment : "issue_id"
Comment ||--o{ Attachment : "comment_id"
Issue ||--o{ IssueTag : "issue_id"
IssueTag }o--|| Tag : "tag_id"
Tag }o--|| TagCategory : "tag_category_id"
TagCategory }o--|| Project : "project_id"
Issue ||--o{ IssueLink : "source_issue_id"
Issue ||--o{ IssueLink : "target_issue_id"
IssueLink }o--|| LinkTypeDefinition : "link_type_definition_id"
Issue ||--o{ ChecklistItem : "issue_id" 4. Implementation Details¶
4.1 Threaded Comments¶
Comment Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
issue_id | BIGINT | FK → issue(id), NOT NULL | |
author_id | UUID | NOT NULL | External user reference |
parent_comment_id | BIGINT | FK → comment(id) | NULL for top-level comments |
body | TEXT | NOT NULL | Markdown text |
depth | INTEGER | NOT NULL, DEFAULT 0 | 0 for top-level, incremented per nesting level |
deleted_at | TIMESTAMPTZ | Soft delete | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Indexes: - (issue_id, created_at) — chronological listing per issue - (parent_comment_id) — reply lookups - (author_id) — user's comments
CommentEditHistory Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
comment_id | BIGINT | FK → comment(id), NOT NULL | |
body_before | TEXT | NOT NULL | Previous body content |
body_after | TEXT | NOT NULL | New body content |
edited_by | UUID | NOT NULL | User who made the edit |
edited_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Comment Loading Strategy¶
All comments for an issue are loaded in a single query ordered by (depth, created_at). The tree structure is assembled in the application layer:
def build_comment_tree(comments: list[Comment]) -> list[CommentNode]:
"""Build threaded comment tree from flat list.
:param comments: All comments for an issue, ordered by (depth, created_at)
:return: Tree of top-level comments with nested replies
"""
by_id: dict[int, CommentNode] = {}
roots: list[CommentNode] = []
for comment in comments:
node = CommentNode(comment=comment, replies=[])
by_id[comment.id] = node
if comment.parent_comment_id is None:
roots.append(node)
elif comment.parent_comment_id in by_id:
by_id[comment.parent_comment_id].replies.append(node)
return roots
Pagination: top-level comments are paginated (e.g., 20 per page). All replies for visible top-level comments are loaded eagerly (typically few replies per thread).
Comment API¶
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /issues/{key}/comments | List top-level comments (paginated) with replies | SuccessResponse[PageModel[CommentModel]] |
| POST | /issues/{key}/comments | Create comment | SuccessResponse[CommentModel] — 201 |
| PUT | /comments/{uuid} | Edit comment body | SuccessResponse[CommentModel] |
| DELETE | /comments/{uuid} | Soft-delete comment | 204 No Content |
Create comment request body:
{
"body": "This looks good. @[user-uuid] can you review?",
"parent_comment_uuid": null
}
Edit behavior: On edit, the previous body is saved to comment_edit_history before updating the comment body. The updated_at timestamp is set to NOW(). Edit history is available through the comment detail endpoint.
4.2 Attachments¶
Attachment Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
issue_id | BIGINT | FK → issue(id) | Nullable — set for issue-level attachments |
comment_id | BIGINT | FK → comment(id) | Nullable — set for comment-level attachments |
file_storage_ref | UUID | NOT NULL | Reference to TDD-0002 file storage |
filename | VARCHAR(255) | NOT NULL | Original filename |
content_type | VARCHAR(100) | NOT NULL | MIME type |
size_bytes | BIGINT | NOT NULL | File size |
uploaded_by | UUID | NOT NULL | External user reference |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraint: CHECK (issue_id IS NOT NULL OR comment_id IS NOT NULL) — attachment must belong to either an issue or a comment, not neither.
Constraint: CHECK (NOT (issue_id IS NOT NULL AND comment_id IS NOT NULL)) — attachment belongs to exactly one parent.
Attachment Workflow¶
- Upload: Client uploads file to file storage service (TDD-0002), receives
file_storage_refUUID - Register: Client calls loryth-issues attachment endpoint with metadata +
file_storage_ref - Download: Client requests attachment metadata, receives download URL from file storage
- Delete: Remove metadata row + publish delete event to file storage via RabbitMQ
Attachment API¶
| Method | Path | Description | Response |
|---|---|---|---|
| POST | /issues/{key}/attachments | Register attachment on issue | SuccessResponse[AttachmentModel] — 201 |
| POST | /comments/{uuid}/attachments | Register attachment on comment | SuccessResponse[AttachmentModel] — 201 |
| GET | /attachments/{uuid} | Get attachment metadata | SuccessResponse[AttachmentModel] |
| DELETE | /attachments/{uuid} | Delete attachment | 204 No Content |
| GET | /issues/{key}/attachments | List attachments for issue | SuccessResponse[list[AttachmentModel]] |
4.3 Labels and Tags¶
TagCategory Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
project_id | BIGINT | FK → project(id), NOT NULL | |
name | VARCHAR(100) | NOT NULL | e.g., "Component", "Team" |
color | VARCHAR(7) | Category accent color | |
is_single_select | BOOLEAN | NOT NULL, DEFAULT FALSE | One tag per category per issue |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraint: UNIQUE(project_id, name).
Tag Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
tag_category_id | BIGINT | FK → tag_category(id) ON DELETE CASCADE, NOT NULL | |
name | VARCHAR(100) | NOT NULL | e.g., "Frontend", "Backend" |
color | VARCHAR(7) | Tag-specific color | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraint: UNIQUE(tag_category_id, name).
IssueTag Junction¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
issue_id | BIGINT | FK → issue(id), NOT NULL | |
tag_id | BIGINT | FK → tag(id), NOT NULL | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraint: UNIQUE(issue_id, tag_id).
Single-Select Enforcement¶
When tag_category.is_single_select = TRUE, assigning a tag from that category to an issue replaces any existing tag from the same category. Enforcement is at the application level:
def set_tags(issue_id: int, tag_ids: list[int]) -> None:
"""Set tags on an issue, enforcing single-select category constraints.
:param issue_id: The issue ID
:param tag_ids: List of tag IDs to assign
:raises ValidationError: If multiple tags from a single-select category are provided
"""
tags = tag_repo.find_by_ids(tag_ids)
categories = {tag.tag_category_id: [] for tag in tags}
for tag in tags:
categories[tag.tag_category_id].append(tag)
for cat_id, cat_tags in categories.items():
category = tag_category_repo.get(cat_id)
if category.is_single_select and len(cat_tags) > 1:
raise ValidationError(
f"Category '{category.name}' is single-select but "
f"{len(cat_tags)} tags were provided"
)
# Replace all tags atomically
issue_tag_repo.delete_all_for_issue(issue_id)
for tag_id in tag_ids:
issue_tag_repo.save(IssueTag(issue_id=issue_id, tag_id=tag_id))
Tag API¶
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /projects/{key}/tag-categories | List categories with tags | SuccessResponse[list[TagCategoryModel]] |
| POST | /projects/{key}/tag-categories | Create category | SuccessResponse[TagCategoryModel] — 201 |
| PUT | /tag-categories/{uuid} | Update category | SuccessResponse[TagCategoryModel] |
| DELETE | /tag-categories/{uuid} | Delete category (cascades tags) | 204 No Content |
| POST | /tag-categories/{uuid}/tags | Create tag in category | SuccessResponse[TagModel] — 201 |
| PUT | /tags/{uuid} | Update tag | SuccessResponse[TagModel] |
| DELETE | /tags/{uuid} | Delete tag | 204 No Content |
| PUT | /issues/{key}/tags | Set tags on issue (full replace) | SuccessResponse[list[TagModel]] |
| GET | /issues/{key}/tags | List tags on issue | SuccessResponse[list[TagModel]] |
4.4 Issue Links¶
IssueLink Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
link_type_definition_id | BIGINT | FK → link_type_definition(id), NOT NULL | |
source_issue_id | BIGINT | FK → issue(id), NOT NULL | |
target_issue_id | BIGINT | FK → issue(id), NOT NULL | |
created_by | UUID | NOT NULL | External user reference |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Constraints: - CHECK (source_issue_id != target_issue_id) — no self-links - UNIQUE(link_type_definition_id, source_issue_id, target_issue_id) — no duplicate links
Link Direction Semantics¶
For directed link types (e.g., "blocks" / "is blocked by"): - source_issue_id is the issue performing the action (the blocker) - target_issue_id is the issue receiving the action (the blocked) - When viewing the source issue: display outward_name ("blocks PROJ-45") - When viewing the target issue: display inward_name ("is blocked by PROJ-44")
For symmetric link types (e.g., "relates to"): - Order is normalized: source_issue_id < target_issue_id to prevent duplicate reversed entries - Both issues display the same label (outward_name = inward_name = "relates to")
Default Link Types (Space Defaults)¶
| Name | Outward | Inward | Symmetric |
|---|---|---|---|
| Blocks | blocks | is blocked by | No |
| Duplicates | duplicates | is duplicated by | No |
| Relates | relates to | relates to | Yes |
| Causes | causes | is caused by | No |
Issue Link API¶
| Method | Path | Description | Response |
|---|---|---|---|
| POST | /issues/{key}/links | Create link | SuccessResponse[IssueLinkModel] — 201 |
| GET | /issues/{key}/links | List all links for issue | SuccessResponse[list[IssueLinkModel]] |
| DELETE | /issue-links/{uuid} | Delete link | 204 No Content |
Create link request body:
{
"link_type_uuid": "...",
"target_issue_key": "PROJ-45",
"direction": "outward"
}
The direction field indicates whether the current issue is the source (outward) or target (inward) of the link. For symmetric link types, direction is ignored.
4.5 Checklists / Acceptance Criteria¶
ChecklistItem Entity¶
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | |
uuid | UUID | UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() | |
issue_id | BIGINT | FK → issue(id), NOT NULL | |
title | VARCHAR(500) | NOT NULL | Item description |
rank | VARCHAR(255) | NOT NULL | LexoRank for ordering |
is_checked | BOOLEAN | NOT NULL, DEFAULT FALSE | |
checked_at | TIMESTAMPTZ | Set when checked, cleared when unchecked | |
checked_by | UUID | User who last checked/unchecked | |
created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() |
Index: (issue_id, rank) — ordered listing per issue.
The same LexoRank strategy from TDD-0005 is used for checklist item ordering. Rebalancing threshold applies per issue's checklist items.
Checklist API¶
| Method | Path | Description | Response |
|---|---|---|---|
| GET | /issues/{key}/checklist | List checklist items (ordered) | SuccessResponse[list[ChecklistItemModel]] |
| POST | /issues/{key}/checklist | Add checklist item | SuccessResponse[ChecklistItemModel] — 201 |
| PUT | /checklist-items/{uuid} | Update item (title, is_checked, rank) | SuccessResponse[ChecklistItemModel] |
| DELETE | /checklist-items/{uuid} | Delete item | 204 No Content |
| PUT | /issues/{key}/checklist/reorder | Batch reorder items | SuccessResponse[list[ChecklistItemModel]] |
Batch reorder request body:
{
"items": [
{ "uuid": "...", "rank": "b" },
{ "uuid": "...", "rank": "d" },
{ "uuid": "...", "rank": "f" }
]
}
4.6 @Mentions¶
Parsing¶
Comment bodies and issue descriptions are scanned for @[user-uuid] patterns at write time (create or edit):
import re
MENTION_PATTERN = re.compile(r"@\[([0-9a-f-]{36})\]")
def extract_mentions(text: str) -> list[str]:
"""Extract user UUIDs from @mention patterns in text.
:param text: Text content to scan
:return: List of unique user UUIDs mentioned
"""
return list(set(MENTION_PATTERN.findall(text)))
Notification Dispatch¶
On comment creation or issue description update: 1. Extract mentioned user UUIDs from the text 2. Exclude the author (don't notify yourself) 3. Publish MentionCreatedEvent to RabbitMQ for each unique mention
@dataclass
class MentionCreatedEvent:
"""Event published when a user is mentioned in a comment or description."""
mentioned_user_id: str # UUID of the mentioned user
mentioner_user_id: str # UUID of the user who wrote the mention
issue_key: str # e.g., "PROJ-123"
context: str # "comment" or "description"
context_uuid: str # Comment UUID or Issue UUID
The loryth-notifications service (TDD-0003) consumes these events and delivers in-app + email notifications.
Storage¶
No mention table is needed. Mentions are derived from text content at write time and dispatched as fire-and-forget events. If the text is edited, new mentions in the updated text trigger new events; removed mentions are not recalled (notifications already sent are not retracted).
4.7 Error Handling and Edge Cases¶
| Scenario | Behavior |
|---|---|
| Comment on soft-deleted issue | 404 Not Found (issue not visible) |
| Edit another user's comment | 403 Forbidden (author check at service level) |
Attachment with invalid file_storage_ref | Accept metadata; file availability is the storage service's concern |
| Multiple tags from single-select category | 422 Unprocessable Entity with validation message |
| Link to non-existent issue key | 404 Not Found for target issue |
| Self-link (issue links to itself) | 422 Unprocessable Entity |
| Duplicate link (same type, same pair) | 409 Conflict |
| @mention of non-existent user UUID | Event published; notification service handles resolution |
5. Semantics¶
- Comments are soft-deleted — the comment slot remains in the thread with a "[deleted]" placeholder to preserve thread structure. Replies to a deleted comment are not affected.
- Attachment deletion removes metadata from loryth-issues and publishes a delete event. Actual file removal is handled by the file storage service asynchronously.
- Tag assignment via
PUT /issues/{key}/tagsis a full replace operation — all existing tags are removed and replaced with the provided list. This simplifies single-select enforcement. - Issue links are undirected in storage for symmetric types (normalized by ID ordering) but directional in display (outward/inward names).
- Checklist items are independent of issue status — checking all items does not auto-complete the issue.
6. Examples¶
6.1 Creating a Threaded Comment¶
POST /issues/PROJ-123/comments
{
"body": "I think we should approach this differently. @[550e8400-e29b-41d4-a716-446655440000] what do you think?"
}
→ 201 Created
{
"data": {
"uuid": "...",
"author_id": "...",
"body": "I think we should approach this differently. @[550e8400-e29b-41d4-a716-446655440000] what do you think?",
"depth": 0,
"created_at": "2026-02-17T10:30:00Z",
"replies": []
}
}
Side effect: MentionCreatedEvent published for user 550e8400-...
6.2 Linking Two Issues¶
POST /issues/PROJ-123/links
{
"link_type_uuid": "blocks-link-type-uuid",
"target_issue_key": "PROJ-456",
"direction": "outward"
}
Result: PROJ-123 "blocks" PROJ-456
Viewing PROJ-123: "blocks PROJ-456"
Viewing PROJ-456: "is blocked by PROJ-123"
7. Test Matrix¶
7.1 Unit Coverage¶
- Comment tree building from flat list
- @mention pattern extraction
- Single-select tag category validation
- Symmetric link ID normalization
- Checklist LexoRank ordering
7.2 Integration Coverage¶
- Full comment lifecycle: create, reply, edit (with history), soft-delete
- Attachment registration and deletion with file storage event
- Tag CRUD with single-select enforcement
- Issue link creation (directed and symmetric) and duplicate prevention
- Checklist CRUD with reordering
- @mention event publication on comment create and edit
7.3 Regression Coverage¶
- Existing issue CRUD unaffected by new child entities
- Soft-deleting an issue hides its comments, attachments, and links from normal queries
8. Acceptance Criteria¶
- Comments support arbitrary nesting depth with correct tree assembly
- Comment edits preserve full edit history
- Soft-deleted comments show "[deleted]" placeholder but preserve thread structure
- Attachments store metadata and reference TDD-0002 file storage
- Single-select tag categories enforce one tag per category per issue
- Directed links display correct outward/inward names per issue
- Symmetric links are normalized (no duplicate reversed entries)
- Checklist items support ordered check/uncheck with LexoRank
- @mentions extract user UUIDs and publish notification events
9. Rollout and Migration¶
9.1 Phased Plan¶
Phase 3a: Schema - Create tables: comment, comment_edit_history, attachment, tag_category, tag, issue_tag, issue_link, checklist_item - Create indexes and constraints
Phase 3b: Comment and attachment features - Implement comment CRUD with threading and edit history - Implement attachment metadata registration and deletion
Phase 3c: Tags, links, and checklists - Implement tag category and tag CRUD with single-select enforcement - Implement issue link CRUD with direction semantics - Implement checklist item CRUD with LexoRank ordering
Phase 3d: @Mentions - Implement mention parsing and event dispatch - Integrate with comment creation and issue description updates
9.2 Migration Strategy¶
All new tables are additive. No existing data requires modification. Tables use cascade delete from issue to ensure data integrity when issues are hard-deleted (future).
10. Risks and Mitigations¶
| Risk | Mitigation |
|---|---|
| Deep comment nesting creates large payloads | Practical limit enforced by UI; consider max depth (e.g., 10) at application level |
| Orphaned file storage refs on attachment delete failure | Eventual consistency; file storage service handles orphan cleanup |
| @mention spam (many mentions in one comment) | Rate limit on notification dispatch; deduplicate mentions per text |
| Large number of tags per issue slowing queries | Index on issue_tag(issue_id); practical limit is small (< 20 tags per issue) |
11. Progress¶
- Create comment entity and repository
- Implement comment threading and tree assembly
- Implement comment edit history
- Create attachment entity and repository
- Implement tag category and tag CRUD
- Implement single-select enforcement
- Create issue link entity and repository
- Implement link direction semantics
- Create checklist item entity and repository
- Implement @mention parsing and event dispatch
- Build all API endpoints
- Write integration tests
12. Changelog¶
| Date | Change |
|---|---|
| 2026-02-17 | Initial version |
13. Glossary¶
| Term | Definition |
|---|---|
| Thread | A top-level comment and all its nested replies, forming a tree structure. |
| Edit history | A record of every edit made to a comment, preserving the before and after content. |
| Tag category | A grouping of tags within a project (e.g., "Component", "Team"). Can be single-select. |
| Single-select | A tag category constraint allowing at most one tag from that category per issue. |
| Directed link | An issue link with distinct outward and inward names (e.g., "blocks" / "is blocked by"). |
| Symmetric link | An issue link where both directions use the same name (e.g., "relates to"). |
| @mention | A reference to a user in text content using the @[user-uuid] format, triggering a notification. |