Chapter 04

The entities

The full tour of the office. What each record stores, the lifecycle it follows, and the rules the database enforces on its behalf — so you know which room a piece of work belongs in before you write it down.

In plain language

The handful of things Ledgenter keeps track of — the "rooms" of the office.

Almost everything in Ledgenter is one of a few simple kinds of record:

  • Projects — the initiatives you're working on.
  • Tasks — the actual work, which can depend on each other ("this has to finish before that can start").
  • Decisions — choices that were made and the reasoning behind them, kept as a permanent record that's never quietly rewritten.
  • Knowledge — reusable notes and how-tos: the team wiki.
  • Handoffs — a request or question passed to a specific teammate's inbox.
  • Plus a running activity log, and links from each task to the actual code that delivered it.

Agents create and update these as they work, and everyone — agents and people — sees the same shared picture.

Projects: the initiatives

A project is the container everything else hangs from: tasks, decisions, knowledge, handoffs, and repositories all carry an optional or required project reference. It is the lightest entity by design — a charter, not a workflow engine.

FieldWhat it means
keyThe short human handle (e.g. ACME). Case-insensitive, unique per tenant among live projects — a soft-deleted project releases its key for reuse.
statusplanned · active · paused · completed · archived · cancelled
title / descriptionThe name and the charter — what this initiative is for.
parent_project_idOptional hierarchy: programmes containing projects. Self-referencing, cycle-checked.
owner_actor_idThe accountable actor (agent, human, or service).
start_on / target_onSchedule edges — calendar dates, not timestamps, because initiatives live at day granularity.

Create with project_create, change with project_update, list with project_query. The fourth tool matters most in practice: project_brief is the one-call orientation for an agent landing on an unfamiliar project — status, counts, the top tasks, recent decisions, knowledge pointers, and linked repositories in a single response (chapter 7).

Tasks: the work graph

Tasks are the richest entity in the system, because they carry the three hardest problems: ordering (a dependency graph), contention (multiple agents wanting the same work), and honesty (was it actually finished?). Each gets its own mechanism.

The status machine

backlog ──► todo ──► in_progress ──► (in_review) ──► done
                          │
                          ├──► blocked      parked deliberately — distinct from derived blockage
                          └──► cancelled
done/cancelled are sticky: reopening requires patch.allow_reopen:true — never by accident

The happy path runs backlog → todo → in_progress → done, with in_review as an optional stop when a reviewer is involved. Terminal states are deliberately hard to leave: a patch that would resurrect a done or cancelled task is rejected unless it carries allow_reopen: true, so an agent reopening work is always doing it on purpose. An invalid transition fails with a hint that names the allowed ones.

Priority is a number from 0 to 4, where 4 is most urgent — worth remembering, because several popular trackers order it the other way. The ordering is not decorative: claim-next and the whoami ready-hint both surface the highest-priority task first.

Dependencies and readiness

Edge kindMeaningAffects readiness?
blocksThe dependency must finish firstYes — the only kind that gates work
relatesUseful context, no ordering claimNo
duplicatesSame work recorded twiceNo

A task is ready when no blocks dependency is still open, and blocked otherwise (or when someone parked it with the explicit blocked status). Crucially, this derived state is computed in the database — a SQL view (tasks_with_state) joins each task against its open blocking edges, and task_query state:"ready" filters and paginates on it server-side. The earlier design filtered after the page limit, which could report “no ready tasks” while ready tasks existed past page one; the view killed that false negative. Dependency cycles are rejected at insert time by a trigger that walks the graph under an advisory lock, so even two agents adding edges concurrently cannot commit a loop — the error returns the offending edges.

Two lighter structures ride along: subtasks (a parent_task_id tree with its own cycle guard) and labels (a free-form string array, filterable in queries and claims).

Claiming and leases

Assignment in Ledgenter is a pull, not a push. task_claim with a task_id claims that specific task compare-and-swap style; with no id it atomically pulls the highest-priority ready unassigned task from the pool (optionally filtered by project or label), using FOR UPDATE SKIP LOCKED so contending agents skip past each other instead of queueing.

claim-next
task_claim({ "project_id": "…", "lease_seconds": 7200 })
// → the best ready unassigned task, now in_progress and leased to this run
{ "ok": true, "lease_expires_at": "2026-06-10T14:00:00Z", … }

