feat(core): add data layer for sessions

Amolith and Crush created

Introduces event, goal, and task storage subsystems built on Badger:

- event: append-only log with typed payloads for state changes
- goal: CRUD operations for session goals with event tracking
- task: full lifecycle management with status indexing
- testutil: helpers for deterministic test clocks and databases
- timeutil: clock abstraction for testability

All packages include comprehensive test coverage.

Co-authored-by: Crush <crush@charm.land>

Change summary

internal/event/event.go       | 311 ++++++++++++++++++++++++++
internal/event/event_test.go  | 151 +++++++++++++
internal/event/payloads.go    | 144 ++++++++++++
internal/goal/goal.go         | 162 ++++++++++++++
internal/goal/goal_test.go    |  84 +++++++
internal/task/store.go        | 427 +++++++++++++++++++++++++++++++++++++
internal/task/store_test.go   | 211 ++++++++++++++++++
internal/task/task.go         | 104 +++++++++
internal/testutil/clock.go    |  31 ++
internal/testutil/db.go       |  31 ++
internal/timeutil/timeutil.go |  42 +++
11 files changed, 1,698 insertions(+)

Detailed changes

internal/event/event.go 🔗

@@ -0,0 +1,311 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package event
+
+import (
+	"context"
+	"encoding/binary"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+// ErrInvalidType indicates that an event type was not recognised.
+var ErrInvalidType = errors.New("event: invalid type")
+
+// ErrEmptyCommand indicates that a command string was not provided.
+var ErrEmptyCommand = errors.New("event: command is required")
+
+// Type enumerates known event kinds.
+type Type string
+
+const (
+	TypeGoalSet          Type = "goal_set"
+	TypeGoalUpdated      Type = "goal_updated"
+	TypeTaskAdded        Type = "task_added"
+	TypeTaskUpdated      Type = "task_updated"
+	TypeTaskStatusChange Type = "task_status_changed"
+)
+
+// Valid reports whether t is a recognised event type.
+func (t Type) Valid() bool {
+	switch t {
+	case TypeGoalSet,
+		TypeGoalUpdated,
+		TypeTaskAdded,
+		TypeTaskUpdated,
+		TypeTaskStatusChange:
+		return true
+	default:
+		return false
+	}
+}
+
+// Record represents a stored event entry.
+type Record struct {
+	Seq     uint64          `json:"seq"`
+	At      time.Time       `json:"at"`
+	Type    Type            `json:"type"`
+	Reason  *string         `json:"reason,omitempty"`
+	Command string          `json:"cmd"`
+	Payload json.RawMessage `json:"payload"`
+}
+
+// HasReason reports whether a reason was supplied.
+func (r Record) HasReason() bool {
+	return r.Reason != nil && *r.Reason != ""
+}
+
+// UnmarshalPayload decodes the payload into dst.
+func (r Record) UnmarshalPayload(dst any) error {
+	if len(r.Payload) == 0 {
+		return errors.New("event: payload is empty")
+	}
+	return json.Unmarshal(r.Payload, dst)
+}
+
+// AppendInput captures the data necessary to append an event.
+type AppendInput struct {
+	Type    Type
+	Command string
+	Reason  string
+	Payload any
+	At      time.Time
+}
+
+// ListOptions controls event listing.
+type ListOptions struct {
+	// After skips events with seq <= After.
+	After uint64
+	// Limit restricts the number of events returned. Zero or negative returns all.
+	Limit int
+}
+
+// Store provides high-level helpers for working with events.
+type Store struct {
+	db    *db.Database
+	clock timeutil.Clock
+}
+
+// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
+func NewStore(database *db.Database, clock timeutil.Clock) *Store {
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+	return &Store{
+		db:    database,
+		clock: clock,
+	}
+}
+
+// WithTxn exposes transactional helpers for use within db.Update.
+func (s *Store) WithTxn(txn *db.Txn) TxnStore {
+	return TxnStore{
+		txn:   txn,
+		clock: s.clock,
+	}
+}
+
+// Append records an event for sid.
+func (s *Store) Append(ctx context.Context, sid string, input AppendInput) (Record, error) {
+	var rec Record
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		var err error
+		rec, err = appendRecord(txn, s.clock, sid, input)
+		return err
+	})
+	return rec, err
+}
+
+// List returns events for sid subject to opts.
+func (s *Store) List(ctx context.Context, sid string, opts ListOptions) ([]Record, error) {
+	var records []Record
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		records, err = listRecords(txn, sid, opts)
+		return err
+	})
+	return records, err
+}
+
+// LatestSequence returns the latest event sequence for sid.
+func (s *Store) LatestSequence(ctx context.Context, sid string) (uint64, error) {
+	var seq uint64
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		seq, err = latestSequence(txn, sid)
+		return err
+	})
+	return seq, err
+}
+
+// TxnStore wraps a db transaction for event operations.
+type TxnStore struct {
+	txn   *db.Txn
+	clock timeutil.Clock
+}
+
+// Append records an event using the wrapped transaction.
+func (s TxnStore) Append(sid string, input AppendInput) (Record, error) {
+	return appendRecord(s.txn, s.clock, sid, input)
+}
+
+// List returns events for sid using opts.
+func (s TxnStore) List(sid string, opts ListOptions) ([]Record, error) {
+	return listRecords(s.txn, sid, opts)
+}
+
+// LatestSequence returns the most recent event sequence.
+func (s TxnStore) LatestSequence(sid string) (uint64, error) {
+	return latestSequence(s.txn, sid)
+}
+
+// Prefix returns the subscription prefix for events in sid.
+func Prefix(sid string) []byte {
+	return db.PrefixSessionEvents(sid)
+}
+
+// Key returns the event key for seq within sid.
+func Key(sid string, seq uint64) []byte {
+	return db.KeySessionEvent(sid, seq)
+}
+
+// SequenceCounterKey returns the key that stores the latest sequence for sid.
+func SequenceCounterKey(sid string) []byte {
+	return db.KeySessionEventSeq(sid)
+}
+
+var nullPayload = json.RawMessage("null")
+
+func appendRecord(txn *db.Txn, clock timeutil.Clock, sid string, input AppendInput) (Record, error) {
+	if txn == nil {
+		return Record{}, errors.New("event: transaction is nil")
+	}
+	if !input.Type.Valid() {
+		return Record{}, ErrInvalidType
+	}
+
+	cmd := strings.TrimSpace(input.Command)
+	if cmd == "" {
+		return Record{}, ErrEmptyCommand
+	}
+
+	at := input.At
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+	if at.IsZero() {
+		at = clock.Now()
+	}
+	at = timeutil.EnsureUTC(at)
+
+	payload, err := marshalPayload(input.Payload)
+	if err != nil {
+		return Record{}, err
+	}
+
+	seq, err := txn.IncrementUint64(db.KeySessionEventSeq(sid), 1)
+	if err != nil {
+		return Record{}, err
+	}
+
+	var reasonPtr *string
+	if trimmed := strings.TrimSpace(input.Reason); trimmed != "" {
+		reasonPtr = &trimmed
+	}
+
+	record := Record{
+		Seq:     seq,
+		At:      at,
+		Type:    input.Type,
+		Reason:  reasonPtr,
+		Command: cmd,
+		Payload: payload,
+	}
+
+	if err := txn.SetJSON(db.KeySessionEvent(sid, seq), record); err != nil {
+		return Record{}, err
+	}
+
+	return record, nil
+}
+
+func listRecords(txn *db.Txn, sid string, opts ListOptions) ([]Record, error) {
+	if txn == nil {
+		return nil, errors.New("event: transaction is nil")
+	}
+
+	var records []Record
+	err := txn.Iterate(db.IterateOptions{
+		Prefix:         db.PrefixSessionEvents(sid),
+		PrefetchValues: true,
+	}, func(item db.Item) error {
+		var rec Record
+		if err := item.ValueJSON(&rec); err != nil {
+			return err
+		}
+
+		if rec.Seq <= opts.After {
+			return nil
+		}
+
+		records = append(records, rec)
+		if opts.Limit > 0 && len(records) >= opts.Limit {
+			return db.ErrTxnAborted
+		}
+
+		return nil
+	})
+	if err != nil && !errors.Is(err, db.ErrTxnAborted) {
+		return nil, err
+	}
+
+	return records, nil
+}
+
+func latestSequence(txn *db.Txn, sid string) (uint64, error) {
+	if txn == nil {
+		return 0, errors.New("event: transaction is nil")
+	}
+
+	key := db.KeySessionEventSeq(sid)
+	exists, err := txn.Exists(key)
+	if err != nil {
+		return 0, err
+	}
+	if !exists {
+		return 0, nil
+	}
+
+	value, err := txn.Get(key)
+	if err != nil {
+		return 0, err
+	}
+
+	if len(value) != 8 {
+		return 0, fmt.Errorf("event: corrupt sequence counter for session %q", sid)
+	}
+
+	return binary.BigEndian.Uint64(value), nil
+}
+
+func marshalPayload(payload any) (json.RawMessage, error) {
+	if payload == nil {
+		return nullPayload, nil
+	}
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return nil, err
+	}
+	if len(data) == 0 {
+		return nullPayload, nil
+	}
+	return json.RawMessage(data), nil
+}

