ADR-001: Mainark owns the task graph (exit Linear)

Status: Phase 1 merged; Phase 2 complete (SQLite backend; Beads prior stands); Phase 3 automated slice green — human soak + default flip are Phase 4
Date: 2026-07-20
Deciders: Mainark maintainers
Context: Linear remains the default (TASK_BACKEND=linear). Opt into local SoT with TASK_BACKEND=sqlite. See backend spike and task-provider.md.


1. Decision (architectural)

Mainark will stop treating Linear as the source of truth for engineering work items.

End state

ConcernOwner
Issue graph (id, title, body, deps, epic plans, human work status)Mainark (local-first backend)
Code, review, mergeGitHub
Agent execution, worktrees, review loops, stacked PRsMainark
Linear APIsNone

Hard requirement: Mainark must be usable completely offline for issue CRUD, implement / plan / queue, and orchestrator dispatch against an already-local DB. Network may still be needed for GitHub PR operations and agent model calls; issue storage and task-graph queries must not require SaaS.

This is not “replace a PM tool.” It is deciding that the orchestrator owns the task graph.


2. Non-goals (do not build)

  • A Linear clone (cycles, roadmaps, assignees as first-class product, SLA, rich projects UI).
  • Fulreach product-DB issues as the eng workflow SoT (unless issues must bind to tenants — separate product bet).
  • GitHub Issues as the orchestrator’s primary graph (acceptable as optional mirror later; not SoT).
  • A second plan store in target repos (.mainark/plans/) — see anti-patterns.
  • Linear webhook parity (Mainark already polls; local events suffice).
  • Cancelling Linear before an offline end-to-end epic has succeeded on the new default backend.

3. Linear touchpoint inventory

Classification:

  • required — capability Mainark must keep (any backend).
  • replaceable — same job, new local API / backend.
  • obsolete — delete when Linear is gone (no local equivalent needed).

3.1 Integrations

LocationDirectionClassNotes
src/integrations/linear/client.tsin/outreplaceableGraphQL hub
src/integrations/linear/reconcile.tsingress (+ reverse egress)replaceableLabel poll → SQLite; reverse-sync drift
src/integrations/linear/statusSync.tsegressobsolete*Board mirroring; replace with local work_status / pipeline status only
src/integrations/linear/constants.tsconfigobsolete*Linear board names/labels
src/types/linear.tstypesreplaceableBecome backend-neutral issue types

*Obsolete as Linear concepts; human pause/resume must still exist locally.

3.2 CLI

LocationDirectionClassNotes
src/cli/linearApi.tsin/outreplaceableSecond client; must collapse into TaskProvider
src/cli/start.tsegress + identityreplaceableCreate/link issue, In Progress, branch rename
src/cli/publish.tsegress + identityreplaceablePR title, Linear comment/state → PR comment / local status
src/cli/implementEntry.ts + parallel/epic pathsingressrequiredNeeds issue body, deps, plans
src/cli/epicPlan.tsparserequiredParser stays; storage moves
src/cli/epicQueue.ts / epicStatus.tsingressrequiredChildren + blockers
src/cli/naming.tsidentityrequiredStable display ids; initially preserve FUL-n; allocation depends on backend
src/cli/issueManifest.tslocalreplaceableDrop linearUuid / linearUrl when unused

3.3 Orchestrator / pipeline

LocationDirectionClassNotes
src/reconciliation/loop.tsingress via providerrequiredMust call TaskProvider, not Linear
src/pipeline/runPipeline.tsegressreplaceable / obsoleteDrop Linear comments; keep pipeline
src/pipeline/reviewLoop.tsegressobsolete*Linear comments → GitHub PR
src/pipeline/failureRouter.tsegressreplaceableLocal failed / paused
src/orchestrator/dispatcher.tsrequiredDispatch from local pending queue
src/integrations/github/merge.ts / reconcile.tsegress todayreplaceableUpdate local status, not Linear Done/Cancelled
src/recovery/startup.tsegressreplaceableLocal failed
src/integrations/slack/notifier.tsdisplayreplaceableLink to Mainark UI / issue id, not linearUrl