Every claim carries a lease: one hour by default, clamped between 60 seconds and 24 hours. Re-claiming a task you already hold extends the lease — the heartbeat for long work. The lease is the entire crash story: if a claimant dies mid-task, no supervisor needs to notice; the lease expires and the task becomes claimable again, with the crashed agent's comments and code refs still attached for its successor to read. Finishing early? task_release hands the task back to the pool immediately (an in-progress task drops back to todo), and only the claimant can release it.

Patching safely

task_update distinguishes three intents that JSON usually conflates:

explicit-null semantics
task_update({ "patch": { "due_on": "2026-07-01" } })   // set the field
task_update({ "patch": { "due_on": null } })           // CLEAR it — key present, value null
task_update({ "patch": { } })                          // leave it untouched — key absent

// optimistic concurrency: a stale write fails with a hint, never clobbers
task_update({ "patch": { "status": "done", "expected_status": "in_review" } })

The explicit-null rule is what makes unassignment exist: a key present with null clears the assignee, due date, body, or reviewer; an absent key changes nothing. And expected_status is an optimistic precondition — pass the status you last read, and if another agent moved the task in the meantime, the write fails with a hinted conflict instead of silently overwriting.

Three fields exist purely to make “done” mean something: acceptance_criteria (a checklist of {text, met} items, every one of which must be met), requires_evidence (at least one live code ref or attachment must exist), and reviewer_actor_id (an answered review handoff is required). The database refuses the transition into done until all configured gates pass.

[i]

Verification gets its own chapter. The acceptance-criteria, evidence, and reviewer gates — and the adversarial harness that proves they hold — are covered in depth in chapter 9.

Decisions: the meeting minutes

A decision records a choice, the rationale behind it, and the options that were weighed — and it is append-only. Content is never edited after the fact; when the team changes its mind, it logs a new decision pointing at the old one via supersedes_decision_id. A unique index allows at most one successor per superseded decision, so the history is a clean chain, not a thicket. This is the property that makes decisions trustworthy as a record: what you read is what was decided at the time, with the reversal visible alongside it.

logging a decision
decision_log({
  "title":     "Keyset pagination for task_query",
  "chosen":    "keyset (seq cursor)",
  "rationale": "offset pagination skews under concurrent inserts",
  "options":   [ { "label": "offset", "cons": ["page drift"] }, … ],
  "supersedes_decision_id": "…"   // optional: link a reversal to its ancestor
})

The one mutable surface is status — proposed · accepted · rejected · superseded — moved by decision_set_status (a proposal becomes accepted or rejected; content stays frozen). Recall goes through decision_query, which can rank by meaning (semantic: true) using the same embedding pipeline as knowledge, falling back to lexical matching when embeddings are unconfigured.

Knowledge: the team wiki

Knowledge notes are the durable memory between sessions — the answer to “didn't an agent already figure this out?” The discipline the system teaches is symmetric: knowledge_search before redoing research, knowledge_write after learning something worth keeping.

KindUse it for
noteGeneral durable context — the default
runbookA procedure to follow step by step
findingThe result of research or an investigation
snippetA reusable code or configuration fragment

Writes never wait on the embedding pipeline: knowledge_write returns immediately with embedding_status: "pending", and the semantic vector backfills in the background (pending → embedded, or failed). The note is findable by trigram and title matching in the meantime, and knowledge_search degrades to that lexical path whenever embeddings are unconfigured — search by meaning is an enhancement, never a dependency. A content_hash (SHA-256 of the body) makes re-embedding cheap to skip: knowledge_update re-embeds only when the body actually changed, not on every tag tweak.

Handoffs: the inboxes

A handoff (the table is work_requests) is the addressed message between actors — the difference between discussing work (a comment) and needing another actor to do something. Five types share one lifecycle:

TypeAsks the recipient to…
handoffTake over a piece of work
questionAnswer something the sender cannot
reviewLook at finished work (auto-created by reviewer-gated tasks)
collabWork on something together
approvalGrant or refuse a yes/no gate
open ──► (claimed) ──► answered ──► processedexactly-once pickup                sender acks the answer
  ├──► expired     due_at passed — a sweeper flips it and notifies the sender
  └──► cancelled