internal/event/event_test.go 🔗

@@ -0,0 +1,151 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package event_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"git.secluded.site/np/internal/event"
+	"git.secluded.site/np/internal/goal"
+	"git.secluded.site/np/internal/task"
+	"git.secluded.site/np/internal/testutil"
+)
+
+func TestStoreAppendAndList(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+
+	clock := &testutil.SequenceClock{
+		Times: []time.Time{
+			time.Date(2025, time.March, 3, 10, 15, 0, 0, time.FixedZone("A", 3600)),
+			time.Date(2025, time.March, 3, 10, 20, 0, 0, time.FixedZone("B", -3600)),
+		},
+	}
+
+	store := event.NewStore(db, clock)
+	sid := "sid-events"
+
+	goalDoc := goal.Document{
+		Title:       "Plan",
+		Description: "do something",
+		UpdatedAt:   time.Date(2025, time.March, 3, 9, 0, 0, 0, time.FixedZone("C", 7200)),
+	}
+
+	first, err := store.Append(ctx, sid, event.BuildGoalSet("np g s", " reason ", goalDoc))
+	if err != nil {
+		t.Fatalf("Append goal_set: %v", err)
+	}
+	if first.Seq != 1 {
+		t.Fatalf("Expected seq 1, got %d", first.Seq)
+	}
+	if !first.At.Equal(clock.Times[0].UTC()) {
+		t.Fatalf("At mismatch: want %v got %v", clock.Times[0].UTC(), first.At)
+	}
+	if !first.HasReason() {
+		t.Fatalf("Expected reason")
+	}
+	if want := "reason"; *first.Reason != want {
+		t.Fatalf("Reason mismatch: want %q got %q", want, *first.Reason)
+	}
+	var goalPayload event.GoalSetPayload
+	if err := first.UnmarshalPayload(&goalPayload); err != nil {
+		t.Fatalf("UnmarshalPayload goal_set: %v", err)
+	}
+	if want := event.GoalSnapshotFrom(goalDoc); goalPayload.Goal != want {
+		t.Fatalf("Goal payload mismatch: want %+v got %+v", want, goalPayload.Goal)
+	}
+
+	taskAdded := task.Task{
+		ID:          "abc123",
+		Title:       "Task",
+		Description: "finish work",
+		Status:      task.StatusPending,
+		CreatedAt:   time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC),
+		UpdatedAt:   time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC),
+		CreatedSeq:  1,
+	}
+
+	second, err := store.Append(ctx, sid, event.BuildTaskAdded("np t a", "", taskAdded))
+	if err != nil {
+		t.Fatalf("Append task_added: %v", err)
+	}
+	if second.Seq != 2 {
+		t.Fatalf("Expected seq 2, got %d", second.Seq)
+	}
+	if second.Reason != nil {
+		t.Fatalf("Expected nil reason")
+	}
+	if !second.At.Equal(clock.Times[1].UTC()) {
+		t.Fatalf("Second At mismatch: want %v got %v", clock.Times[1].UTC(), second.At)
+	}
+
+	var taskPayload event.TaskAddedPayload
+	if err := second.UnmarshalPayload(&taskPayload); err != nil {
+		t.Fatalf("UnmarshalPayload task_added: %v", err)
+	}
+	if taskPayload.Task != taskAdded {
+		t.Fatalf("Task payload mismatch: want %+v got %+v", taskAdded, taskPayload.Task)
+	}
+
+	all, err := store.List(ctx, sid, event.ListOptions{})
+	if err != nil {
+		t.Fatalf("List: %v", err)
+	}
+	if len(all) != 2 {
+		t.Fatalf("Expected 2 events, got %d", len(all))
+	}
+	if all[0].Seq != 1 || all[1].Seq != 2 {
+		t.Fatalf("Events not ordered: %+v", all)
+	}
+
+	after, err := store.List(ctx, sid, event.ListOptions{After: 1})
+	if err != nil {
+		t.Fatalf("List after=1: %v", err)
+	}
+	if len(after) != 1 || after[0].Seq != 2 {
+		t.Fatalf("After list mismatch: %+v", after)
+	}
+
+	limited, err := store.List(ctx, sid, event.ListOptions{Limit: 1})
+	if err != nil {
+		t.Fatalf("List limit=1: %v", err)
+	}
+	if len(limited) != 1 || limited[0].Seq != 1 {
+		t.Fatalf("Limited list mismatch: %+v", limited)
+	}
+
+	seq, err := store.LatestSequence(ctx, sid)
+	if err != nil {
+		t.Fatalf("LatestSequence: %v", err)
+	}
+	if seq != 2 {
+		t.Fatalf("LatestSequence mismatch: want 2 got %d", seq)
+	}
+
+	emptySeq, err := store.LatestSequence(ctx, "missing")
+	if err != nil {
+		t.Fatalf("LatestSequence missing: %v", err)
+	}
+	if emptySeq != 0 {
+		t.Fatalf("LatestSequence should be 0 for missing, got %d", emptySeq)
+	}
+}
+
+func TestAppendValidation(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+	store := event.NewStore(db, &testutil.SequenceClock{Times: []time.Time{time.Now()}})
+
+	if _, err := store.Append(ctx, "sid", event.AppendInput{Type: "unknown", Command: "cmd"}); !errors.Is(err, event.ErrInvalidType) {
+		t.Fatalf("expected ErrInvalidType, got %v", err)
+	}
+
+	if _, err := store.Append(ctx, "sid", event.AppendInput{Type: event.TypeGoalSet, Command: "   "}); !errors.Is(err, event.ErrEmptyCommand) {
+		t.Fatalf("expected ErrEmptyCommand, got %v", err)
+	}
+}

