Skip to content

Implementation Plan: Document Approval Workflow Engine

Branch: 001-document-approval-engine | Date: 2026-06-09 | Spec: spec.md

Input: Feature specification from /specs/001-document-approval-engine/spec.md

Summary

Deliver the document-approval use case end to end on a data-defined, human-centric workflow engine. A flow is described as data (states, transitions, candidate groups, initiator group); Temporal durably orchestrates each running flow; PostgreSQL is the system of record for human-task state, business data, and an append-only audit log. People interact through a Ktor REST API (submit, claim, release, decide, query); meaningful state changes are published as CloudEvents through a transactional outbox. The codebase is a Kotlin/JVM Gradle multi-module monorepo built strictly to hexagonal architecture: domain and application are framework-free and reach infrastructure only through ports, with Temporal, Postgres/jOOQ, Ktor, and CloudEvents living in adapters. Two deployables share the inner modules: an api-service (REST + Temporal client signals + outbox publisher) and a worker-service (Temporal workflows + activities).

Technical Context

Language/Version: Kotlin 2.0+ on JDK 21 (LTS)

Primary Dependencies: - Ktor (HTTP server, REST API) — Spring Boot prohibited per constitution - Temporal Java SDK (durable orchestration), used from Kotlin - jOOQ (type-safe SQL) + PostgreSQL JDBC driver - Flyway (schema migrations; jOOQ codegen runs off the migrated schema) - CloudEvents Java SDK (event envelope) - kotlinx.serialization (REST DTO + event payload JSON) - Koin (lightweight DI, app/adapter composition roots only; inner layers stay DI-free) - Gradle (Kotlin DSL), version catalog (gradle/libs.versions.toml), convention plugins in build-logic/

Storage: PostgreSQL (current state in normalized tables + append-only audit log + transactional outbox)

Testing: JUnit 5 + kotlin.test; Testcontainers (PostgreSQL); Temporal TestWorkflowEnvironment/Testcontainers for orchestration; fast framework-free unit tests for domain/application

Target Platform: Linux server / containers (two JVM deployables); external Temporal cluster (self-hosted or Temporal Cloud) and PostgreSQL

Project Type: Backend — Gradle multi-module monorepo, two deployable services sharing inner modules

Performance Goals: ~1,000 active flows and ~100 concurrent reviewers; work-list and flow-status queries < 1s p95 (SC-008)

Constraints: - Inward-only dependencies; domain/application reference no framework, SQL, or Temporal types (Principle I) - Exactly one effective decision per task under concurrency (FR-013, SC-004) - State and emitted events atomically consistent via transactional outbox (Principle V, FR-020) - Every state change produces an immutable audit record (Principle III, FR-014) - Internal flow progression via Temporal signals/activities only — no broker driving steps (Principle IV)

Scale/Scope: First deliverable = the document-approval flow only (US1–US5). Steps, admin operations (FR-022–026), external IdP, and a web frontend are explicitly out of scope.

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

# Principle Gate Status
I Hexagonal Architecture (NON-NEGOTIABLE) domain/application Gradle modules declare no dependency on Ktor, Temporal, jOOQ, Koin, or JDBC; all I/O via ports; apps are composition roots only ✅ PASS — enforced by module graph (see Project Structure)
II Test-First (NON-NEGOTIABLE) TDD red→green→refactor; unit tests for inner layers; Testcontainers integration tests for every adapter & contract; explicit concurrency/audit/outbox tests ✅ PASS — test strategy defined; /speckit-tasks will emit test-first tasks (overrides template "tests optional")
III Auditability & Traceability Append-only audit_entry table; every command writes an audit record in the same transaction; flow lifecycle reconstructable from history ✅ PASS — see data-model.md
IV Orchestration Behind a Port WorkflowEngine port in domain; Temporal only in adapters/temporal; progression via signals/activities; CloudEvents/broker for external integration only ✅ PASS — see research.md
V Explicit Contracts & Consistency Transactional outbox (state + outbox row in one tx); single-effective-decision via DB optimistic concurrency; CloudEvents envelope; intention-revealing ports ✅ PASS — see data-model.md & contracts/

