logbook/Docs

Concepts: the logbook domain model

logbook is a team backlog tracker built agent-first. Humans reach it through a web app (authenticated by Clerk); coding agents reach the same data through an MCP server authenticated by a bearer token. Both planes read and write the same rows, so the concepts below are the shared vocabulary — a human filing an item in the UI and an agent filing one over MCP are creating the same thing.

This document defines every domain object exactly. The field names, enum values, and rules here are taken from lib/db/schema.ts and the MCP tool definitions in app/api/mcp/route.ts; where the product ships a specific piece of wording as canon (the decision test, the size calibration), it is quoted verbatim and marked as such.

Organizations (the tenant boundary)

An organization is a Clerk Organization (org_…) and it is the tenant boundary. Every project, item, label, PR, and event belongs — transitively — to exactly one org, and a user sees a project only if it belongs to their active org. The org is where membership lives: there is one member list per org, managed in Clerk, and that roster is what scopes who can be assigned work and whose names appear on items.

The org is also the unit of billing. org_billing holds one row per paying or grandfathered org (plan is free | pro | founder, status is active | past_due | canceled); the absence of a row means an implicit free/active org. Both planes read this to gate writes — the item quota is an org-level limit.

Membership is resolved from Clerk at request time. When an agent lists the team or assigns work, the roster is the org's Clerk members plus anyone already attributed on the project's items — never every user in the database, which would leak names across tenants.

Projects (the backlog namespace)

A project is one isolated backlog. Projects nest inside an org: an org can hold many projects, and each project is a self-contained namespace with:

  • Its own LOG-N sequence. Item numbers are per-project, assigned from projects.item_seq (bumped transactionally on create), so every board starts at LOG-1. LOG-42 in one project is a different item from LOG-42 in another. The canonical reference form is LOG-<number> (see formatRef); lookups also accept a bare number or log-42/LOG 42/log#42.
  • Its own label taxonomy. Labels are defined per project (see Labels below); the same label name can exist independently in two projects.
  • Its own MCP connection URL. An agent's token is scoped to a project, so it writes only to the board it was set up for. Org-scoped tokens act on a project through the per-project /api/mcp/<slug> URL, where the slug's project must belong to the token's org.
  • Its own webhook secret. Each project has a distinct projects.webhookSecret (HMAC secret for its GitHub webhook), generated at creation, so one project's leaked secret cannot forge events for another.

projects.orgId is the access check (which tenant owns it); projects.ownerId records who created it. Repos are mapped to a project in project_repos (by owner/name), which is how an incoming PR webhook is routed to the right project.

Logs (the unit of work)

A log is the atomic unit of work — one shippable deliverable. In schema terms a log is an items row with kind = 'log' (the default). The shape a log should have is "one PR closes it": a single, coherent change a session can pick up, do, and cross off.

A log carries:

  • number — its LOG-N within the project.
  • title — the headline.
  • problem — "What problem are you trying to solve?" Every item is meant to be grounded in a problem, not just a task.
  • description — markdown body; the detail read on every pull.
  • summary — the write-time one-liner (see Summaries).
  • size — required for logs (see Sizes).
  • status, priority — lifecycle and urgency (see below).
  • assigneeIds — zero or more team members (multi-assignee).
  • labels — from the project taxonomy.
  • links — PRs, branches, files, URLs (see Relations vs containment).
  • resolution — "What was done", written at completion (see Resolutions).
  • tasks, notes, documents — child collections, defined below.

A log can belong to an epic (via epicId) or stand alone.

Epics (containers)

An epic is a container that groups child logs. It is not a separate table: an epic is an items row with kind = 'epic'. It has the same numbering, labels, notes, documents, and @-mentions as a log — everything existing works on epics for free — but it is a container, not a deliverable.

The decision test (ships verbatim in the tool schema)

Whether something is a log or an epic is decided by this test. It ships in the create_item kind parameter description exactly as written; treat it as canon:

If one PR would close it, it's a log — even with a long task checklist (tasks are steps of one deliverable). If it decomposes into independently shippable pieces that different sessions could claim, it's an epic with sized children.

The corollary, from the size calibration: an XL log "usually means this should be an epic — file kind:'epic' with S/M children instead."