internal/event/payloads.go 🔗

@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package event
+
+import (
+	"time"
+
+	"git.secluded.site/np/internal/goal"
+	"git.secluded.site/np/internal/task"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+// GoalSnapshot captures relevant goal fields for event payloads.
+type GoalSnapshot struct {
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	UpdatedAt   time.Time `json:"updated_at"`
+}
+
+// GoalSetPayload records a goal being established.
+type GoalSetPayload struct {
+	Goal GoalSnapshot `json:"goal"`
+}
+
+// GoalUpdatedPayload records a goal changing.
+type GoalUpdatedPayload struct {
+	Before GoalSnapshot `json:"goal_before"`
+	After  GoalSnapshot `json:"goal_after"`
+}
+
+// TaskAddedPayload records a newly added task.
+type TaskAddedPayload struct {
+	Task task.Task `json:"task"`
+}
+
+// TaskStateSnapshot captures mutable task fields for updates.
+type TaskStateSnapshot struct {
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	UpdatedAt   time.Time `json:"updated_at"`
+}
+
+// TaskUpdatedPayload captures title/description edits.
+type TaskUpdatedPayload struct {
+	TaskID string            `json:"task_id"`
+	Before TaskStateSnapshot `json:"before"`
+	After  TaskStateSnapshot `json:"after"`
+}
+
+// TaskStatusChangedPayload captures status transitions.
+type TaskStatusChangedPayload struct {
+	TaskID       string      `json:"task_id"`
+	Title        string      `json:"title"`
+	StatusBefore task.Status `json:"status_before"`
+	StatusAfter  task.Status `json:"status_after"`
+	UpdatedAt    time.Time   `json:"updated_at"`
+}
+
+// GoalSnapshotFrom converts a goal document into an event snapshot.
+func GoalSnapshotFrom(doc goal.Document) GoalSnapshot {
+	return GoalSnapshot{
+		Title:       doc.Title,
+		Description: doc.Description,
+		UpdatedAt:   timeutil.EnsureUTC(doc.UpdatedAt),
+	}
+}
+
+// TaskStateSnapshotFrom converts a task into a mutable-field snapshot.
+func TaskStateSnapshotFrom(t task.Task) TaskStateSnapshot {
+	return TaskStateSnapshot{
+		Title:       t.Title,
+		Description: t.Description,
+		UpdatedAt:   timeutil.EnsureUTC(t.UpdatedAt),
+	}
+}
+
+// BuildGoalSet constructs AppendInput for goal_set events.
+func BuildGoalSet(command, reason string, doc goal.Document) AppendInput {
+	return AppendInput{
+		Type:    TypeGoalSet,
+		Command: command,
+		Reason:  reason,
+		Payload: GoalSetPayload{
+			Goal: GoalSnapshotFrom(doc),
+		},
+	}
+}
+
+// BuildGoalUpdated constructs AppendInput for goal_updated events.
+func BuildGoalUpdated(command, reason string, before, after goal.Document) AppendInput {
+	return AppendInput{
+		Type:    TypeGoalUpdated,
+		Command: command,
+		Reason:  reason,
+		Payload: GoalUpdatedPayload{
+			Before: GoalSnapshotFrom(before),
+			After:  GoalSnapshotFrom(after),
+		},
+	}
+}
+
+// BuildTaskAdded constructs AppendInput for task_added events.
+func BuildTaskAdded(command, reason string, t task.Task) AppendInput {
+	return AppendInput{
+		Type:    TypeTaskAdded,
+		Command: command,
+		Reason:  reason,
+		Payload: TaskAddedPayload{
+			Task: t,
+		},
+	}
+}
+
+// BuildTaskUpdated constructs AppendInput for task_updated events.
+func BuildTaskUpdated(command, reason, taskID string, before, after task.Task) AppendInput {
+	return AppendInput{
+		Type:    TypeTaskUpdated,
+		Command: command,
+		Reason:  reason,
+		Payload: TaskUpdatedPayload{
+			TaskID: taskID,
+			Before: TaskStateSnapshotFrom(before),
+			After:  TaskStateSnapshotFrom(after),
+		},
+	}
+}
+
+// BuildTaskStatusChanged constructs AppendInput for task_status_changed events.
+func BuildTaskStatusChanged(command, reason string, before task.Task, after task.Task) AppendInput {
+	return AppendInput{
+		Type:    TypeTaskStatusChange,
+		Command: command,
+		Reason:  reason,
+		Payload: TaskStatusChangedPayload{
+			TaskID:       after.ID,
+			Title:        after.Title,
+			StatusBefore: before.Status,
+			StatusAfter:  after.Status,
+			UpdatedAt:    timeutil.EnsureUTC(after.UpdatedAt),
+		},
+	}
+}

internal/goal/goal.go 🔗

@@ -0,0 +1,162 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package goal
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"time"
+
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+// ErrNotFound is returned when no goal exists for the requested session.
+var ErrNotFound = errors.New("goal: not found")
+
+// ErrEmptyTitle indicates that a goal title was not provided.
+var ErrEmptyTitle = errors.New("goal: title is required")
+
+// Document captures the session goal persisted in the database.
+type Document struct {
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	UpdatedAt   time.Time `json:"updated_at"`
+}
+
+// Store provides high-level helpers for working with session goals.
+type Store struct {
+	db    *db.Database
+	clock timeutil.Clock
+}
+
+// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
+func NewStore(database *db.Database, clock timeutil.Clock) *Store {
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+	return &Store{
+		db:    database,
+		clock: clock,
+	}
+}
+
+// WithTxn returns a transactional view of the store for use within db.Update.
+func (s *Store) WithTxn(txn *db.Txn) TxnStore {
+	return TxnStore{
+		txn:   txn,
+		clock: s.clock,
+	}
+}
+
+// Get loads the goal for sid using a read-only transaction.
+func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
+	var doc Document
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		doc, err = load(txn, sid)
+		return err
+	})
+	return doc, err
+}
+
+// Exists reports whether a goal has been set for sid.
+func (s *Store) Exists(ctx context.Context, sid string) (bool, error) {
+	var exists bool
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		exists, err = txn.Exists(db.KeySessionGoal(sid))
+		return err
+	})
+	return exists, err
+}
+
+// Set stores a goal for sid using a write transaction, returning the persisted
+// document.
+func (s *Store) Set(ctx context.Context, sid, title, description string) (Document, error) {
+	var doc Document
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		var err error
+		doc, err = save(txn, s.clock, sid, title, description)
+		return err
+	})
+	return doc, err
+}
+
+// TxnStore offers goal helpers scoped to an existing transaction.
+type TxnStore struct {
+	txn   *db.Txn
+	clock timeutil.Clock
+}
+
+// Get loads the goal for sid within the wrapped transaction.
+func (s TxnStore) Get(sid string) (Document, error) {
+	return load(s.txn, sid)
+}
+
+// Exists reports whether a goal has been set for sid within the wrapped txn.
+func (s TxnStore) Exists(sid string) (bool, error) {
+	if s.txn == nil {
+		return false, errors.New("goal: transaction is nil")
+	}
+	return s.txn.Exists(db.KeySessionGoal(sid))
+}
+
+// Key returns the Badger key storing the goal document. This is useful for
+// observers that subscribe to goal updates.
+func Key(sid string) []byte {
+	return db.KeySessionGoal(sid)
+}
+
+// Set writes the goal for sid inside the wrapped transaction.
+func (s TxnStore) Set(sid, title, description string) (Document, error) {
+	return save(s.txn, s.clock, sid, title, description)
+}
+
+func load(txn *db.Txn, sid string) (Document, error) {
+	if txn == nil {
+		return Document{}, errors.New("goal: transaction is nil")
+	}
+	key := db.KeySessionGoal(sid)
+	exists, err := txn.Exists(key)
+	if err != nil {
+		return Document{}, err
+	}
+	if !exists {
+		return Document{}, ErrNotFound
+	}
+
+	var doc Document
+	if err := txn.GetJSON(key, &doc); err != nil {
+		return Document{}, err
+	}
+	return doc, nil
+}
+
+func save(txn *db.Txn, clock timeutil.Clock, sid, title, description string) (Document, error) {
+	if txn == nil {
+		return Document{}, errors.New("goal: transaction is nil")
+	}
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+
+	cleanTitle := strings.TrimSpace(title)
+	if cleanTitle == "" {
+		return Document{}, ErrEmptyTitle
+	}
+
+	doc := Document{
+		Title:       cleanTitle,
+		Description: description,
+		UpdatedAt:   timeutil.EnsureUTC(clock.Now()),
+	}
+
+	if err := txn.SetJSON(db.KeySessionGoal(sid), doc); err != nil {
+		return Document{}, err
+	}
+	return doc, nil
+}