3.4 Data & UI

LocationClassNotes
Local task store / task repository (issues table + 003_linear_metadata.sql)mixedTask repository required (today mirrors Linear; may become graph SoT later); Linear display cols obsolete
depends_onrequiredAlready local shape; stop sourcing only from Linear
Epic ## Mainark planrequiredMust live in local store (field or attached markdown), not Linear description
.mainark/epics.jsonrequiredActive epic watch list (target repo)
Web /issues Linear chromereplaceable / obsoleteKeep pipeline UI; drop Linear links/metadata

3.5 Agent / docs / env

LocationClassNotes
.cursor/skills/spec-to-linear/replaceableBecomes spec → Mainark issues
.cursor/rules/mainark-parallel.mdcreplaceablePoint at local SoT
docs/parallel-agents/linear-setup.mdreplaceableRetitle / rewrite after cutover
LINEAR_*, TASK_BACKENDreplaceableLINEAR_* removed at end; TASK_BACKEND stays
Fulreach CI → Linear commentsreplaceableOutside this repo; must move to PR comments in lockstep

3.6 Current abstraction (insufficient)

// src/tasks/provider.ts today — too narrow
interface TaskProvider {
  backend: "linear" | "beads";
  reconcile(): Promise<LinearReconcileResult>;
}

Only src/reconciliation/loop.ts uses it. CLI and pipeline still hard-import Linear. BeadsTaskProvider throws. No Linear replacement work until §5 is implemented and parity-proven (§7).


4. Backend evaluation (criteria before picking a winner)

Candidates:

  1. SQLite — extend Mainark’s existing orchestrator DB
  2. Beads (bd, Dolt-backed) — existing TASK_BACKEND=beads stub
  3. Git-backed Markdown — files + conventions as SoT

4.1 Criteria (must score explicitly)

CriterionWhat “good” means for Mainark
OfflineFull CRUD + ready/claim + epic plan read with no network
ConcurrencySafe multi-agent / CLI + orchestrator without corrupt graph (claim atomicity)
Backup / portabilityDump, restore, move machine; clear disaster story
Agent ergonomicsEasy for Cursor/agents: show body, deps, ready work, create linked tasks
Epic plansStore and parse ## Mainark plan (or equivalent) without a second system
Dependency graphFirst-class blockers; topo sort for parallel/stacks
Orchestrator fitMaps cleanly to pipeline status, pause/resume, reconcile tick
IdentitySupport stable FUL-n (or documented mapping) for branches/worktrees/CI
Ops costDependencies, daemons, merge conflicts, learning curve
OwnershipMainark controls schema and migration; no mandatory third-party SoT

4.2 Provisional scores (design-time; validate with spike)

Scores: H / M / L relative to Mainark needs. Not a final decision.

CriterionSQLite (extend)BeadsMarkdown SoT
OfflineH — already localH — local Dolt / embeddedH
ConcurrencyM–H — single-writer SQLite is fine for solo + orchestrator; need explicit claimHbd update --claim, ready queue, server mode for multi-writerL — merge conflicts, no atomic claim
Backup / portabilityH — copy DB file; already in Mainark opsHbd export / bd backup; Dolt remotesH — git
Agent ergonomicsM — need CLI/MCP (mainark issue …)H — designed for agents (bd ready --json, dep types, remember/prime)H for reading; L for structured mutations
Epic plansHplan_md column or blob; parser unchangedM — parent-child + description possible; need convention for Mainark plan formatH — natural markdown
Dependency graphM–Hdepends_on exists; ready = queryH — blocks / parent-child / discovered-from; bd readyL — prose or fragile frontmatter
Orchestrator fitH — issues table + pipeline already coupledM — must map Beads status ↔ pipeline; dual concepts riskL — poll/parse files awkwardly
Identity (FUL-n)H — sequence tableM — native bd-* ids; need alias/FUL-n mapping or accept rename costM — filename conventions
Ops costH — zero new runtimeMbd + Dolt; another moving partH — no daemon
OwnershipH — Mainark schemaL–M — graph lives in Beads/Dolt product; Mainark becomes client againH

