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
| Noun | Meaning |
|---|---|
| Product | Something we build and run: Fulreach, Mainark. Owns Goals, Tasks, Services. |
| Goal | An outcome we want. Contains Goals and/or Tasks. Never dispatched. |
| Task | An executable leaf. Dispatched to agents; has a plan, Runs and PRs. |
| Run | One agent activity against a Task: implement, review, fix, verify. |
| PR | A pull request implementing a Task. |
| Service | A deployable unit of a Product (e.g. fulreach-api, fulreach-workers). |
| Deployment | A specific commit deployed to a Service. |
| Incident | A 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:
- Integration boundaries. Inside the Linear adapter a Linear Issue is an Issue; inside the GitHub adapter a PR is a PR.
- Physical storage names. The
issuestable,issue_events,agent_runs.issue_idkeep their names (see Storage names). - 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 document | Task document |
|---|---|
| Outcome and context | Context relevant to execution |
| Decomposition: why these children | Plan: how to implement it |
| Decisions affecting the children | Acceptance criteria |
Phases: ordering, checkpoints (merge / approve), stacking between children | Implementation decisions and notes |
| Coordination notes between children |
Rules:
- The
## Mainark planblock (phases/checkpoints, parsed byepicPlan.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 storedkinddecides. 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_*andagent_runs.pr_numberas today. - Target storage: a
pull_requeststable (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
| Concept | What it means | How it happens | Effect on the dependent |
|---|---|---|---|
| Outcome achieved | Y's work is integrated | Y reaches merged (or done) | Satisfied |
| Dependency waived | X no longer needs Y | A human removes the edge from X, with a reason | The edge is gone; it no longer gates X |
| Dependency work cancelled | Nobody will produce Y | Y is set to cancelled | Not 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 on | Satisfied when |
|---|---|
| a Task | its work status is merged or done |
| a Goal | it 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_waivedevent 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_reasoninstead 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)
| Value | Meaning |
|---|---|
todo | Wanted; not started |
in_progress | Claimed by a dispatch |
paused | Held by a human or by failure policy |
merged | New. Implementation integrated; dependents may proceed |
done | Intended outcome verified |
cancelled | No longer wanted |
Task — derived
| Shown as | Derived from |
|---|---|
| Ready | todo ∧ dispatchable (above) |
| Blocked | todo ∧ some dependency not yet satisfied |
| Blocked: dependency cancelled | todo ∧ some dependency (direct or via an ancestor Goal) is cancelled — needs a human |
| Running | latest implement/fix Run is queued/running |
| In review | open PR ∧ latest review Run not yet PASS |
| PR ready | existing mainark ready predicate |
| Deployed | merged ∧ a successful Deployment of the Product's Service contains the merge commit |
| Verifying | merged ∧ latest verify Run is queued/running |
| Verification failed | merged ∧ 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 as | Rule (over non-cancelled Tasks beneath, recursively) |
|---|---|
| Awaiting decomposition | no Tasks |
| Planned | Tasks exist; none started |
| In progress | any Task started, not all merged |
| Merged | all Tasks merged or done |
| Done | all 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
verifiedevent with its kind and detail, or a succeededverifyRun whose output is the evidence). A Task reachesdoneonly 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
mergedand 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
productsrow (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, whosetaskGraphfield already names the Product) moves into aservicestable keyed byproduct_idin 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;
productsis a table andproduct_idpartitions every domain row. The four rules below carry over and are enforced by composite foreign keys that includeproduct_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:
- Every domain row carries
product_id, even while each database holds one Product. Queries filter byproduct_idrather than relying on "one Product per file". - 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-databaseINC-N, which would collide on merge. - No cross-Product references. Parents, dependencies, incident links and PRs never point across Products — enforced, so a merge never has to untangle them.
- Product-scoped configuration moves into tables keyed by
product_id(epics.jsonactive/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'sallocateIdentifierstrips non-alphanumerics from the prefix and must learn theINCfamily before incidents ship. - An Incident creates or links to at most one Task — never a Goal
(
incidents.issue_idmust 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
verifystep is ADR-003's verification (the running commit contains the merge commit, then the check staysok). - ADR-003's derived "Merged" becomes
work_status ∈ {merged, done}.
Linear mapping
Linear terminology stays inside the Linear adapter. Mainark's invariants are authoritative.
| Linear | Mainark |
|---|---|
| Team (key) | Product (identifier prefix) |
Issue with label goal | Goal |
Issue without label goal | Task |
| Parent / sub-issue | parent_id |
| "blocked by" relation | dependency |
## Mainark plan on a Goal | Goal document, phases section |
## Mainark plan on a Task | Task document, implementation plan |
| Project, Initiative, Cycle, Milestone | not 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
| # | Invariant | Enforced by |
|---|---|---|
| I1 | issues.kind ∈ {goal, task}, not null | CHECK |
| I2 | parent_id is null or references a row with kind = goal | trigger (insert/update) |
| I3 | A Task has no children | same trigger as I2 |
| I4 | The parent chain has no cycles | trigger rejects self-parenting; deeper cycles in setParentId (SQLite forbids WITH inside triggers) and in the phase 2 preflight |
| I5 | agent_runs.issue_id references a Task | trigger on agent_runs insert |
| I6 | PRs reference a Task | trigger on pull_requests (phase 5); interim: I5 + issues.pr_* written only for Tasks |
| I7 | incidents.issue_id references a Task | trigger on incidents |
| I8 | Goal 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) |
| I9 | Parent, dependency, incident link stay within one Product | trigger once product_id exists |
| I10 | Dependencies: no self, ancestor, descendant, cycle, or unknown id | repository layer (depends_on is a CSV column; triggers cannot parse it reliably) + mainark doctor check |
| I11 | Only Tasks are dispatched; implement <Goal> only orchestrates Tasks | prepareImplementDispatch / review / fix guards, in addition to I5 |
| I12 | A cancelled item never satisfies a dependency; only a recorded waiver removes one | dependency 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
CHECKuntilpull_requestsexists; I12 by the dependency table with noDELETEgrant. I8 stays deferred to phase 3, as above. The column above readsissues.kind; on Postgres it iswork_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 thedepends_onCSV; 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,mergedis a singleCHECKchange 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 = goalfor rows that have children; every other row is a Task. A## Mainark planheading is not used (see Goal and Task documents).epics.jsonis 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.tsandpickNextfilter onkind = taskinstead of inferring. implementEntry.tsroutes onkind, 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;
validateEpicPlanaccepts 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:
goallabel mapping and violation reporting. - Agent prompts include ancestor Goal documents.
Phase 4 — merged vs done
work_statusgainsmerged;isTerminalWorkStatusis 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 --depslists 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 todone. That switch is its own PR.
Phase 5 — Product and state cleanup
productstable;product_idonissues,agent_runs, incidents and new tables (I9).servicesanddeploymentstables, replacingservices.json.pull_requeststable (I6) with merge commit.- Goal
active/focuscolumns replaceepics.json; Goal phase progress replaces.reviews/epic-*.state.json. - Optional: an
issue_dependenciestable replacing thedepends_onCSV, which would let I10 move into triggers.
Phase 6 — vocabulary rename
- UI: sidebar Work, routes
/work, Goal/Task tree view. - Types:
TaskEpicWithChildren→Goalwithchildren,TaskEpicChild→WorkItem,epic*modules →goal*. - CLI:
mainark goal …,mainark task …;epicsubcommands 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.
donegains a stricter meaning without slowing development, because the dependency boundary moves tomergedfirst.- 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.