Technology constraints (constitution §Technology & Architecture): Kotlin/JVM ✅, Ktor for REST (not Spring) ✅, Temporal via port ✅, Postgres + jOOQ ✅, CloudEvents ✅, candidate-group/claim-to-act ✅, shared-module multi-deployable topology ✅.

Result: PASS — no violations. Complexity Tracking left empty.

Project Structure

Documentation (this feature)

specs/001-document-approval-engine/
├── plan.md              # This file
├── research.md          # Phase 0 output
├── data-model.md        # Phase 1 output
├── quickstart.md        # Phase 1 output
├── contracts/           # Phase 1 output (openapi.yaml, events.md)
├── checklists/
│   └── requirements.md  # Spec quality checklist
└── tasks.md             # Phase 2 output (/speckit-tasks — not created here)

Source Code (repository root)

wrkflw/
├── settings.gradle.kts                # includes all modules
├── build.gradle.kts                   # root
├── gradle/libs.versions.toml          # version catalog (single source of versions)
├── build-logic/                       # Gradle convention plugins (kotlin, test, jooq, ktor)
│
├── domain/                            # PURE Kotlin — no framework/SQL/Temporal/DI deps
│   └── src/{main,test}/kotlin/dev/wrkflw/domain/
│       ├── flow/                      # FlowDefinition, State, Transition, FlowInstance, FlowStatus
│       ├── task/                      # Task, TaskStatus, Decision, Outcome, Claim
│       ├── identity/                  # ActorId, GroupId, candidate-group membership rules
│       ├── audit/                     # AuditEntry, AuditEventType
│       ├── event/                     # DomainEvent hierarchy (FlowStarted, TaskCreated, ...)
│       └── port/                      # FlowDefinitionRepository, FlowInstanceRepository,
│                                      #   TaskRepository, AuditLog, WorkflowEngine,
│                                      #   DomainEventPublisher, Clock, ActorContext
│
├── application/                       # Use cases — depends only on domain
│   └── src/{main,test}/kotlin/dev/wrkflw/application/
│       ├── command/                   # SubmitDocument, ClaimTask, ReleaseTask, SubmitDecision
│       ├── query/                     # GroupWorkList, MyTasks, FlowStatus
│       ├── service/                   # application services (transaction boundary, orchestrate ports)
│       └── port/                      # TransactionRunner (UnitOfWork) port if needed
│
├── adapters/
│   ├── persistence-postgres/          # jOOQ repos, AuditLog, Outbox; Flyway migrations; jOOQ-generated code
│   │   └── src/main/resources/db/migration/
│   ├── temporal/                      # WorkflowEngine impl (Temporal client), DocumentApprovalWorkflow,
│   │                                  #   activities that call application services, signal sender
│   ├── rest-api/                      # Ktor routes, request/response DTOs, mappers, ActorContext from auth
│   └── eventing-cloudevents/          # outbox poller → CloudEvents publisher (DomainEventPublisher impl)
│
└── apps/
    ├── api-service/                   # Ktor server main + Koin wiring + outbox publisher runner
    └── worker-service/                # Temporal worker host main + Koin wiring

Structure Decision: Gradle multi-module monorepo. The dependency graph mechanically enforces Principle I: domain has zero infrastructure dependencies; application depends only on domain; each adapter depends on application/domain and its own infrastructure library; the two apps in apps/ are the only modules that depend on adapters and wire them to ports. Both apps reuse domain + application + persistence-postgres; they differ only in active driving adapters (rest-api + eventing-cloudevents + Temporal client for api-service; temporal worker host for worker-service). A build check (e.g., a convention-plugin dependency rule or ArchUnit/Konsist test) will fail the build if domain/application gain a forbidden dependency.

Complexity Tracking

No constitution violations. Section intentionally empty.