ADR-004: Goals and Tasks — Mainark's domain model

Date: 2026-10-10
Status: Accepted — phase 2 implemented (see Migration strategy); amended during phase 2 (## Mainark plan is not evidence of a Goal, I4 enforcement, I8 deferred)
Amends: ADR-003 (incident → Task link, "merged" vs "done", incident ids), dual task graphs (work_status=done only after merge)
Amended by: ADR-005 (2026-10-10) — one Postgres database with product_id replaces one database per Product; Postgres storage names (work_items); enforcement of I2–I5, I8–I10, I12; products, product_id and the dependency table move into the Postgres migration; phases 3–5 start after ADR-005's phase E. Goal/Task vocabulary, semantics and the invariants themselves are unchanged.

Decision

Work is a tree of Goals. Every leaf that an agent can execute is a Task.

Product
└── Goal
    ├── Goal
    │   ├── Task
    │   └── Task
    └── Task

Mainark stops modelling work as epics, issues, features or stories. There are two kinds of work item, and only two:

  • a Goal is an outcome. It contains Goals and Tasks. It is never executed.
  • a Task is an executable leaf. It has a plan, Runs and PRs. It never has children.

A Goal is a directory; a Task is a file. A Goal with no children is still a Goal — one awaiting decomposition. It never becomes a Task by being empty.

Fulreach                                          Product
└── Agent chat                                    Goal
    ├── Analytical capabilities                   Goal
    │   ├── Add conversation query tool           Task
    │   └── Add account metrics tool              Task
    ├── Wire Mastra + chat API                    Task
    └── Build chat interface                      Task

Why the type is stored, not inferred

The code currently infers "is this a container?" three different ways: children in the task store (listAutoDispatchReady, dispatchPick.ts), membership in .mainark/epics.json (dispatchPick.ts, epicQueue.ts), and a ## Mainark plan heading in the description (implementEntry.ts). They can disagree, none of them can express an empty Goal, and the one choke point that every implement dispatch passes through (prepareImplementDispatch) checks none of them — mainark dispatch <container> and mainark next --run can start an implement run on a container today. A stored kind replaces all three.

Canonical vocabulary

NounMeaning
ProductSomething we build and run: Fulreach, Mainark. Owns Goals, Tasks, Services.
GoalAn outcome we want. Contains Goals and/or Tasks. Never dispatched.
TaskAn executable leaf. Dispatched to agents; has a plan, Runs and PRs.
RunOne agent activity against a Task: implement, review, fix, verify.
PRA pull request implementing a Task.
ServiceA deployable unit of a Product (e.g. fulreach-api, fulreach-workers).
DeploymentA specific commit deployed to a Service.
IncidentA production problem on a Service, detected by Mainark.

Work is the UI name for a Product's tree of Goals and Tasks.

Retired from Mainark's domain language: Issue, Ticket, Epic, Feature, Story, Initiative, Project, Leaf (as a type name), Sub-issue. They may survive in three places only:

  1. Integration boundaries. Inside the Linear adapter a Linear Issue is an Issue; inside the GitHub adapter a PR is a PR.
  2. Physical storage names. The issues table, issue_events, agent_runs.issue_id keep their names (see Storage names).
  3. Deprecated CLI aliases during migration (mainark epic … → mainark goal …).

"Plan" is not a noun. It is a section of a Task's document (see below). The word "phases" names the coordination section of a Goal's document.

Goals

  • A Goal has a title, a document, an optional parent Goal, and optional dependencies.
  • A Goal may contain any mix of Goals and Tasks, to any depth.
  • A Goal never has Runs or PRs, and is never dispatched.
  • A Goal's progress is derived from the Tasks beneath it (see Statuses). Humans may store only intent on it: paused or cancelled.
  • mainark implement <Goal> is an orchestration shortcut: it recursively orchestrates the dispatchable Tasks beneath the Goal, in dependency and phase order. It never creates a Run or PR on the Goal itself.
  • A Goal with zero Tasks beneath it is awaiting decomposition. Implementing it is an error ("decompose first"), not a dispatch.

Tasks

  • A Task has a title, a document, an optional parent Goal, dependencies, a stored work status, Runs and PRs.
  • A Task never has children. Work that turns out to be too large is converted to a Goal (see Kind changes), not given children.
  • Only Tasks are dispatched, claimed, reviewed, merged and verified.

Documents

Both kinds have one Markdown document. Internally both use the existing plan_md / description storage; the semantics differ, the storage does not.

Goal documentTask document
Outcome and contextContext relevant to execution
Decomposition: why these childrenPlan: how to implement it
Decisions affecting the childrenAcceptance criteria
Phases: ordering, checkpoints (merge / approve), stacking between childrenImplementation decisions and notes
Coordination notes between children

Rules:

  • The ## Mainark plan block (phases/checkpoints, parsed by epicPlan.ts) is the Goal document's phases section. Tasks use the same heading for their implementation plan (18 FUL Tasks and MAI-29 do, some with Runs and PRs), so the heading alone says nothing about kind: the stored kind decides. The error is a Task whose plan lists work items as phase entries.
  • A phase entry may reference a child Task or Goal. A Goal entry completes when that Goal is merged (all Tasks beneath it merged); the orchestrator runs that Goal's Tasks, using the child Goal's own phases if it has them.
  • base: stack (PR stacking) is valid only between Tasks.
  • When an agent runs a Task, its prompt includes the Task document plus the document of every ancestor Goal, nearest first. Goals carry context down; they do not duplicate it into Tasks.

Runs and PRs

Runs belong to Tasks. agent_runs.issue_id must reference a Task. Run roles: implement, review, fix, and — new — verify. Run status (queued | running | succeeded | failed | cancelled) stays the execution state machine, separate from the Task's work status, as today.

PRs belong to Tasks, not Runs. One Task normally has one PR. Several Runs contribute to it (an implement Run opens it, fix Runs push to it, review Runs judge it). A Task may own more than one PR over its life (a closed attempt followed by a new one, or a Task deliberately split across stacked PRs).

  • Interim storage: issues.pr_* and agent_runs.pr_number as today.
  • Target storage: a pull_requests table (phase 5), keyed to the Task, holding repo, number, state and merge commit. The merge commit is what links a Task to the Deployment that shipped it.
  • A Task is merged when it has at least one merged PR and no open PR. A PR closed without merging never advances the Task.

Dependencies

A dependency says "X needs Y's outcome before it can start". Both Tasks and Goals may be dependency targets, and both may declare dependencies.

Satisfaction is at merged, not done. Downstream development must not wait for deployment or production verification.

Three different things

ConceptWhat it meansHow it happensEffect on the dependent
Outcome achievedY's work is integratedY reaches merged (or done)Satisfied
Dependency waivedX no longer needs YA human removes the edge from X, with a reasonThe edge is gone; it no longer gates X
Dependency work cancelledNobody will produce YY is set to cancelledNot satisfied. X is blocked: dependency cancelled

Cancellation is a statement about Y ("we won't do it"), never about X ("we don't need it"). Only a decision about X — a waiver — can unblock X without Y's outcome. The legacy rule (isTerminalWorkStatus treats cancelled like done, so cancelling a blocker unblocks its dependents) is dropped. It already contradicts the ledger's own rule in syncMergedIssues: a PR closed without merging leaves the Task as it is, because "whatever depends on rejected work must not start by itself". Cancelling the work is the same situation and gets the same answer.

Satisfaction

Dependency onSatisfied when
a Taskits work status is merged or done
a Goalit is not cancelled, it has at least one non-cancelled Task beneath it (recursively), and every non-cancelled Task beneath it is merged or done
  • A cancelled Task or Goal never satisfies a dependency.
  • A Goal with no Tasks beneath it never satisfies a dependency — "all zero Tasks are merged" must not unblock anything.
  • Cancelled Tasks inside a Goal are excluded from that Goal's outcome. Cancelling a child is the Goal owner descoping the Goal; the Goal's outcome is redefined, not abandoned. Cancelling the Goal itself abandons the outcome.

Waivers

  • Waiving removes the dependency edge and records a dependency_waived event on the dependent ({ dependsOn, reason, by }). The reason is required.
  • Re-pointing (Y was replaced by Z) is a waiver of Y plus a new edge to Z, recorded together. Kind conversion and "cancel and replace" offer it for every dependent of the replaced item.
  • Once dependencies have their own table (phase 5), a waiver may be kept as the edge with waived_at / waived_reason instead of a deletion; the semantics stay the same.

Dispatchability

A Task is dispatchable when it is todo, its own dependencies are satisfied, the dependencies of every ancestor Goal are satisfied, and no ancestor Goal is paused or cancelled. A dependency declared on a Goal therefore gates every Task beneath it.

A Task whose dependency (direct or via an ancestor) is cancelled is derived as blocked: dependency cancelled and shown as needing attention, with two actions: waive the dependency, or re-point it. It is never auto-dispatched.

Forbidden (rejected on write):

  • depending on yourself, an ancestor, or a descendant (an ancestor Goal is only satisfied once you are merged — a guaranteed deadlock);
  • dependency cycles, including cycles that pass through Goals;
  • dependencies across Products.

Stored vs derived statuses

Stored state is human intent plus the integration milestones that external events record. Everything else is derived, never copied — the same rule as mainark ready and ADR-003's incident timeline.

Task — stored (issues.work_status)

ValueMeaning
todoWanted; not started
in_progressClaimed by a dispatch
pausedHeld by a human or by failure policy
mergedNew. Implementation integrated; dependents may proceed
doneIntended outcome verified
cancelledNo longer wanted

Task — derived

Shown asDerived from
Readytodo ∧ dispatchable (above)
Blockedtodo ∧ some dependency not yet satisfied
Blocked: dependency cancelledtodo ∧ some dependency (direct or via an ancestor Goal) is cancelled — needs a human
Runninglatest implement/fix Run is queued/running
In reviewopen PR ∧ latest review Run not yet PASS
PR readyexisting mainark ready predicate
Deployedmerged ∧ a successful Deployment of the Product's Service contains the merge commit
Verifyingmerged ∧ latest verify Run is queued/running
Verification failedmerged ∧ latest verification evidence failed

The UI may collapse these to Planned · Running · Review · Merged · Done.

Goal — stored

Only intent: todo (open), paused, cancelled. A Goal is never stored as in_progress, merged or done.

Goal — derived

Shown asRule (over non-cancelled Tasks beneath, recursively)
Awaiting decompositionno Tasks
PlannedTasks exist; none started
In progressany Task started, not all merged
Mergedall Tasks merged or done
Doneall Tasks done

Progress counts (3/7 merged) are derived the same way.

Not part of the domain lifecycle

issues.status (pending | running | reviewing | pr_open | merged | failed) is the legacy daemon pipeline's runtime state machine. It is not a work status, is not exposed as one, and is out of scope here.

Merged vs done

  • merged — the implementation is integrated; downstream Tasks may proceed.
  • done — the Task's intended outcome has been verified.

Until Task verification exists, done = merged in effect: the merge handler (syncMergedIssues, SqliteTaskProvider.onMerged) keeps writing done, while every dependency check is switched to accept merged | done. When Task verification ships, the merge handler writes merged and recorded verification evidence promotes the Task to done. Nothing downstream changes at that switch, because dependencies already stop at merged.

Verification

Verification is evidence that the Task's acceptance criteria hold in the target environment. There is no single mechanism. Depending on the Task, evidence may be:

  • automated tests run against the target environment;
  • a health check (the health contract of ADR-003);
  • deployment state (the Service is running a commit that contains the merge);
  • browser or application checks;
  • production metrics;
  • incident recovery (ADR-003's verification, for incident Tasks);
  • human approval.

Architectural rules:

  • Evidence is recorded against the Task (a verified event with its kind and detail, or a succeeded verify Run whose output is the evidence). A Task reaches done only with recorded evidence.
  • Evidence must refer to the acceptance criteria in the Task document; a check that does not relate to them does not verify the Task.
  • Failed verification does not reopen the merge. The Task stays merged and is derived as verification failed until new evidence passes or a human creates follow-up Tasks.
  • How evidence is gathered for ordinary Tasks is a separate design.

Product, Service, Deployment

  • Product becomes a first-class entity: a products row (id slug, name, identifier prefix, repo, Linear team key). Every Goal and Task belongs to exactly one Product.
  • Service belongs to a Product. The registry (.mainark/services.json, whose taskGraph field already names the Product) moves into a services table keyed by product_id in phase 5.
  • Deployment belongs to a Service. It records the Service, commit, status and source (Coolify). A Task never owns a Deployment; "the Deployment that shipped this Task" is derived: the earliest successful Deployment of the Product's Service whose commit contains the Task's merge commit.

One database per Product, for now

Amended by ADR-005. One Postgres database holds every Product; products is a table and product_id partitions every domain row. The four rules below carry over and are enforced by composite foreign keys that include product_id (I9).

Fulreach (data/orchestrator.db, FUL-*) and Mainark (data/mainark-tasks.db, MAI-*) stay in separate databases. Product is a concept, not a database. To keep a later unified control-plane database a data copy rather than a redesign:

  1. Every domain row carries product_id, even while each database holds one Product. Queries filter by product_id rather than relying on "one Product per file".
  2. Identifiers are unique across Products. Product identifier prefixes are unique (FUL, MAI). Every other identifier family is Product-prefixed too: incidents are FUL-INC-12, MAI-INC-7 (see Incidents), replacing ADR-003's per-database INC-N, which would collide on merge.
  3. No cross-Product references. Parents, dependencies, incident links and PRs never point across Products — enforced, so a merge never has to untangle them.
  4. Product-scoped configuration moves into tables keyed by product_id (epics.json active/focus, services.json, .reviews/epic-*.state.json), not more files.

Incidents → Tasks

  • An Incident belongs to a Service, hence to a Product.
  • Incident ids are Product-prefixed: <PRODUCT>-INC-<n> (FUL-INC-12, MAI-INC-7), with one sequence per Product. The shape never matches a Goal/Task id (<PRODUCT>-<n>), so the two cannot be confused. Today's allocateIdentifier strips non-alphanumerics from the prefix and must learn the INC family before incidents ship.
  • An Incident creates or links to at most one Task — never a Goal (incidents.issue_id must reference a Task). Many incidents never get one.
  • The Task goes through the normal chain (dispatch → Runs → PR → merged → done). There is no separate incident work queue.
  • An incident Task may optionally sit under a Goal (e.g. "Reliability") or at the Product root.
  • Incident resolved ≠ Task done, but they share one verification mechanism: an incident Task's verify step is ADR-003's verification (the running commit contains the merge commit, then the check stays ok).
  • ADR-003's derived "Merged" becomes work_status ∈ {merged, done}.

Linear mapping

Linear terminology stays inside the Linear adapter. Mainark's invariants are authoritative.

LinearMainark
Team (key)Product (identifier prefix)
Issue with label goalGoal
Issue without label goalTask
Parent / sub-issueparent_id
"blocked by" relationdependency
## Mainark plan on a GoalGoal document, phases section
## Mainark plan on a TaskTask document, implementation plan
Project, Initiative, Cycle, Milestonenot mapped

State mapping: Linear Done ↔ done; a Linear workflow state named "Merged", if the team has one, ↔ merged (otherwise Mainark writes "Done" until verification exists). stateNameToWorkStatus must stop folding "merged" into done once merged exists.

The child fetch becomes recursive (today getEpicWithChildren reads one level).

Violations are errors, never conversions. When Linear contradicts an invariant — a Task (no goal label) with sub-issues, a Goal or Task under a Task, a Goal with Mainark Runs/PRs, a Task whose plan lists phase entries, a cross-team parent — the sync records a kind_violation event on the affected item, reports it in the sync result and CLI output, and dispatch refuses everything beneath the violation until a human fixes it in Linear. Mainark never flips the kind to make Linear's shape fit.

Database invariants

#InvariantEnforced by
I1issues.kind ∈ {goal, task}, not nullCHECK
I2parent_id is null or references a row with kind = goaltrigger (insert/update)
I3A Task has no childrensame trigger as I2
I4The parent chain has no cyclestrigger rejects self-parenting; deeper cycles in setParentId (SQLite forbids WITH inside triggers) and in the phase 2 preflight
I5agent_runs.issue_id references a Tasktrigger on agent_runs insert
I6PRs reference a Tasktrigger on pull_requests (phase 5); interim: I5 + issues.pr_* written only for Tasks
I7incidents.issue_id references a Tasktrigger on incidents
I8Goal work_status ∈ {todo, paused, cancelled}trigger, from phase 3 (today 21 FUL Goals are done and 6 in_progress; they need the status rollup first)
I9Parent, dependency, incident link stay within one Producttrigger once product_id exists
I10Dependencies: no self, ancestor, descendant, cycle, or unknown idrepository layer (depends_on is a CSV column; triggers cannot parse it reliably) + mainark doctor check
I11Only Tasks are dispatched; implement <Goal> only orchestrates TasksprepareImplementDispatch / review / fix guards, in addition to I5
I12A cancelled item never satisfies a dependency; only a recorded waiver removes onedependency predicate (single function) + dependency_waived event with required reason

Triggers raise ABORT, so the rules hold no matter which code path writes (CLI, HTTP API, inbox import, Linear sync, scripts).

Enforcement amended by ADR-005 on Postgres: I2, I3, I5, I9 by composite foreign keys; I4 and I10 by triggers under a per-Product advisory lock (any depth, any writer); I6 by a row CHECK until pull_requests exists; I12 by the dependency table with no DELETE grant. I8 stays deferred to phase 3, as above. The column above reads issues.kind; on Postgres it is work_items.kind.

Kind changes

Explicit only (mainark goal convert <id> / mainark task convert <id>), recorded as a kind_changed event:

  • Task → Goal: allowed only if the Task has no Runs and no PRs. A Task that has Runs is cancelled and replaced by a new Goal instead, so its execution history stays attached to the Task it belongs to. Its dependents are offered a re-point to the new Goal; until then they are blocked, not released.
  • Goal → Task: allowed only if the Goal has no children.

Storage names

The issues table and its columns are not renamed. A row is a Goal or a Task by kind; the table name is physical, not domain language. Renaming SQLite tables touches every query, migration, trigger and the daemon path for no behavioural gain. Code above the repository layer uses Goal, Task, WorkItem (either), not Issue.

Amended by ADR-005. The Postgres port names the tables work_items, work_item_events, work_item_dependencies, …: a persistence name only, never user-facing vocabulary. Repository function names and domain types are unchanged by the port; the SQLite names above apply until cutover.

Migration strategy

Each phase is a separate PR (or short stack), ships behind no flag, and leaves the system working. Phases 2–6 start only after this ADR is accepted.

Order amended by ADR-005. Storage moves to Postgres first (ADR-005 phases A–E); phases 3, 4 and 5 then build on Postgres. From phase 5, products, product_id (I9) and the dependency table (replacing the depends_on CSV; I10 in triggers) move into the Postgres migration itself. Phase 3 logic work may proceed in parallel if it adds no SQLite schema. In phase 4, merged is a single CHECK change on Postgres, and is not allowed during ADR-005's rollback window.

Phase 1 — this ADR

Lock vocabulary and invariants. No code.

Phase 2 — kind and guards

  • Migration: issues.kind TEXT NOT NULL DEFAULT 'task' CHECK (kind IN ('goal','task')).
  • Backfill: kind = goal for rows that have children; every other row is a Task. A ## Mainark plan heading is not used (see Goal and Task documents). epics.json is checked, not used as a source: every entry must already be a Goal, and loading a queue whose entry is a Task is an error.
  • Preflight, run by the migrator before 013_work_item_kind.sql: the migration refuses to proceed while any row cannot be classified safely (a parent with Runs or a PR, a dangling parent, a parent cycle, a childless row whose plan lists work items) until each is resolved by hand. Dry run: bun scripts/check-work-item-kinds.ts [--db path] [--epics file].
  • Triggers I1–I5 (I4 as above); I8 moves to phase 3.
  • Guards: prepareImplementDispatch (and review/fix) reject Goals; listAutoDispatchReady, dispatchPick.ts and pickNext filter on kind = task instead of inferring.
  • implementEntry.ts routes on kind, not on ## Mainark plan.
  • Behaviour change: containers can no longer be dispatched. Nothing else.

Phase 3 — recursive Goals

  • Recursive child fetch (SQLite and Linear adapter), Goal status rollup, then trigger I8.
  • Queue/next: Tasks recursively beneath active Goals, in phase/sort order.
  • Orchestrator: phase entries may be Goals; validateEpicPlan accepts descendants per the rules above.
  • Dependencies on Goals and ancestor-Goal gating (I10).
  • Plan inbox: a multi-step plan creates a Goal with one Task per step, instead of flat issues labelled plan. The Goal exists before any Task is dispatched, in the same transaction as its Tasks.
    • With a title, the title names the Goal.
    • Without one, Mainark infers a Goal title only when the steps share a coherent outcome it can state confidently. Otherwise the import fails with a request for clarification (written back to the inbox entry); it never creates a placeholder container such as "Untitled plan".
    • A single-step plan without a title creates one root Task, no Goal.
  • mainark implement <Goal> becomes the recursive orchestration shortcut.
  • Linear: goal label mapping and violation reporting.
  • Agent prompts include ancestor Goal documents.

Phase 4 — merged vs done

  • work_status gains merged; isTerminalWorkStatus is split into "satisfies dependencies" (merged | done) and "finished" (done | cancelled, used only for "is there anything left to do on this item").
  • All dependency checks (assertDependenciesSatisfied, listAutoDispatchReady, isChildBlocked) move to the first predicate.
  • Behaviour change: a cancelled blocker stops unblocking its dependents. Before deploying, mainark doctor --deps lists every open Task whose dependency is cancelled; each is waived or re-pointed by hand, or left blocked on purpose.
  • Merge handlers keep writing done (effective done = merged).
  • When Task verification exists: merge writes merged, recorded evidence promotes to done. That switch is its own PR.

Phase 5 — Product and state cleanup

  • products table; product_id on issues, agent_runs, incidents and new tables (I9).
  • services and deployments tables, replacing services.json.
  • pull_requests table (I6) with merge commit.
  • Goal active / focus columns replace epics.json; Goal phase progress replaces .reviews/epic-*.state.json.
  • Optional: an issue_dependencies table replacing the depends_on CSV, which would let I10 move into triggers.

Phase 6 — vocabulary rename

  • UI: sidebar Work, routes /work, Goal/Task tree view.
  • Types: TaskEpicWithChildren → Goal with children, TaskEpicChild → WorkItem, epic* modules → goal*.
  • CLI: mainark goal …, mainark task …; epic subcommands kept as aliases for one release.
  • Docs: retire epic/issue outside the Linear and storage boundaries.

Consequences

  • One rule replaces three heuristics for "is this a container", and fixes the existing hole where a container can be dispatched.
  • Arbitrary depth becomes possible; queue, orchestrator and rollup must all be recursive, which is the bulk of phase 3.
  • done gains a stricter meaning without slowing development, because the dependency boundary moves to merged first.
  • Linear users must label Goals; unlabeled containers in Linear surface as violations after phase 3 rather than being silently tolerated.
  • Cancelling work no longer silently releases its dependents; someone has to say "we don't need it" (waive) or "use this instead" (re-point).

Resolved during review

  • Incident ids: Product-prefixed, FUL-INC-12.
  • Untitled multi-step plans: infer a Goal title when confident, otherwise ask; never a meaningless container.
  • Verification: evidence that acceptance criteria hold in the target environment, from whichever source fits the Task.
  • implement <Goal>: allowed as recursive orchestration of its Tasks; never a Run or PR on the Goal.
  • Cancelled dependencies: not satisfied; only a waiver removes a dependency.

Follow-up designs (not blocking this ADR)

  • Ordinary Task verification: how evidence is gathered and recorded.
  • Goal-title inference for untitled plans: what "confident" means.