internal/goal/goal_test.go 🔗

@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package goal_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"git.secluded.site/np/internal/goal"
+	"git.secluded.site/np/internal/testutil"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+func TestStoreSetAndGet(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+
+	fixed := time.Date(2025, time.January, 12, 8, 30, 0, 0, time.FixedZone("TEST", 2*3600))
+	store := goal.NewStore(db, timeutil.FixedClock{Time: fixed})
+
+	doc, err := store.Set(ctx, "sid-1", "  My Goal  ", "describe")
+	if err != nil {
+		t.Fatalf("Set: %v", err)
+	}
+
+	if want, got := "My Goal", doc.Title; want != got {
+		t.Fatalf("Title mismatch: want %q got %q", want, got)
+	}
+	if want, got := "describe", doc.Description; want != got {
+		t.Fatalf("Description mismatch: want %q got %q", want, got)
+	}
+	if want, got := fixed.UTC(), doc.UpdatedAt; !want.Equal(got) {
+		t.Fatalf("UpdatedAt mismatch: want %v got %v", want, got)
+	}
+
+	loaded, err := store.Get(ctx, "sid-1")
+	if err != nil {
+		t.Fatalf("Get: %v", err)
+	}
+	if loaded != doc {
+		t.Fatalf("Loaded doc mismatch: want %+v got %+v", doc, loaded)
+	}
+
+	exists, err := store.Exists(ctx, "sid-1")
+	if err != nil {
+		t.Fatalf("Exists: %v", err)
+	}
+	if !exists {
+		t.Fatalf("Exists returned false")
+	}
+
+	exists, err = store.Exists(ctx, "sid-unknown")
+	if err != nil {
+		t.Fatalf("Exists unknown: %v", err)
+	}
+	if exists {
+		t.Fatalf("Exists should be false for unknown session")
+	}
+}
+
+func TestStoreGetMissing(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+	store := goal.NewStore(db, timeutil.FixedClock{Time: time.Now()})
+
+	_, err := store.Get(ctx, "missing")
+	if !errors.Is(err, goal.ErrNotFound) {
+		t.Fatalf("expected ErrNotFound, got %v", err)
+	}
+}
+
+func TestStoreSetRequiresTitle(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+	store := goal.NewStore(db, timeutil.FixedClock{Time: time.Now()})
+
+	if _, err := store.Set(ctx, "sid", "   ", "desc"); !errors.Is(err, goal.ErrEmptyTitle) {
+		t.Fatalf("expected ErrEmptyTitle, got %v", err)
+	}
+}