4.3 How to read this (important)

  • Prior: SQLite is favored on ownership and orchestrator-fit grounds. Mainark already owns the task repository schema; making Beads the graph SoT would introduce a second embedded DB the orchestrator must reason about. That tradeoff has not disappeared.
  • The spike (or design review) exists to falsify that prior, not to treat SQLite vs Beads as a coin-flip. Beads still deserves evaluation because it scores highest on agent ergonomics and ready/claim semantics — but the burden of proof is on Beads to win without Mainark becoming a client of another product’s data model.
  • Prefer a cheap design review first. A half-day read of Beads’ CLI/data model (IDs, deps, claim, epic/plan storage, multi-writer) may resolve the ownership question without building two working backends. Escalate to dual coded TaskProvider slices only if ownership is still contested after that review.
  • Markdown as SoT remains attractive for AI reading / export, but weak as the runtime graph. Prefer markdown as a view over a structured backend.

4.4 Phase 2 spike acceptance criteria (both candidates)

Whichever path is spiked must document:

CriterionMust show
Concurrent claimsSafe claim under CLI + orchestrator without double-pickup
Atomic state transitionswork_status / pipeline transitions do not tear or race
Durability / backupDump, restore, move-machine story; what is lost if the process dies mid-claim
Event loggingAppend-only issue_events (or equivalent) for status/claim/failure
Offline operationFull Phase 3 slice with no Linear (and no remote sync required)
Implementation complexityOps surface, deps, concepts leaked into Mainark domain types
IdentityStable display ids for branches/worktrees/CI
OwnershipMainark domain types remain authoritative behind TaskProvider

4.5 Decision gate (backend)

Pick SoT only after:

  1. TaskProvider methods in §5 exist behind Linear (no behavior change).
  2. Beads ownership question resolved via design review, and only then an optional coded spike if still ambiguous.
    “Still unclear” (operational): docs/CLI inspection alone cannot answer (a) display-id mapping for branches/CI and (b) atomic claim without leaking Beads concepts into Mainark domain types.
  3. Phase 3 parity checklist (§7) passes on the candidate default.
  4. Offline gate: mainark implement / plan / issue CRUD with network disabled for Linear.

Until then: TASK_BACKEND=linear remains default; docs say backend deferred (SQLite prior stands).


5. TaskProvider design (must cover every workflow)

Expand beyond reconcile(). All CLI and pipeline Linear calls eventually go through this interface (or a thin facade used by both). Types should not be named Linear*.

5.1 Domain model (minimal — not a Linear clone)

Issue
  id              // stable display identifier; initially preserve FUL-n;
                  // allocation mechanism determined by the selected backend
  title
  description     // agent-facing body
  dependsOn[]     // blocker ids (execution / stack)
  parentId?       // epic → child
  planMd?         // epic-only: ## Mainark plan body (or full description section)
  workStatus      // todo | in_progress | paused | done  (human control plane)
  labels[]        // e.g. orchestrator-managed
  // pipeline fields stay on local task store even if graph SoT is another backend:
  pipelineStatus, pr*, reviewCycle, failure*, tokens*, ...

IssueEvent (append-only; required before Phase 2 coding)
  id, issueId, at, kind, detail
  // kinds: created | updated | claimed | work_status | pipeline_status |
  //        failed | paused | resumed | pr_opened | merged | ...
  // Purpose: agents debug their own execution history without Linear comments

Comments are not required on the issue backend; use GitHub PR (and Slack) for human narrative. Agent-facing history is IssueEvent, not a comment stream.

5.2 Provider API (parity surface)

type TaskBackend = "linear" | "sqlite" | "beads"; // markdown is export, not a backend enum value

interface TaskProvider {
  backend: TaskBackend;

