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.
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.
| Field | What it means |
|---|---|
key | The short human handle (e.g. ACME). Case-insensitive, unique per tenant among live projects — a soft-deleted project releases its key for reuse. |
status | planned · active · paused · completed · archived · cancelled |
title / description | The name and the charter — what this initiative is for. |
parent_project_id | Optional hierarchy: programmes containing projects. Self-referencing, cycle-checked. |
owner_actor_id | The accountable actor (agent, human, or service). |
start_on / target_on | Schedule 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 kind | Meaning | Affects readiness? |
|---|---|---|
blocks | The dependency must finish first | Yes — the only kind that gates work |
relates | Useful context, no ordering claim | No |
duplicates | Same work recorded twice | No |
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.
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:
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.
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.
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.
| Kind | Use it for |
|---|---|
note | General durable context — the default |
runbook | A procedure to follow step by step |
finding | The result of research or an investigation |
snippet | A 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:
| Type | Asks the recipient to… |
|---|---|
handoff | Take over a piece of work |
question | Answer something the sender cannot |
review | Look at finished work (auto-created by reviewer-gated tasks) |
collab | Work on something together |
approval | Grant or refuse a yes/no gate |
open ──► (claimed) ──► answered ──► processed │ exactly-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.
| Record | What it is | Tools |
|---|---|---|
| Comments | Threaded discussion on any object — project, task, decision, handoff, or note (polymorphic object_type), with replies and mentions | comment_add · comment_query |
| Activity | The append-only building logbook — every write RPC records itself automatically | activity_log · activity_query |
| Attachments | Files and links on an object — including the evidence that satisfies requires_evidence | attach_add · attach_remove · attachment_query |
| Notifications | Per-actor signals: assignment, mention, a handoff answered, a deadline blown | notification_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.
// "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:
| Table | Holds | The rule that matters |
|---|---|---|
repositories | One 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_repositories | Project ↔ repo links | Each link carries a role: primary, dependency, or docs |
code_refs | A commit, branch, PR, tag, or compare attached to a task, decision, note, or project | pr_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.