Skip to content

Technical Design TD-0004 — Issue Tracking Domain Model

Field Value
ID TD-0004
Title Issue Tracking Domain Model
Status Draft
Owner Loryth Team
Audience contributors
Created 2026-02-17
Last Updated 2026-02-17
Reviewers —
Approvers —
Tracking —
Related ADR-0016, Roadmap

1. Summary

  • The loryth-issues service needs a complete domain model redesign to support Space-scoped catalogs, project configuration, immutable issue keys, custom fields, type hierarchy constraints, and soft delete
  • This document specifies all entities, their fields, relationships, invariants, and SQL migrations for Phase 1
  • Impact: transforms the skeleton service into a fully modeled issue tracking domain

2. Context and Problem Statement

The current data model has five entities (Status, Workflow/WorkflowTransition, IssueType, Project, Issue) with basic fields and no concept of organizational scoping, project configuration, custom fields, or issue numbering. Issues lack assignees, reporters, priority, ranking, and meaningful metadata. Projects have no key for human-readable issue identifiers.

This design document addresses the complete entity model needed for a production issue tracker, corresponding to Phase 1 of the roadmap.

2.1 Goals

  • Define all entities with complete field specifications
  • Establish Space-scoped isolation for catalog entities
  • Design the project configuration model (enabled types, fields, link types, hierarchy rules)
  • Specify the Issue aggregate with all required fields
  • Define immutable issue key generation (PROJ-123)
  • Specify the EAV custom field model
  • Provide complete SQL migration DDL

2.2 Out of Scope

  • Board, sprint, and ranking behavior (see TDD-0005)
  • Comments, attachments, links, labels, checklists (see TDD-0006)
  • Activity log, change history, search, reporting (see TDD-0007)
  • Space entity definition (future core-platform service)
  • Permission model beyond data isolation

2.3 Assumptions and Constraints

  • The shared loryth PostgreSQL database is used (not iam)
  • Existing migrations v00–v05 are preserved; new migrations extend the schema
  • space_id references an external Space entity — no FK constraint to another service's table
  • The existing event sourcing infrastructure (outbox, eventlog from v00-setup.sql) is reused
  • All entities follow BaseAggregate/BaseEntity patterns from loryth-domain

3. Design Overview

3.1 Key Components

Component Purpose
Space-scoped catalogs Status, Workflow, IssueType, CustomFieldDefinition, LinkTypeDefinition — isolated per Space
Project configuration ProjectIssueType, ProjectCustomField, ProjectLinkType, TypeHierarchyRule — per-project enablement
Issue aggregate Core entity with all tracking fields, soft delete, and immutable key
Custom field values EAV table linking issues to field definitions with typed columns
Issue number sequence Atomic per-project counter for sequential issue numbering

3.2 Architecture

