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-issuesservice 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
lorythPostgreSQL database is used (notiam) - Existing migrations v00–v05 are preserved; new migrations extend the schema
space_idreferences 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/BaseEntitypatterns fromloryth-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 extendBaseEntity+AuditMixin - loryth-persistence:
BaseRepositoryfor all repositories;@transactionalfor service methods; column helpers (mapped_uuid(),mapped_enum(),mapped_jsonb(),mapped_primary_key()) - loryth-common: DI registration via
DiContainerBuilder;Pagefor pagination - loryth-fastapi:
SuccessResponsewrapper;PageModelfor 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
issuesschema (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/BaseEntitypatterns
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. |