● active2026

Cairn

A self-hosted GTD task manager with a kanban board as one of its views — built for exactly two principals, one human and one agent, against an identity provider I control.

TypeScriptHonoDrizzle ORMPostgreSQLReactViteZodTailwind CSSStorybookTurborepopnpmSeaweedFSOpenTelemetryAutheliaDockerKomodo

highlights

  • Replaced a frozen Planka 2.1.1 instance in ten days — live and the system of record since 2026-08-20
  • Context, estimate and status kept as three separate axes; they were one overloaded label field in Planka
  • Two mutually exclusive API prefixes — a session cookie for the human, an API key for the agent, each refusing the other's credential
  • Offline-first web client — optimistic writes, a persisted mutation queue replayed in order, SSE instead of WebSocket
  • Four structure gates beyond the linter, with complexity and comment budgets ratcheted per file; barrel files banned after eleven hid a build-time cycle and seventy dead exports
  • Ships an MCP server two ways — an HTTP service on the stack network, and `@cortlan/cairn-mcp` on the private Gitea registry for stdio

Overview

Planka 2.2.0 moved single sign-on behind a €36/month floor for one user and dropped the table that backs OIDC, leaving the 2.1.1 instance frozen and unpatched. Forking is a licence problem, not a technical one, and the obvious alternative ran the same trap, so switching boards bought nothing. Cairn was built for supportability, not interaction design.

Cairn Timeline

  1. 2026-08-10First commit — product doc, then a domain model audited against live Planka
  2. 2026-08-20Live and the system of record — one-shot Planka import, all three boards

Two Principals, Different in Kind

Two users, and they are not the same kind of thing — most of the design descends from that. The human half is built around how a task system loses its user: capture has to land in under two seconds and must never block on the network, because a thought that waits on a spinner is already gone. The desk is where planning and triage happen and the phone is where execution happens — a tendency, not permission to build management surfaces that need a keyboard.

The agent half is a separate prefix, not a user with an unusual login: it generates candidate work for review, retries on failure, cannot poll efficiently, and has to be distinguishable from me at a glance.

The Three Axes

Three separate concepts:

  • Status — the GTD bucket: inbox, next, waiting, someday, done, dropped
  • Context — what makes an item doable right now: @errands, @computer
  • Estimate — effort: s, m, l, xl

In Planka all three lived in one overloaded label field, and re-merging any two recreates the problem the project exists to solve; there is deliberately no general-purpose tag to fall back into, and no scheduled status — an item is scheduled when deferUntil is in the future. dueAt is a promise to something outside the system, given only when missing it means letting somebody down.

Architecture

cairn/
├── packages/
│   ├── api/       ← Hono + Drizzle + Postgres 16
│   ├── web/       ← React + Vite SPA, offline-first
│   ├── shared/    ← Zod schemas both halves validate against
│   └── mcp/       ← thin wrapper over the same REST contract

Two API prefixes. /api/v1 takes the OIDC session cookie; /api/agent/v1 takes an x-api-key header. Provenance comes from the authenticated principal, never from request input, so the mount itself is the access mechanism. Key management is human-only twice over — on the human prefix and refusing an agent principal in the router, because a key that can mint a key outlives its own revocation.

Sync is server-authoritative with an IndexedDB cache, not a replica — last-write-wins per field, no merge UI.

Scope

Cairn replaces Planka as the task system of record and nothing else. Capture, habits, goals and briefings stay in Huginn, which writes through the agent prefix; Cairn never reads Huginn, never watches, and never interrupts.

learnings

  • Ordering has to be fractional indexing from day one — with a human and an agent both writing, integer positions produce reindex races that silently swap items
  • A required field added to a persisted client schema must bump the cache version; a rehydrated cache skips the parse, so stale rows read `undefined` and nothing fails loudly
  • OpenTelemetry's `metrics.getMeter()` does not rebind the way `trace.getTracer()` does — an eagerly built meter is a silent no-op for the life of the process
  • A service name is a join key for every metric series, span and alert rule; renaming it orphans all prior history, so it gets pinned and never follows a product rename
  • Capping cognitive complexity cannot work on a real tree — the worst function outscores any threshold worth setting, so the budget ratchets down instead
(END)q back