internal/task/store.go 🔗

@@ -0,0 +1,427 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"context"
+	"errors"
+	"sort"
+	"strings"
+
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+// CreateParams captures the data required to create a new task.
+type CreateParams struct {
+	ID          string
+	Title       string
+	Description string
+	Status      Status
+	CreatedSeq  uint64
+}
+
+// Mutator modifies a task prior to persistence.
+type Mutator func(*Task) error
+
+// Store coordinates task persistence and retrieval.
+type Store struct {
+	db    *db.Database
+	clock timeutil.Clock
+}
+
+// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
+func NewStore(database *db.Database, clock timeutil.Clock) *Store {
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+	return &Store{
+		db:    database,
+		clock: clock,
+	}
+}
+
+// WithTxn exposes transactional helpers for use within db.Update.
+func (s *Store) WithTxn(txn *db.Txn) TxnStore {
+	return TxnStore{
+		txn:   txn,
+		clock: s.clock,
+	}
+}
+
+// Create inserts a task into sid.
+func (s *Store) Create(ctx context.Context, sid string, params CreateParams) (Task, error) {
+	var out Task
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.Create(sid, params)
+		return err
+	})
+	return out, err
+}
+
+// Get retrieves a task by ID.
+func (s *Store) Get(ctx context.Context, sid, id string) (Task, error) {
+	var out Task
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.Get(sid, id)
+		return err
+	})
+	return out, err
+}
+
+// Update applies mutate to the stored task and persists the result.
+func (s *Store) Update(ctx context.Context, sid, id string, mutate Mutator) (Task, error) {
+	var out Task
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.Update(sid, id, mutate)
+		return err
+	})
+	return out, err
+}
+
+// UpdateStatus changes the status of a task.
+func (s *Store) UpdateStatus(ctx context.Context, sid, id string, status Status) (Task, error) {
+	var out Task
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.UpdateStatus(sid, id, status)
+		return err
+	})
+	return out, err
+}
+
+// Delete removes a task from storage.
+func (s *Store) Delete(ctx context.Context, sid, id string) error {
+	return s.db.Update(ctx, func(txn *db.Txn) error {
+		return TxnStore{txn: txn, clock: s.clock}.Delete(sid, id)
+	})
+}
+
+// List returns all tasks for sid sorted by creation order.
+func (s *Store) List(ctx context.Context, sid string) ([]Task, error) {
+	var out []Task
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.List(sid)
+		return err
+	})
+	return out, err
+}
+
+// ListByStatus returns tasks matching status for sid sorted by creation order.
+func (s *Store) ListByStatus(ctx context.Context, sid string, status Status) ([]Task, error) {
+	var out []Task
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		out, err = TxnStore{txn: txn, clock: s.clock}.ListByStatus(sid, status)
+		return err
+	})
+	return out, err
+}
+
+// Exists reports whether a task with id is stored for sid.
+func (s *Store) Exists(ctx context.Context, sid, id string) (bool, error) {
+	var exists bool
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		var err error
+		exists, err = TxnStore{txn: txn, clock: s.clock}.Exists(sid, id)
+		return err
+	})
+	return exists, err
+}
+
+// TxnStore wraps a db transaction for task operations.
+type TxnStore struct {
+	txn   *db.Txn
+	clock timeutil.Clock
+}
+
+// Create inserts a task into sid using params.
+func (s TxnStore) Create(sid string, params CreateParams) (Task, error) {
+	if s.txn == nil {
+		return Task{}, errors.New("task: transaction is nil")
+	}
+
+	title := strings.TrimSpace(params.Title)
+	if title == "" {
+		return Task{}, ErrEmptyTitle
+	}
+
+	status := params.Status
+	if status == "" {
+		status = StatusPending
+	}
+	if !status.Valid() {
+		return Task{}, ErrInvalidStatus
+	}
+
+	id := strings.TrimSpace(params.ID)
+	if id == "" {
+		id = GenerateID(sid, title, params.Description)
+	}
+
+	key := db.KeySessionTask(sid, id)
+	exists, err := s.txn.Exists(key)
+	if err != nil {
+		return Task{}, err
+	}
+	if exists {
+		return Task{}, ErrExists
+	}
+
+	now := timeutil.EnsureUTC(s.clock.Now())
+	task := Task{
+		ID:          id,
+		Title:       title,
+		Description: params.Description,
+		Status:      status,
+		CreatedAt:   now,
+		UpdatedAt:   now,
+		CreatedSeq:  params.CreatedSeq,
+	}
+
+	if err := s.txn.SetJSON(key, task); err != nil {
+		return Task{}, err
+	}
+	if err := addStatusIndex(s.txn, sid, status, id); err != nil {
+		return Task{}, err
+	}
+
+	return task, nil
+}
+
+// Get retrieves a task by ID from sid.
+func (s TxnStore) Get(sid, id string) (Task, error) {
+	if s.txn == nil {
+		return Task{}, errors.New("task: transaction is nil")
+	}
+	return loadTask(s.txn, sid, id)
+}
+
+// Update applies mutate to the task and persists changes.
+func (s TxnStore) Update(sid, id string, mutate Mutator) (Task, error) {
+	if s.txn == nil {
+		return Task{}, errors.New("task: transaction is nil")
+	}
+
+	current, err := loadTask(s.txn, sid, id)
+	if err != nil {
+		return Task{}, err
+	}
+
+	next := current
+	if mutate != nil {
+		if err := mutate(&next); err != nil {
+			return Task{}, err
+		}
+	}
+
+	next.ID = current.ID
+	next.CreatedAt = current.CreatedAt
+	next.CreatedSeq = current.CreatedSeq
+
+	next.Title = strings.TrimSpace(next.Title)
+	if next.Title == "" {
+		return Task{}, ErrEmptyTitle
+	}
+
+	if next.Status == "" {
+		next.Status = current.Status
+	}
+	if !next.Status.Valid() {
+		return Task{}, ErrInvalidStatus
+	}
+
+	next.UpdatedAt = timeutil.EnsureUTC(s.clock.Now())
+
+	key := db.KeySessionTask(sid, id)
+	if err := s.txn.SetJSON(key, next); err != nil {
+		return Task{}, err
+	}
+
+	if next.Status != current.Status {
+		if err := removeStatusIndex(s.txn, sid, current.Status, id); err != nil {
+			return Task{}, err
+		}
+	}
+
+	if err := addStatusIndex(s.txn, sid, next.Status, id); err != nil {
+		return Task{}, err
+	}
+
+	return next, nil
+}
+
+// UpdateStatus changes a task's status.
+func (s TxnStore) UpdateStatus(sid, id string, status Status) (Task, error) {
+	if !status.Valid() {
+		return Task{}, ErrInvalidStatus
+	}
+	return s.Update(sid, id, func(t *Task) error {
+		t.Status = status
+		return nil
+	})
+}
+
+// Delete removes a task by ID.
+func (s TxnStore) Delete(sid, id string) error {
+	if s.txn == nil {
+		return errors.New("task: transaction is nil")
+	}
+
+	task, err := loadTask(s.txn, sid, id)
+	if err != nil {
+		return err
+	}
+
+	if err := s.txn.Delete(db.KeySessionTask(sid, id)); err != nil {
+		return err
+	}
+
+	return removeStatusIndex(s.txn, sid, task.Status, id)
+}
+
+// List returns all tasks sorted by creation sequence.
+func (s TxnStore) List(sid string) ([]Task, error) {
+	if s.txn == nil {
+		return nil, errors.New("task: transaction is nil")
+	}
+
+	var tasks []Task
+	err := s.txn.Iterate(db.IterateOptions{
+		Prefix:         db.PrefixSessionTasks(sid),
+		PrefetchValues: true,
+	}, func(item db.Item) error {
+		var entry Task
+		if err := item.ValueJSON(&entry); err != nil {
+			return err
+		}
+		tasks = append(tasks, entry)
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	sortTasks(tasks)
+	return tasks, nil
+}
+
+// Exists reports whether a task with id exists.
+func (s TxnStore) Exists(sid, id string) (bool, error) {
+	if s.txn == nil {
+		return false, errors.New("task: transaction is nil")
+	}
+	return s.txn.Exists(db.KeySessionTask(sid, id))
+}
+
+// ListByStatus returns tasks filtered by status sorted by creation sequence.
+func (s TxnStore) ListByStatus(sid string, status Status) ([]Task, error) {
+	if s.txn == nil {
+		return nil, errors.New("task: transaction is nil")
+	}
+	if !status.Valid() {
+		return nil, ErrInvalidStatus
+	}
+
+	var ids []string
+	err := s.txn.Iterate(db.IterateOptions{
+		Prefix: db.PrefixSessionStatusIndex(sid, status.String()),
+	}, func(item db.Item) error {
+		ids = append(ids, lastKeySegment(item.KeyString()))
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	tasks := make([]Task, 0, len(ids))
+	for _, id := range ids {
+		task, err := loadTask(s.txn, sid, id)
+		if err != nil {
+			return nil, err
+		}
+		tasks = append(tasks, task)
+	}
+
+	sortTasks(tasks)
+	return tasks, nil
+}
+
+func loadTask(txn *db.Txn, sid, id string) (Task, error) {
+	key := db.KeySessionTask(sid, id)
+	exists, err := txn.Exists(key)
+	if err != nil {
+		return Task{}, err
+	}
+	if !exists {
+		return Task{}, ErrNotFound
+	}
+
+	var task Task
+	if err := txn.GetJSON(key, &task); err != nil {
+		return Task{}, err
+	}
+
+	if task.ID == "" {
+		task.ID = id
+	}
+	return task, nil
+}
+
+func addStatusIndex(txn *db.Txn, sid string, status Status, id string) error {
+	key := db.KeySessionTaskStatusIndex(sid, status.String(), id)
+	return txn.Set(key, []byte{})
+}
+
+func removeStatusIndex(txn *db.Txn, sid string, status Status, id string) error {
+	key := db.KeySessionTaskStatusIndex(sid, status.String(), id)
+	exists, err := txn.Exists(key)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		return nil
+	}
+	return txn.Delete(key)
+}
+
+func sortTasks(tasks []Task) {
+	sort.Slice(tasks, func(i, j int) bool {
+		if tasks[i].CreatedSeq != tasks[j].CreatedSeq {
+			return tasks[i].CreatedSeq < tasks[j].CreatedSeq
+		}
+		if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) {
+			return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
+		}
+		return tasks[i].ID < tasks[j].ID
+	})
+}
+
+func lastKeySegment(key string) string {
+	idx := strings.LastIndex(key, "/")
+	if idx == -1 || idx == len(key)-1 {
+		return key
+	}
+	return key[idx+1:]
+}
+
+// Key returns the storage key for a task ID within sid.
+func Key(sid, id string) []byte {
+	return db.KeySessionTask(sid, id)
+}
+
+// Prefix returns the prefix for all task documents in sid.
+func Prefix(sid string) []byte {
+	return db.PrefixSessionTasks(sid)
+}
+
+// StatusPrefix returns the prefix for tasks matching status within sid.
+func StatusPrefix(sid string, status Status) []byte {
+	return db.PrefixSessionStatusIndex(sid, status.String())
+}

internal/task/store_test.go 🔗

@@ -0,0 +1,211 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"git.secluded.site/np/internal/task"
+	"git.secluded.site/np/internal/testutil"
+)
+
+func TestStoreCreateAndList(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+
+	clock := &testutil.SequenceClock{
+		Times: []time.Time{
+			time.Date(2025, time.January, 1, 9, 0, 0, 0, time.FixedZone("A", 3600)),
+			time.Date(2025, time.January, 1, 9, 5, 0, 0, time.FixedZone("B", -3600)),
+		},
+	}
+
+	store := task.NewStore(db, clock)
+	sid := "sid-create"
+
+	first, err := store.Create(ctx, sid, task.CreateParams{
+		Title:       " First Task ",
+		Description: "first description",
+		CreatedSeq:  2,
+	})
+	if err != nil {
+		t.Fatalf("Create first: %v", err)
+	}
+
+	if first.Title != "First Task" {
+		t.Fatalf("Title not trimmed: %q", first.Title)
+	}
+	if first.Status != task.StatusPending {
+		t.Fatalf("Expected default status pending, got %s", first.Status)
+	}
+	if want := clock.Times[0].UTC(); !first.CreatedAt.Equal(want) || !first.UpdatedAt.Equal(want) {
+		t.Fatalf("Expected timestamps %v, got created %v updated %v", want, first.CreatedAt, first.UpdatedAt)
+	}
+	if want := task.GenerateID(sid, "First Task", "first description"); first.ID != want {
+		t.Fatalf("GenerateID mismatch: want %q got %q", want, first.ID)
+	}
+
+	second, err := store.Create(ctx, sid, task.CreateParams{
+		Title:       "Second Task",
+		Description: "second description",
+		CreatedSeq:  1,
+		Status:      task.StatusInProgress,
+	})
+	if err != nil {
+		t.Fatalf("Create second: %v", err)
+	}
+	if second.Status != task.StatusInProgress {
+		t.Fatalf("Explicit status not applied")
+	}
+	if want := clock.Times[1].UTC(); !second.CreatedAt.Equal(want) {
+		t.Fatalf("Second CreatedAt mismatch: want %v got %v", want, second.CreatedAt)
+	}
+
+	all, err := store.List(ctx, sid)
+	if err != nil {
+		t.Fatalf("List: %v", err)
+	}
+	if len(all) != 2 {
+		t.Fatalf("Expected 2 tasks, got %d", len(all))
+	}
+	if all[0].ID != second.ID || all[1].ID != first.ID {
+		t.Fatalf("Tasks not sorted by CreatedSeq: %+v", all)
+	}
+
+	pending, err := store.ListByStatus(ctx, sid, task.StatusPending)
+	if err != nil {
+		t.Fatalf("ListByStatus pending: %v", err)
+	}
+	if len(pending) != 1 || pending[0].ID != first.ID {
+		t.Fatalf("Pending list mismatch: %+v", pending)
+	}
+
+	byProgress, err := store.ListByStatus(ctx, sid, task.StatusInProgress)
+	if err != nil {
+		t.Fatalf("ListByStatus in_progress: %v", err)
+	}
+	if len(byProgress) != 1 || byProgress[0].ID != second.ID {
+		t.Fatalf("In-progress list mismatch: %+v", byProgress)
+	}
+
+	exists, err := store.Exists(ctx, sid, first.ID)
+	if err != nil {
+		t.Fatalf("Exists: %v", err)
+	}
+	if !exists {
+		t.Fatalf("Expected first task to exist")
+	}
+	exists, err = store.Exists(ctx, sid, "missing")
+	if err != nil {
+		t.Fatalf("Exists missing: %v", err)
+	}
+	if exists {
+		t.Fatalf("Expected missing task to be false")
+	}
+}
+
+func TestStoreUpdateAndStatusIndex(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+
+	clock := &testutil.SequenceClock{
+		Times: []time.Time{
+			time.Date(2025, time.February, 2, 11, 0, 0, 0, time.FixedZone("A", 7200)),
+			time.Date(2025, time.February, 2, 12, 0, 0, 0, time.FixedZone("B", -3600)),
+			time.Date(2025, time.February, 2, 13, 0, 0, 0, time.FixedZone("C", 5400)),
+		},
+	}
+
+	store := task.NewStore(db, clock)
+	sid := "sid-update"
+
+	created, err := store.Create(ctx, sid, task.CreateParams{
+		Title:       "Initial",
+		Description: "Old",
+		CreatedSeq:  1,
+	})
+	if err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+
+	updated, err := store.Update(ctx, sid, created.ID, func(tk *task.Task) error {
+		tk.Title = " Updated Title "
+		tk.Description = "New"
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+	if updated.Title != "Updated Title" {
+		t.Fatalf("Title not trimmed: %q", updated.Title)
+	}
+	if updated.Description != "New" {
+		t.Fatalf("Description not updated")
+	}
+	if !updated.UpdatedAt.Equal(clock.Times[1].UTC()) {
+		t.Fatalf("UpdatedAt mismatch: want %v got %v", clock.Times[1].UTC(), updated.UpdatedAt)
+	}
+
+	changed, err := store.UpdateStatus(ctx, sid, created.ID, task.StatusCompleted)
+	if err != nil {
+		t.Fatalf("UpdateStatus: %v", err)
+	}
+	if changed.Status != task.StatusCompleted {
+		t.Fatalf("Status not updated")
+	}
+	if !changed.UpdatedAt.Equal(clock.Times[2].UTC()) {
+		t.Fatalf("Status UpdatedAt mismatch: want %v got %v", clock.Times[2].UTC(), changed.UpdatedAt)
+	}
+
+	pending, err := store.ListByStatus(ctx, sid, task.StatusPending)
+	if err != nil {
+		t.Fatalf("ListByStatus pending: %v", err)
+	}
+	if len(pending) != 0 {
+		t.Fatalf("Pending should be empty: %+v", pending)
+	}
+
+	completed, err := store.ListByStatus(ctx, sid, task.StatusCompleted)
+	if err != nil {
+		t.Fatalf("ListByStatus completed: %v", err)
+	}
+	if len(completed) != 1 || completed[0].ID != created.ID {
+		t.Fatalf("Completed list mismatch: %+v", completed)
+	}
+}
+
+func TestStoreValidation(t *testing.T) {
+	ctx := context.Background()
+	db := testutil.OpenDB(t)
+	store := task.NewStore(db, &testutil.SequenceClock{
+		Times: []time.Time{time.Now(), time.Now()},
+	})
+
+	sid := "sid-validate"
+
+	created, err := store.Create(ctx, sid, task.CreateParams{
+		Title:       "Valid",
+		Description: "desc",
+		CreatedSeq:  1,
+	})
+	if err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+
+	_, err = store.Update(ctx, sid, created.ID, func(tk *task.Task) error {
+		tk.Title = "   "
+		return nil
+	})
+	if !errors.Is(err, task.ErrEmptyTitle) {
+		t.Fatalf("Expected ErrEmptyTitle, got %v", err)
+	}
+
+	if _, err = store.UpdateStatus(ctx, sid, created.ID, task.Status("bogus")); !errors.Is(err, task.ErrInvalidStatus) {
+		t.Fatalf("Expected ErrInvalidStatus, got %v", err)
+	}
+}

internal/task/task.go 🔗

@@ -0,0 +1,104 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"encoding/hex"
+	"errors"
+	"strings"
+	"time"
+
+	"github.com/zeebo/blake3"
+)
+
+// ErrNotFound indicates that a task with the given ID is absent.
+var ErrNotFound = errors.New("task: not found")
+
+// ErrExists indicates that a task already exists when attempting to create it.
+var ErrExists = errors.New("task: already exists")
+
+// ErrEmptyTitle indicates that a title was missing when required.
+var ErrEmptyTitle = errors.New("task: title is required")
+
+// ErrInvalidStatus is returned when a status string is not recognised.
+var ErrInvalidStatus = errors.New("task: invalid status")
+
+// Status captures the lifecycle of a task.
+type Status string
+
+const (
+	StatusPending    Status = "pending"
+	StatusInProgress Status = "in_progress"
+	StatusCompleted  Status = "completed"
+	StatusFailed     Status = "failed"
+	StatusCancelled  Status = "cancelled"
+)
+
+// AllStatuses returns the known task statuses.
+func AllStatuses() []Status {
+	return []Status{
+		StatusPending,
+		StatusInProgress,
+		StatusCompleted,
+		StatusFailed,
+		StatusCancelled,
+	}
+}
+
+// Valid reports whether s matches a known status.
+func (s Status) Valid() bool {
+	switch s {
+	case StatusPending,
+		StatusInProgress,
+		StatusCompleted,
+		StatusFailed,
+		StatusCancelled:
+		return true
+	default:
+		return false
+	}
+}
+
+func (s Status) String() string {
+	return string(s)
+}
+
+// ParseStatus converts a string to a Status value.
+func ParseStatus(v string) (Status, error) {
+	status := Status(v)
+	if !status.Valid() {
+		return "", ErrInvalidStatus
+	}
+	return status, nil
+}
+
+// Task represents an individual unit of work tracked within a session.
+type Task struct {
+	ID          string    `json:"id"`
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	Status      Status    `json:"status"`
+	CreatedAt   time.Time `json:"created_at"`
+	UpdatedAt   time.Time `json:"updated_at"`
+	CreatedSeq  uint64    `json:"created_seq"`
+}
+
+// GenerateID deterministically produces a task identifier for the given
+// session, title, and description.
+func GenerateID(sessionID, title, description string) string {
+	normalized := normalizeForHash(title) + "|" + normalizeForHash(description) + "|" + sessionID
+
+	sum := blake3.Sum256([]byte(normalized))
+	hexed := hex.EncodeToString(sum[:])
+	return hexed[:6]
+}
+
+func normalizeForHash(s string) string {
+	trimmed := strings.TrimSpace(s)
+	if trimmed == "" {
+		return ""
+	}
+	return strings.Join(strings.Fields(trimmed), " ")
+}

internal/testutil/clock.go 🔗

@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package testutil
+
+import (
+	"time"
+)
+
+// SequenceClock returns the supplied times in order, repeating the final entry
+// when no more values remain.
+type SequenceClock struct {
+	Times []time.Time
+	index int
+}
+
+// Now implements timeutil.Clock semantics.
+func (c *SequenceClock) Now() time.Time {
+	if len(c.Times) == 0 {
+		return time.Now().UTC()
+	}
+	if c.index >= len(c.Times) {
+		return c.Times[len(c.Times)-1]
+	}
+	t := c.Times[c.index]
+	if c.index < len(c.Times)-1 {
+		c.index++
+	}
+	return t
+}

internal/testutil/db.go 🔗

@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package testutil
+
+import (
+	"testing"
+
+	"git.secluded.site/np/internal/db"
+)
+
+// OpenDB opens a database in a temporary directory and registers cleanup with t.
+func OpenDB(t *testing.T) *db.Database {
+	t.Helper()
+
+	database, err := db.Open(db.Options{
+		Path: t.TempDir(),
+	})
+	if err != nil {
+		t.Fatalf("open db: %v", err)
+	}
+
+	t.Cleanup(func() {
+		if err := database.Close(); err != nil {
+			t.Fatalf("closing db: %v", err)
+		}
+	})
+
+	return database
+}

internal/timeutil/timeutil.go 🔗

@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package timeutil
+
+import "time"
+
+// Clock abstracts time retrieval so callers can substitute deterministic values
+// during testing.
+type Clock interface {
+	Now() time.Time
+}
+
+// UTCClock returns the system time in UTC.
+type UTCClock struct{}
+
+// Now implements Clock.
+func (UTCClock) Now() time.Time {
+	return time.Now().UTC()
+}
+
+// FixedClock always reports the same instant.
+type FixedClock struct {
+	Time time.Time
+}
+
+// Now implements Clock.
+func (f FixedClock) Now() time.Time {
+	if f.Time.IsZero() {
+		return time.Time{}
+	}
+	return f.Time
+}
+
+// EnsureUTC normalises t to UTC when it has a location set.
+func EnsureUTC(t time.Time) time.Time {
+	if t.IsZero() {
+		return t
+	}
+	return t.UTC()
+}