  // Identity & CRUD
  getIssue(id: string): Promise<Issue | null>;
  createIssue(input: CreateIssueInput): Promise<Issue>;
  updateIssue(id: string, patch: UpdateIssuePatch): Promise<Issue>;

  // Graph
  setDependsOn(id: string, blockerIds: string[]): Promise<void>;
  listReady(filter?: ReadyFilter): Promise<Issue[]>; // unblocked + workStatus eligible
  listChildren(epicId: string): Promise<Issue[]>;

  // Epic plans
  getPlan(epicId: string): Promise<string | null>; // raw ## Mainark plan markdown
  setPlan(epicId: string, planMd: string): Promise<void>;

  // Human control (replaces Linear board moves)
  setWorkStatus(id: string, status: WorkStatus): Promise<void>;

  // Orchestrator
  reconcile(): Promise<ReconcileResult>; // ingest/sync into local task store; no-op or local-only when SoT is local
  claim(id: string): Promise<boolean>;   // atomic start; false if lost race

  // Optional migration
  importSnapshot?(snapshot: IssueSnapshot[]): Promise<void>;
}

5.3 Workflow → API mapping (coverage checklist)

Workflow todayProvider methods required
mainark start create/linkcreateIssue / getIssue, setWorkStatus(in_progress)
mainark publishgetIssue; status → local/PR (no Linear comment)
mainark implement leaf / queuelistReady or getIssue + deps
Parallel bootstrap / topogetIssue, dependsOn
mainark plan / --resumegetPlan, listChildren
mainark queue / nextgetPlan / children + workStatus
Spec → tickets (skill)createIssue, setDependsOn, setPlan
Orchestrator pickupreconcile + claim / pending queue in task store
Pause / resume (was Linear Backlog ↔ Todo)setWorkStatus(paused|todo)
Failure / merge terminalpipeline repo + setWorkStatus(done) as needed
Agent implement bodygetIssue → write .mainark/implement-*.md

Gate: Do not switch default backend until every row has an automated or manual parity test on the new backend while Linear remains available as rollback (TASK_BACKEND=linear).

5.4 Local task store vs graph SoT

Clarify in code:

  • Graph SoT — TaskProvider backend (Linear today; SQLite favored later)
  • Local task store — Mainark’s issues (+ issue_events) repository: pipeline fields, and eventually possibly the full graph

Today the store mirrors Linear. After migration it may be the SoT. The neutral name “task store / task repository” covers both eras.

Do not require Linear (or Beads remotes) for task-store updates once a local backend is default.


6. Migration plan (incremental, rollback-friendly)

Each phase leaves Mainark working. No phase deletes Linear until §6.5.

Phase 0 — Export & conventions (docs only / one-shot scripts OK)

  • Export open Linear issues: id, title, body, state, labels, blockedBy, epic plan sections.
  • Freeze display-id rule: initially preserve FUL-n in branches/CI; allocation mechanism is determined later by the selected backend.
  • Record offline requirement in this ADR (done).
  • Rollback: N/A.

Phase 1 — Widen TaskProvider; Linear still only backend

  • Implement §5 API with LinearTaskProvider.
  • Route CLI + pipeline through provider (behavior unchanged).
  • Collapse linearApi.ts usages behind the provider.
  • Rollback: revert PR; Linear unchanged.
  • Exit criteria: all workflows in §5.3 call provider; no new direct GraphQL from CLI/pipeline.

Phase 2 — Backend spike; Linear remains default

  • Step A (preferred): half-day Beads design review against §4.1 / §4.4 (IDs, claim, deps, plans, multi-writer, ownership). Document go/no-go.
    Escalate only if still unclear: display-id mapping for branches/CI, or atomic claim without leaking Beads concepts into Mainark domain types, cannot be answered from docs/CLI inspection alone.
  • Step B (only if still ambiguous): implement the same narrow TaskProvider workflow slice for SQLite and/or Beads behind a flag so comparison is apples-to-apples.
  • Spike acceptance (§4.4): concurrent claims, atomic state transitions, durability/backup, event logging, offline operation, implementation complexity.
  • Default read path still Linear.
  • Rollback: TASK_BACKEND=linear.
  • Exit criteria: written decision (SQLite prior stands, or Beads falsified it) + optional slice demo; Linear default green.
  • Practical note: after Phase 1 ships, use Mainark for a week or two before deciding whether a Beads spike is needed at all — experience often clarifies more than further design.