A handoff can address several recipients, which raises the double-work problem: handoff_claim solves it with exactly-once pickup — one sibling wins the claim, and a claimed handoff cannot be answered out from under its claimant. Creation is defensive too: recipients are validated before insert (a bogus id fails with a hint, not a raw constraint error), and a fingerprint deduplicates live duplicates — at most one open/claimed/answered request per fingerprint, with the race returning deduped: true rather than an error. Deadlines are real: a handoff with a due_at that passes unanswered is flipped to expired by a five-minute sweeper, and the sender is notified — SLAs are enforced, not decorative.

Consumption is a cheap loop: inbox polls what is addressed to you; handoff_respond answers (and notifies the requester); inbox with ack: [ids] marks items processed; handoff_resolve closes one out. The asymmetry agents most often miss: to read the answer to a question you asked, query your own outbox — handoff_query direction: "from_me" returns your requests with their responses attached.

The paper trail: comments, activity, attachments, notifications

Four record types exist to make work legible after the fact. They share a design stance: cheap to write, attached to the thing they describe, and never load-bearing for the work itself.

RecordWhat it isTools
CommentsThreaded discussion on any object — project, task, decision, handoff, or note (polymorphic object_type), with replies and mentionscomment_add · comment_query
ActivityThe append-only building logbook — every write RPC records itself automaticallyactivity_log · activity_query
AttachmentsFiles and links on an object — including the evidence that satisfies requires_evidenceattach_add · attach_remove · attachment_query
NotificationsPer-actor signals: assignment, mention, a handoff answered, a deadline blownnotification_query · notification_mark_read

Activity deserves a closer look because agents rarely need to write it. Every write RPC emits its own entry inside the same transaction — verb-coded (task.claimed, task.released, pr.merged, skill.created), attributed to the actor and the run that performed it, and deduplicated by a content hash so retries cannot double-log. activity_log exists for the milestones worth narrating in your own words; the routine record keeps itself. Notifications close the loop from the other side: unread counts surface in whoami, so an agent learns it was assigned, mentioned, or answered without polling every room.

Actors: the roster

An actor is any principal that acts — agent, human, or service — resolved from its own API key, never from a client-supplied parameter. Each carries an external_ref: the stable, case-insensitive handle (unique per tenant among live actors) that lets one agent find another by name. register_actor resolves-or-creates by that handle, so same-name registration races converge on one row instead of two.

the two sentinels
// "me" resolves to the calling actor wherever an actor is accepted:
task_query({ "assignee_actor_id": "me", "state": "ready" })   // my unblocked work
task_query({ "assignee_actor_id": "unassigned" })             // the open pool
// find anyone else before addressing them:
actor_query()

The "me" sentinel works in every actor-shaped input — assignment, handoff recipients, filters — so an agent never has to look up its own id. The "unassigned" filter is its counterpart for the pool. Both exist because the alternative (agents pasting UUIDs) is exactly the kind of friction that makes agents stop using a tool.

Repositories & code refs: where the work landed

Ledgenter's claim that “done points at real code” rests on three tables:

TableHoldsThe rule that matters
repositoriesOne row per remote (GitHub, GitLab, Bitbucket, Azure, other, or local-only)Deduplicated by url_fingerprint — a normalized host/owner/name, so the SSH and HTTPS forms of the same repo converge on one row
project_repositoriesProject ↔ repo linksEach link carries a role: primary, dependency, or docs
code_refsA commit, branch, PR, tag, or compare attached to a task, decision, note, or projectpr_state (open · merged · closed · draft) exists only on PR refs; refs are fingerprint-deduplicated

repo_register resolves-or-registers a repository (with autodetect: true it reads the current checkout's git remote); repo_link ties it to a project. The everyday tool is task_code_ref: it records what code delivered a task, and because the MCP server detects the repo, branch, and HEAD of the working directory at startup, those fields default from the current run — recording the delivering commit is often a one-argument call. code_ref_update moves a PR through open → merged/closed, and code_ref_query answers the question every future agent asks: what code delivered this? The repo-matching behavior that steers agents into the right checkout is covered in chapter 7.

[§]

Every room shares one floor plan. All of these tables follow the same physical convention: tenant_id leads the primary key, every foreign key is composite with it, and forced row-level security walls each tenant off in the database itself. Chapter 5 shows how that wall is built and proven.