erDiagram
    Space ||--o{ Status : "space_id"
    Space ||--o{ Workflow : "space_id"
    Space ||--o{ IssueTypeDefinition : "space_id"
    Space ||--o{ CustomFieldDefinition : "space_id"
    Space ||--o{ LinkTypeDefinition : "space_id"
    Space ||--o{ Project : "space_id"

    Workflow ||--o{ WorkflowTransition : "workflow_id"
    WorkflowTransition }o--|| Status : "source_status_id"
    WorkflowTransition }o--|| Status : "target_status_id"

    IssueTypeDefinition ||--o| Workflow : "default_workflow_id"

    Project ||--o{ ProjectIssueType : "project_id"
    ProjectIssueType }o--|| IssueTypeDefinition : "issue_type_definition_id"
    ProjectIssueType }o--o| Workflow : "workflow_id (override)"

    Project ||--o{ ProjectCustomField : "project_id"
    ProjectCustomField }o--|| CustomFieldDefinition : "custom_field_definition_id"

    Project ||--o{ ProjectLinkType : "project_id"
    ProjectLinkType }o--|| LinkTypeDefinition : "link_type_definition_id"

    Project ||--o{ TypeHierarchyRule : "project_id"
    TypeHierarchyRule }o--|| IssueTypeDefinition : "parent_type_id"
    TypeHierarchyRule }o--|| IssueTypeDefinition : "child_type_id"

    Project ||--|| ProjectIssueSequence : "project_id"

    Project ||--o{ Issue : "project_id"
    Issue }o--|| IssueTypeDefinition : "issue_type_id"
    Issue }o--|| Status : "status_id"
    Issue }o--o| Issue : "parent_id"

    Issue ||--o{ CustomFieldValue : "issue_id"
    CustomFieldValue }o--|| CustomFieldDefinition : "custom_field_definition_id"

3.3 Interface Impact (Before → After)

Area Before After
Status id, uuid, name, description, color, icon Add space_id, category (todo/in_progress/done)
Workflow id, uuid, name, description Add space_id
IssueType id, uuid, name, description, icon, color, workflow_id Rename to issue_type_definition; add space_id, rename workflow_id to default_workflow_id
Project id, uuid, name, description, owner_id, cms_node_id Add space_id, key, lead_id; deprecate owner_id in favor of lead_id
Issue id, uuid, title, description, project_id, status_id, parent_id Add issue_type_id, assignee_id, reporter_id, priority, rank, issue_number, issue_key, start_date, due_date, sprint_id, deleted_at

4. Implementation Details

4.1 Entity Specifications

Status (modified)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4() Existing
space_id UUID NOT NULL New. External Space reference
name VARCHAR(100) NOT NULL Existing
description TEXT Existing
color VARCHAR(7) Existing (UiMetadataMixin)
icon VARCHAR(50) Existing (UiMetadataMixin)
category status_category NOT NULL, DEFAULT 'todo' New. Enum: todo, in_progress, done
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() Existing
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() Existing

Index: (space_id, name) UNIQUE — status names unique within a Space.

Workflow (modified)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL Existing
space_id UUID NOT NULL New
name VARCHAR(100) NOT NULL Existing
description TEXT Existing
created_at TIMESTAMPTZ NOT NULL Existing
updated_at TIMESTAMPTZ NOT NULL Existing

Index: (space_id, name) UNIQUE.

WorkflowTransition (unchanged)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL Existing
workflow_id BIGINT FK → workflow(id), NOT NULL Existing
source_status_id BIGINT FK → status(id), NOT NULL Existing
target_status_id BIGINT FK → status(id), NOT NULL Existing
created_at TIMESTAMPTZ NOT NULL Existing
updated_at TIMESTAMPTZ NOT NULL Existing

Constraint: UNIQUE(workflow_id, source_status_id, target_status_id).

IssueTypeDefinition (renamed from IssueType, modified)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL Existing
space_id UUID NOT NULL New
name VARCHAR(100) NOT NULL Existing
description TEXT Existing
icon VARCHAR(50) Existing
color VARCHAR(7) Existing
default_workflow_id BIGINT FK → workflow(id) Renamed from workflow_id
created_at TIMESTAMPTZ NOT NULL Existing
updated_at TIMESTAMPTZ NOT NULL Existing

Index: (space_id, name) UNIQUE.

CustomFieldDefinition (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
space_id UUID NOT NULL
name VARCHAR(100) NOT NULL
field_type custom_field_type NOT NULL Enum: text, number, date, single_select, multi_select, user
options JSONB For select types: ["Option A", "Option B"]
is_required BOOLEAN NOT NULL, DEFAULT FALSE
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Index: (space_id, name) UNIQUE.

LinkTypeDefinition (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
space_id UUID NOT NULL
name VARCHAR(100) NOT NULL Internal name
outward_name VARCHAR(100) NOT NULL e.g., "blocks"
inward_name VARCHAR(100) NOT NULL e.g., "is blocked by"
is_symmetric BOOLEAN NOT NULL, DEFAULT FALSE If true, outward_name = inward_name
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Index: (space_id, name) UNIQUE.

Project (modified)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL Existing
space_id UUID NOT NULL New
key VARCHAR(10) UNIQUE, NOT NULL New. Uppercase, 2-10 chars, immutable
name VARCHAR(200) NOT NULL Existing
description TEXT Existing
lead_id UUID New. Replaces owner_id
owner_id UUID Existing (deprecated)
cms_node_id UUID Existing
created_at TIMESTAMPTZ NOT NULL Existing
updated_at TIMESTAMPTZ NOT NULL Existing

Constraint: CHECK (key ~ '^[A-Z][A-Z0-9]{1,9}$').

ProjectIssueType (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
project_id BIGINT FK → project(id), NOT NULL
issue_type_definition_id BIGINT FK → issue_type_definition(id), NOT NULL
workflow_id BIGINT FK → workflow(id) Optional override; if NULL, uses default_workflow_id
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Constraint: UNIQUE(project_id, issue_type_definition_id).

ProjectCustomField (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
project_id BIGINT FK → project(id), NOT NULL
custom_field_definition_id BIGINT FK → custom_field_definition(id), NOT NULL
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Constraint: UNIQUE(project_id, custom_field_definition_id).

ProjectLinkType (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
project_id BIGINT FK → project(id), NOT NULL
link_type_definition_id BIGINT FK → link_type_definition(id), NOT NULL
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Constraint: UNIQUE(project_id, link_type_definition_id).

TypeHierarchyRule (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
project_id BIGINT FK → project(id), NOT NULL
parent_type_id BIGINT FK → issue_type_definition(id), NOT NULL
child_type_id BIGINT FK → issue_type_definition(id), NOT NULL
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Constraint: UNIQUE(project_id, parent_type_id, child_type_id).

ProjectIssueSequence (new)

Column Type Constraints Notes
project_id BIGINT PK, FK → project(id) One row per project
next_number BIGINT NOT NULL, DEFAULT 1 Atomic counter

Issue (modified)

Column Type Constraints Notes
id BIGSERIAL PK Existing
uuid UUID UNIQUE, NOT NULL Existing
project_id BIGINT FK → project(id), NOT NULL Existing
issue_number BIGINT NOT NULL New. Per-project sequential
issue_key VARCHAR(21) UNIQUE, NOT NULL New. PROJ-123, stored, immutable
issue_type_id BIGINT FK → issue_type_definition(id), NOT NULL New
status_id BIGINT FK → status(id), NOT NULL Existing
parent_id BIGINT FK → issue(id) Existing
assignee_id UUID New. External user reference
reporter_id UUID NOT NULL New. External user reference
title VARCHAR(500) NOT NULL Existing
description TEXT Existing
priority issue_priority NOT NULL, DEFAULT 'medium' New. Enum: critical, high, medium, low, trivial
rank VARCHAR(255) NOT NULL New. LexoRank string
start_date DATE New. Auto-set on "in progress"
due_date DATE New
sprint_id BIGINT FK → sprint(id) New. NULL = backlog
deleted_at TIMESTAMPTZ New. Soft delete
created_at TIMESTAMPTZ NOT NULL Existing
updated_at TIMESTAMPTZ NOT NULL Existing

Indexes: - UNIQUE(project_id, issue_number) — uniqueness within project - (project_id, deleted_at) — filtered queries for active issues - (project_id, rank) — ordered listing - (assignee_id, deleted_at) — "my issues" queries - (sprint_id) — sprint board queries - (parent_id) — child issue lookups

CustomFieldValue (new)

Column Type Constraints Notes
id BIGSERIAL PK
uuid UUID UNIQUE, NOT NULL, DEFAULT uuid_generate_v4()
issue_id BIGINT FK → issue(id), NOT NULL
custom_field_definition_id BIGINT FK → custom_field_definition(id), NOT NULL
value_text TEXT For text and user types
value_number NUMERIC For number type
value_date DATE For date type
value_json JSONB For single_select, multi_select types
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW()

Constraint: UNIQUE(issue_id, custom_field_definition_id) — one value per field per issue.

4.2 Issue Key Generation

The issue key is a denormalized stored column combining the project key and a sequential issue number:

issue_key = project.key || '-' || issue_number

Example: PROJ-123, BUG-1, PLATFORM-4567.

Atomic Number Generation

-- Acquire advisory lock scoped to the project, auto-released at transaction end
SELECT pg_advisory_xact_lock(project_id);

-- Atomically increment and retrieve the next number
UPDATE issues.project_issue_sequence
SET next_number = next_number + 1
WHERE project_id = :project_id
RETURNING next_number - 1 AS issue_number;

The advisory lock prevents concurrent transactions from obtaining the same number. The lock is scoped to the transaction and released automatically on commit or rollback.

The issue_key is computed in the application layer and stored once at creation time. Neither issue_key nor issue_number may ever be updated.

4.3 Integration Points

  • loryth-domain: Issue extends BaseAggregate; child entities extend BaseEntity + AuditMixin
  • loryth-persistence: BaseRepository for all repositories; @transactional for service methods; column helpers (mapped_uuid(), mapped_enum(), mapped_jsonb(), mapped_primary_key())
  • loryth-common: DI registration via DiContainerBuilder; Page for pagination
  • loryth-fastapi: SuccessResponse wrapper; PageModel for paginated responses

4.4 Error Handling and Edge Cases

Scenario Behavior
Duplicate project key UNIQUE constraint violation → 409 Conflict
Invalid project key format CHECK constraint + application validation → 422
Parent-child type violation Application checks type_hierarchy_rule → 422
Cycle in parent hierarchy Walk ancestor chain, reject if self found → 422
Custom field type mismatch Application validates value column matches field type → 422
Required custom field missing Application checks on issue create/update → 422
Issue key collision Prevented by advisory lock + atomic increment
Concurrent issue creation Serialized per project via advisory lock

5. Semantics

Invariants

Invariant Enforcement
Issue key (PROJ-123) is immutable and never reused Set once at creation; no UPDATE; soft delete preserves key
Project key is immutable, unique, and uppercase UNIQUE + CHECK constraint; no UPDATE on key column
Issue number is sequential per project with no gaps Advisory lock + atomic increment
Parent-child types follow hierarchy rules Application check against type_hierarchy_rule on parent assignment
No cycles in parent-child hierarchy Application-level ancestry walk (reject if self found)
One status per issue at a time Single status_id FK column
Custom field values match field type Application validation; one value_* column populated
Soft-deleted issues excluded from normal queries Repository-level WHERE deleted_at IS NULL
Status names unique within a Space UNIQUE(space_id, name) constraint

Naming Conventions

  • Database tables use the issues schema (established in v00-setup.sql)
  • Column names use snake_case
  • Enum type names use snake_case (e.g., status_category, issue_priority, custom_field_type)
  • Foreign key columns end with _id
  • Timestamp columns use TIMESTAMPTZ

6. Examples

6.1 Creating a Project with Configuration

# Service layer
def create_project(cmd: CreateProjectCommand) -> ProjectDto:
    project = Project(
        space_id=cmd.space_id,
        key=cmd.key.upper(),
        name=cmd.name,
        description=cmd.description,
        lead_id=cmd.lead_id,
    )
    project = project_repo.save(project)

    # Initialize issue number sequence
    sequence = ProjectIssueSequence(project_id=project.id)
    sequence_repo.save(sequence)

    # Enable default issue types
    for type_def in type_def_repo.find_by_space(cmd.space_id):
        project_type = ProjectIssueType(
            project_id=project.id,
            issue_type_definition_id=type_def.id,
        )
        project_type_repo.save(project_type)

    return ProjectDto.map(project)

6.2 Creating an Issue with Key Generation

# Service layer
@transactional
def create_issue(cmd: CreateIssueCommand) -> IssueDto:
    project = project_repo.get_by_key(cmd.project_key)

    # Atomic issue number generation
    issue_number = sequence_repo.next_number(project.id)
    issue_key = f"{project.key}-{issue_number}"

    issue = Issue(
        project_id=project.id,
        issue_number=issue_number,
        issue_key=issue_key,
        issue_type_id=cmd.issue_type_id,
        status_id=cmd.initial_status_id,
        reporter_id=cmd.reporter_id,
        title=cmd.title,
        description=cmd.description,
        priority=cmd.priority or Priority.MEDIUM,
        rank=rank_service.generate_last_rank(project.id),
    )

    if cmd.parent_id:
        validate_hierarchy(project.id, cmd.parent_id, cmd.issue_type_id)
        validate_no_cycle(cmd.parent_id, issue)
        issue.parent_id = cmd.parent_id

    issue = issue_repo.save(issue)
    return IssueDto.map(issue)

7. Test Matrix

7.1 Unit Coverage

  • Project key validation (format, uniqueness)
  • Issue key generation (format, immutability)
  • Type hierarchy rule validation (allowed/disallowed combinations)
  • Cycle detection in parent-child relationships
  • Custom field type validation (correct value column populated)
  • Priority enum mapping

7.2 Integration Coverage

  • Full issue creation flow with key generation and advisory lock
  • Project configuration (enable/disable types, fields, link types)
  • Custom field value CRUD with type enforcement
  • Soft delete and restore round-trip
  • Concurrent issue creation produces sequential, gap-free numbers
  • Space-scoped catalog queries return only matching Space data

7.3 Regression Coverage

  • Existing issue CRUD endpoints continue to work after schema migration
  • Workflow transition validation unchanged
  • Event sourcing (outbox) continues to function

8. Acceptance Criteria

  • All existing migrations (v00–v05) continue to apply cleanly
  • New migrations create all specified tables and modify existing ones
  • Issue keys are generated atomically and are globally unique
  • Space-scoped catalog queries filter by space_id
  • Custom field values enforce type constraints
  • Parent-child hierarchy respects type rules and prevents cycles
  • Soft-deleted issues are excluded from standard queries
  • All new entities follow BaseAggregate/BaseEntity patterns

9. Rollout and Migration

9.1 Phased Plan

Phase 1a: Schema migration - Add space_id to existing tables (nullable initially for backfill) - Create new enum types (status_category, issue_priority, custom_field_type) - Create new tables (all configuration and custom field tables)

Phase 1b: Data backfill - Assign a default Space UUID to all existing records - Set status.category based on status names (heuristic) - Generate project keys from project names - Backfill issue_key and issue_number for existing issues - Make space_id NOT NULL after backfill

Phase 1c: Application updates - Update aggregates, repositories, services, and API schemas - Update DI configuration - Add new API endpoints for configuration management

9.2 Migration Strategy

New SQL migration files will be numbered sequentially from v06. Each migration is forward-only (no rollback DDL). Backfill operations run as part of the migration to ensure data consistency before NOT NULL constraints are applied.

10. Risks and Mitigations

Risk Mitigation
Advisory lock contention under high concurrency Locks are per-project and held briefly (single UPDATE); acceptable for expected load
Space UUID references may become orphaned Application-level validation on write; future cleanup job when core-platform service exists
Existing data lacks space_id Backfill migration assigns default Space UUID
Schema migration on large tables may lock Use ALTER TABLE ... ADD COLUMN ... DEFAULT (PostgreSQL 11+ is instant for defaults)
Custom field EAV queries may be slow Index on (issue_id, custom_field_definition_id) covers common access patterns

11. Progress

  • Write SQL migrations (v06–v08)
  • Update SQLAlchemy models
  • Update repositories
  • Update domain services
  • Update API schemas and routers
  • Update DI configuration
  • Write integration tests
  • Data backfill for existing records

12. Changelog

Date Change
2026-02-17 Initial version

13. Glossary

Term Definition
Space An organizational unit that scopes catalogs (statuses, workflows, types). Owned by a future core-platform service.
Catalog entity A reusable definition (Status, Workflow, IssueType, etc.) shared across projects within a Space.
Issue key A human-readable identifier (PROJ-123) combining the project key and sequential issue number. Immutable.
EAV Entity-Attribute-Value — a data model pattern for storing dynamic attributes with typed value columns.
LexoRank A lexicographic ranking system using string comparison for sort order.
Advisory lock A PostgreSQL application-level lock (pg_advisory_xact_lock) used to serialize access to shared resources.