Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The
event log stays the only source of truth; the board is a projection, and all of
a project's task events live in one stream (tasks:<projectId>).
core:
- new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/
BoardProjector/CounterProjection/Repository + TaskService write path that
appends events via the existing EventStore (no new storage)
- task events in core:events (sealed marker TaskEvent), registered in
Serialization.kt; status is derived by the reducer from lifecycle facts
- work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle
resolution dispatches on the recorded kind instead of guessing from the id
- delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome
surfaces:
- agent tools (Tier T2 mutators, T1 read): task_create / task_update /
task_delete / task_context, registered in both the main and per-workspace
tool registries
- REST GET /tasks/{id}/context for external agents
context bundle (TaskContextAssembler):
- task fields + acceptance criteria + relevant files + resolved dependency
tasks (live status) + raw related links + notes, with a compact render()
- semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing
L3 retriever (late-bound holder for composition-root ordering)
- ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter
(docs/decisions/adr-*.md, padding-agnostic; *.md paths)
Unit-verified across core:tasks / infrastructure:tools / apps:server; full
gradlew check green. Not yet live-QA'd end-to-end against a running server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.9 KiB
name, description, depth, links
| name | description | depth | links | ||||
|---|---|---|---|---|---|---|---|
| Adr 0012 Task Tracking | Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node | 2 |
|
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 TaskCreatedEvents 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 withtasks:<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/nextassignment scheduler (the pitch): dropped — scheduling/prioritisation is a separate hard problem; v1 offersclaim(pull), notassign(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).