Rules (app-enforced, not DB constraints)

  • Children must be logs. An epic's children all have kind = 'log'.
  • No nesting. An epic cannot have an epicId; epics are one level deep in v1. epic/epic_index parameters are rejected on epics.
  • Kind is immutable. An item's kind is fixed at creation. Converting a log to an epic (or back) is future work.
  • Epics carry no size. Containers are not sized. size is required for logs, rejected for epics — passing a size on an epic is an error.
  • Epics count toward the log quota. They are items and they carry value, so a free org's item allowance counts epics too.

Children and derived progress

An epic's children are the logs whose epicId points at it. Child progress is derived, not stored: it is computed from the children's status as done/total and surfaced as children: "3/7" on epic index rows (and as a children list of index rows when you pull_item an epic). There is no stored progress field to keep in sync.

On delete: children survive

items.epicId is ON DELETE SET NULL. Deleting or orphaning an epic leaves its children as standalone, unparented logs rather than cascading them away. The work outlives the container.

Sizes

Every log carries a T-shirt size from the enum S | M | L | XL (ITEM_SIZES). Size is enforced at the MCP tool boundary — required by create_item for logs — but the DB column is nullable, so the roughly 1,300 legacy rows that predate sizing stay valid and surface for cleanup via backlog_stats.unsized. Unsized items never match a size filter; they are meant to be backfilled, not silently excluded.

Size exists because the most common agent query is shape-based ("S items I can run in the background", "skip L/XL"), so it is a first-class field, not prose. The calibration below ships in the create_item size parameter description verbatim. Quote it as the canonical definition:

Required for logs; OMIT for epics (containers aren't sized). T-shirt size — pick one:

  • S — one focused change, single session, no design decisions left.
  • M — a feature: several files, some judgment, still one sitting.
  • L — cross-cutting or multi-session; needs a plan.
  • XL — usually means this should be an epic — file kind:'epic' with S/M children instead.

Summaries

The summary is a one-line, authored-at-write-time précis of an item, stored in items.summary (max 120 chars). It exists for index economy: every backlog index row carries the summary, and MCP list results stay resident in a consuming agent's context for the whole session, so a good one-liner is what lets an agent scan fifty items without pulling fifty bodies.

The parameter description ships this instruction verbatim:

One line, max 120 chars: what this item is, imperative mood. Shown in every backlog index — write it for a teammate scanning fifty of these.

When summary is NULL (legacy rows), the index falls back at read time to the first sentence of description, hard-cut at 140 characters. There is no backfill: new writes fill the column organically, old rows degrade gracefully. Summaries are strongly encouraged on epics — the summary is the container's one-liner.

Statuses and the lifecycle

An item's status is one of backlog | in_progress | done | stuck (ITEM_STATUSES), defaulting to backlog.

  • backlog — filed, not started.
  • in_progress — claimed and being worked. Claiming an item (assigning yourself) moves it here from backlog.
  • done — crossed off. The transition stamps doneAt and doneById (the closer); leaving done clears them. done items drop off the default board view but still count toward done stats.
  • stuck — blocked work, flagged so it is visible as needing attention.

Archiving

Archiving is distinct from status. archivedAt moves an item off the board while keeping its full history; archived items still count toward done stats. archive_done_items sweeps every done item into the archive in one call. There is also a time-based sweep (archiveDoneOlderThan, default 7 days). Archiving does not change status — a done archived item is still done.

Priorities

An item's priority is one of low | medium | high | urgent (ITEM_PRIORITIES), defaulting to medium. Priority is orthogonal to status and size: status is where the work is, size is how big it is, priority is how much it matters.

Labels (per-project taxonomy)

Labels are a managed taxonomy, not freeform tags. They live in the labels table, scoped per project (a label name is unique within a project), and are created deliberately — through Settings in the web app or the create_label MCP tool — then picked from the defined set. An item can only use labels that already exist; filing an item with an unknown label is rejected.

The discipline this enforces: reuse an existing label rather than minting a near-duplicate. The create_label tool says it directly — "reuse existing labels over near-duplicates (frontend vs front-end)". Label names are normalized to a lowercase slug (normalizeLabel: trimmed, lowercased, spaces to hyphens).

Tasks (steps of one deliverable)

A task is a checkable subtask inside one item — a step of that single deliverable. Tasks live in the tasks table (itemId, title, optional single assigneeId, doneAt, doneBy). "Campaign page" is the item; "wire KPI strip", "error boundary", "skeletons" are its tasks.

The critical contrast is with epic children. Tasks are steps of one deliverable; epic children are separate deliverables. A long task checklist is the signature of a log, not an epic — per the decision test, "even with a long task checklist ... tasks are steps of one deliverable". If the checklist items are actually independently shippable pieces that different sessions could claim, it is not a task list — it is an epic with child logs.

Task progress surfaces on index rows as tasks: "2/5" when present.

Notes (the handoff channel)

A note is a markdown comment on an item (notes table: itemId, authorLabel, body). Notes are the handoff and discussion channel — how a human or agent leaves context on a specific item. Leaving a note notifies the item's assignee: it writes an events row targeted at the assignee, which surfaces in that user's inbox (the check_inbox / briefing tools and the UI bell) until seen. The GitHub webhook also leaves a note on each crossed-off item recording the merge.

Note the separate concept of user notes (user_notes table): private per-user sticky notes pinned to the board's optional Notes column, scoped to one user in one project, where a @LOG-N token links to that item. These are a personal scratchpad, not the shared handoff channel, and are not exposed over MCP.

Documents (long-form specs outside the description)

A document is a markdown file attached to an item (documents table: itemId, filename, contentType, blobUrl, size, authorLabel). The bytes live in Vercel Blob; the row holds only metadata plus the blob URL.

Documents exist so long-form content — full UX specs, design docs, RFCs — lives outside description. description is read on every pull, so cramming a spec into it weighs down every list_backlog and pull_item. An attached document does not: agents see only the filename until they call get_document to fetch the content on demand. This is the same progressive-disclosure instinct the whole agent plane is built on (see docs/guide/agent-workflows.md). v1 is markdown-only.

Relations vs containment

Two different ways items connect, and they mean different things:

  • Containment (epicId) is hierarchical and exclusive: a child log belongs to exactly one epic. It expresses "this is part of that larger plan."
  • Relations (item_relations table) are flat, undirected item ↔ item links (stored once per pair, canonical order in code). A relation expresses "these two are connected" — a shared root cause, a PR cluster, a blocking dependency — without either owning the other. Related items appear in each other's pull_item as related_items. Use link_items to create one.

Reach for containment when work decomposes into a plan (epic + children); reach for a relation when two otherwise-independent items simply bear on each other.

Separately, links (items.links, a jsonb array of {kind, value}) attach external references to a single item. kind is one of pr | branch | file | url (LINK_KINDS). PR links are what the merge webhook matches against to cross an item off, and pull_item resolves each PR link's live state (open | draft | merged | closed) from the prs registry.

Resolutions (the institutional-memory contract)

The resolution (items.resolution) is "What was done" — written at completion. It is the most important field for institutional memory. The schema comment states the intent directly: these "accumulate into the de-facto documentation of what was built." The update_item tool enforces it — "When marking done, ALWAYS write a resolution" — and the parameter asks for the concrete substance:

What was done — written at completion. Concrete: what changed, where, key decisions. These become documentation.

The bar to hold yourself to: write the changelog entry you would want to find in six months. A resolution is not "fixed it" — it is what changed, where, and which decisions were made, because that text is what a future agent reads when it asks the history of a file (see below) and what a teammate reads to understand what shipped.

When a PR merges and no resolution has been written, the webhook fills in a default stub (Resolved by PR #N (title), merged by @who, plus the file summary). It never overwrites a resolution someone wrote — a hand-written resolution always wins over the auto-stub.

Changed-files history

At merge time, the GitHub webhook records not just that the work is done but which files the PR touched, in the item_files table (itemId, path, repo, prNumber). This turns the accumulated resolutions into a queryable map: file path → the items and resolutions that shaped it.

  • Capture is at merge, best-effort: the webhook fetches the PR's changed files from the GitHub API (capped at 300 paths) and stamps a row per linked item per path (idempotent via the unique (itemId, path, prNumber) triple, so a redelivered webhook is a no-op). If the fetch fails, the item still crosses off exactly as before — enrichment never blocks the cross-off.
  • The webhook also appends a deterministic file summary to the auto-resolution stub, e.g. "Touched 14 files: components/ (6), lib/db/ (4), app/api/ (4)".
  • It is queried by path through the history_for_files MCP tool, whose description states the ritual: "Before modifying a file, ask what happened to it before — returns the items and resolutions that shaped these paths." A trailing slash makes a path a directory query; otherwise the match is exact and case-sensitive.
  • pull_item / pull_items also surface an item's distinct changed_files (capped at 40, with files_truncated: true beyond).

There is no project_id on item_files: scope derives through the item, and history_for_files is always project-scoped through the token. git log tells you what changed; only logbook holds the intent and the resolution behind it, which is why capturing this at merge — rather than discarding it — is the whole point.