Phase 3 — Prove feature parity (explicit exit criteria)

Run the checklist below on the chosen candidate with TASK_BACKEND=<candidate>. Fix gaps in provider or task store — not by special-casing Linear.
Rollback: default remains Linear.
Exit criteria: every row signed off; offline gate passed.

CapabilityPass condition
Create issueStable display id allocated; title/body persist offline
Edit issueTitle / description / work_status update without Linear
Dependency graphBlockers set; topo / parallel bootstrap correct
Epic plan## Mainark plan parse; mainark plan / --resume
QueueReady queue returns unblocked work
ImplementAgent body from local store; worktree created
PublishPR titled/linked; local status updated
ReviewReview loop + local status; no Linear comments required
Resume paused workPause / resume without a board UI
Offline executionAirplane mode: issue-graph CRUD + implement path
Worktree lifecycleCreate / reuse / clean worktree for issue id
Event historyissue_events (or equivalent) records claim/status/failure
BackupDocumented dump/restore for the chosen backend

Phase 4 — Switch default backend

  • Default TASK_BACKEND → chosen local backend.
  • Keep Linear provider for emergency rollback for one release window.
  • Rollback: flip env back to linear.

Phase 5 — Remove runtime Linear dependency

  • Delete runtime Linear client/statusSync usage and LINEAR_* requirements.
  • Rewrite skill → spec-to-mainark.
  • Coordinate Fulreach CI: Linear comments → PR comments.
  • Keep migration helpers/docs that still mention Linear until Phase 6.
  • Rollback: only via git revert / reinstall old version (expect pain — do not rush).

Phase 6 — Cleanup docs/migration shims + cancel subscription

  • Remove migration code/docs that assume Linear is SoT.
  • Cancel Linear subscription only after one full epic offline: create → parallel implement → publish → review/merge path.

7. Parity checklist (before default switch)

Canonical copy lives under Phase 3 above. Summary:

  • Create issue
  • Edit issue
  • Dependency graph
  • Epic plan
  • Queue
  • Implement
  • Publish
  • Review
  • Resume paused work
  • Offline execution
  • Worktree lifecycle
  • Event history
  • Backup / restore documented

8. Risks

RiskMitigation
Switching SoT before provider covers CLIPhase 1 gate
Losing pause/resume with board syncworkStatus + CLI/UI before Phase 5
Epic plans stranded in Linear descriptionsExport in Phase 0; getPlan/setPlan before cutting ingress
Display id (FUL-n) vs backend-native idsSpike ID strategy; do not surprise CI/branch naming
Dual Linear clients driftCollapse in Phase 1
Fulreach CI still posts to LinearTrack as explicit dependency of Phase 5
Choosing SQLite only “because it exists”§4 criteria + concurrency/backup/events in spike
Beads ownership boundarySQLite prior stands; design review / spike exists to falsify it, not to toss a coin

9. Consequences

Positive

  • Offline task graph; no subscription for IDs.
  • Single owner for deps + epic plans + dispatch.
  • Cleaner architecture: GitHub for code, Mainark for work graph.

Negative / costs

  • Build/maintain issue CRUD and human control plane.
  • Migration + Fulreach CI coordination.
  • Temporary dual-backend complexity (Phases 1–4).

Neutral

  • Historical FUL-* in code/docs remain archaeology.
  • Markdown implement files remain generated views.

10. Immediate next steps

  1. Soak with TASK_BACKEND=sqlite on real tickets (create → implement → publish) for a week or two.
  2. When Phase 3 human checklist is signed off, Phase 4: flip default to sqlite.
  3. Phase 5–6: remove Linear runtime + cancel subscription.
  4. Beads remains a non-SoT stub (backend spike).

References