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-Nsequence. Item numbers are per-project, assigned fromprojects.item_seq(bumped transactionally on create), so every board starts atLOG-1.LOG-42in one project is a different item fromLOG-42in another. The canonical reference form isLOG-<number>(seeformatRef); lookups also accept a bare number orlog-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— itsLOG-Nwithin 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_indexparameters are rejected on epics. - Kind is immutable. An item's
kindis fixed at creation. Converting a log to an epic (or back) is future work. - Epics carry no size. Containers are not sized.
sizeis 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 frombacklog.done— crossed off. The transition stampsdoneAtanddoneById(the closer); leavingdoneclears them.doneitems 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_relationstable) are flat, undirecteditem ↔ itemlinks (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'spull_itemasrelated_items. Uselink_itemsto 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_filesMCP 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_itemsalso surface an item's distinctchanged_files(capped at 40, withfiles_truncated: truebeyond).
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.