Skip to content

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

  1. Upload: Client uploads file to file storage service (TDD-0002), receives file_storage_ref UUID
  2. Register: Client calls loryth-issues attachment endpoint with metadata + file_storage_ref
  3. Download: Client requests attachment metadata, receives download URL from file storage
  4. 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]]
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

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")

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
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}/tags is 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.