---
name: "Adr 0012 Task Tracking"
description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node"
depth: 2
links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"]
---

# ADR 0012: native task tracking as an event-sourced aggregate

**status:** accepted
**date:** 23.06.2026 (June)
**deciders:** correx design team

---

## context

Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity,
agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and
session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent
that outlives any single session and *references* the artifacts and sessions that act on it.

An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth**
with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical
choices are **rejected** here: they contradict correx's foundational invariants —
"the event log is the only source of truth; projections are disposable and rebuilt from
events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth
would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal*
(native tasks) and realise it with correx's actual architecture.

This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph
edge model. REST/CLI/agent-context-bundle/search are deliberately later phases.

## decision

**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions`
aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new
`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM
outputs; a task is durable and cross-session.

### 1. event log is the source of truth
Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable
projection* (export), never the master copy.

### 2. per-project task stream
The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both
`TypeId`, all task events for a project live in one stream keyed `tasks:<projectId>`
(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map<TaskId,
TaskState>`). The `tasks:` prefix keeps these streams distinguishable from real agent
sessions; when task streams are later wired into the live server, session-listing consumers
filter the prefix.

### 3. lifecycle as facts; status is derived
Mirroring sessions (whose events never carry `SessionStatus`), task events are facts
(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives
`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A
`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized
`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`.

### 4. status workflow
`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to
IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal
transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does).

### 5. work-graph edges
`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`):
`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id
string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph
backbone the agent-context bundle (later phase) will traverse.

### 6. human-friendly ids
`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the
numeric suffix of a key (`<prefix>-<n>`, e.g. `auth-142`) is the count of *created* tasks, so
ids never collide even after cancellation. The creating caller composes the key and stores it
in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key`
field land now.)

## consequences

**positive:**

* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections);
  no new persistence model, no second source of truth
* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later
  "everything touching X" queries and the agent-context bundle
* deterministic, replayable task state; status derivation is unit-testable in isolation

**negative:**

* overloading the `sessionId`-partitioned stream key with `tasks:<projectId>` means
  session-enumerating consumers must learn to filter the prefix once tasks go live
  (acceptable; isolated to the live-wiring phase)
* per-project (not per-task) stream means rebuilding one task replays the project's task
  events — fine at expected volumes; revisit with snapshots if a project's task log grows large

## alternatives considered

* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs;
  tasks are durable, cross-session, and carry their own lifecycle/claiming.
* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected —
  violates ADR-0001/0006; creates two masters and loses replay/idempotency.
* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation
  is a separate hard problem; v1 offers `claim` (pull), not `assign` (push).
* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with
  one stream per task and makes board/list queries fan out across many streams; the
  per-project stream is the better default for a tracker that lists/boards constantly.

## status

Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface,
the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are
designed (those phases build on, and must not contradict, this ADR).
