Architecture
You will be able to trace any commission command from the surface that invoked it down to the SQLite file and back, say which facts are stored and which are computed on every read, and explain why each of the decisions that looks strange from outside is the cheapest way to keep one truth — well enough to extend commission, or to argue that a particular decision was wrong.
This page explains. It does not instruct — that is Guides —
and it does not enumerate — that is Reference. It uses
the vocabulary of
positioning.md §6 and defines nothing itself.
The shape, in one paragraph
Commission is a library with four thin consumers. src/core/ owns every write and
every derivation; the CLI, the HTTP API, the MCP server, and the web view are
argument parsers and renderers over it. Underneath is one SQLite file in WAL
mode, whose schema is an ordered array of DDL statements shipped inside the
binary. Above it is src/workspace/, a provider registry that prepares the place
work physically happens and deliberately stores nothing in the database.
commission done shop-12 POST /v1/tasks/:id/transition MCP tool "commission_transition"
│ │ │
└──────────────── src/cli ───── src/server ───── src/mcp ─────────────┘
│
src/core ← the only writer
│
src/db/client.ts ← open, migrate, guard
│
commission.db (SQLite, WAL)Every surface calls the same function. commission done shop-12, the HTTP transition
route, and the MCP tool all reach transition() in src/core/transitions.ts,
which is where the five statuses, the allowed moves, and the four refusals live.
Decision 1 — the core is a library, and it is the only writer
What it looks like. src/core/index.ts is a flat export surface: transition,
claim, apply, readyFrontier, buildBundle, inbox, approve, search.
Its first line of comment is the whole rule: “Commission core — the only writer. CLI,
HTTP API, adapters, and any future MCP server are all thin consumers of this
module.”
The alternative that was rejected. The obvious shape for a tool with a CLI and an API is to put the logic in the API and have the CLI call it over HTTP — one implementation, one process, familiar. Commission cannot do that, because it must work with no server running and no network at all. The other obvious shape is to implement each surface against the database directly, which is how a tool ends up with three subtly different definitions of “ready”.
What it buys. A rule that can be checked rather than remembered: if a surface
file contains business logic, it is a bug. src/server/api.ts is under two
hundred lines for nineteen routes, because a route is a JSON shape and a core
call. The MCP server
is a schema per tool and the same calls. The consequence a user notices is that
the inbox rendered in the web view cannot disagree with the terminal’s — not
because they are kept in sync, but because there is only one inbox().
What it costs. The core carries surface-shaped concerns it would otherwise
not have. CommissionError knows about exit codes and HTTP statuses; every refusal
has a hint string that exists for a human reader. That is the price of one
implementation, and it is cheaper than three.
Decision 2 — derived views, never stored ones
What it looks like. These are all functions over the graph, computed on every
read: the ready frontier, blocked, the board, standup, commission export, the parallel
batches, the inbox, routing explanations, divergences.
There is no blocked column. readyFrontier() filters open tasks by
unmetDeps(...).length === 0 and no open children, then applies eligibility,
then orders by the project’s selection policy. commission export writes markdown
whose first line is DO NOT EDIT, because it is a rendering of board() and
standup() and nothing more.
The alternative that was rejected. Storing blocked as a status, which is
what every tracker does. It is faster to query and it is the source of the single
most common failure in task tracking: a task that was unblocked three days ago and
is still labelled blocked, because unblocking is an action someone has to
remember to take. Storing a derived fact means owning a reconciliation problem
forever.
What it buys. Drift becomes structurally impossible rather than operationally discouraged. A dependency closing is the unblocking; there is no second write to forget. This is why commission can promise that the board is never wrong: the board is not a thing that can be wrong, it is a query.
What it costs. Reads do work. readyFrontier walks dependencies per task and
touches overlaps pairwise, which is fine at the scale Commission is for and would
not be at a hundred thousand tasks. That is a deliberate trade: the product is an
execution layer for a repository, not a portfolio database. The one place the
principle is knowingly broken is the FTS5 search_index table, which is a stored
projection kept current by explicit calls in src/core/search.ts — full-text
search over notes cannot be derived at read time, so it is the exception, and it
is isolated in one file rather than spread through the writers.
Decision 3 — refusals are prompts, and they are data
What it looks like. src/core/errors.ts is thirty lines and its first
sentence is the design: “Error messages are prompts: every refusal states what
is missing and the exact command that fixes it.” A CommissionError carries a code,
a message, an optional missing: string[], and an optional hint. The code maps
to an exit status (2 for a refusal) and to an HTTP status (422, or 409 for the
stale-version case). toJSON() is the machine form of the same thing.
The shape matters more than the wording. This is a refusal:
cannot complete ledger-1: 2 unchecked criteria; gate 'shipped' missing evidence [test, commit]
missing: criterion 0: cents(1.005) returns 101
missing: criterion 1: no float arithmetic remains in src/money.ts
missing: evidence.test
missing: evidence.commit
hint: tick each with 'commission check ledger-1 0', 'commission check ledger-1 1' — only once it is actually true; attach with 'commission evidence ledger-1 test=<ref> commit=<ref>'; or --force to override (logged)Three parts, deliberately: what was refused, what is missing as a list a machine
can iterate, and the command line that fixes it. The transition code even carries
a VERB map from status to the command that produces it, so a refusal about
done can hand back commission done <id> --force -m "<why>" rather than making the
caller reconstruct the invocation.
The alternative that was rejected. Conventional error handling: say what
went wrong, let the caller work out what to do. That is adequate when a human
reads the message and knows the tool. It is not adequate when the reader is a
model that has never seen commission’s source — which, because Commission is proprietary
and its repository is private, is every model that will ever operate it. There
is no commission in anyone’s training data. The refusal messages, the MCP tool
descriptions, and commission prime are the entire teaching surface
(licensing).
What it buys. An agent that reads exit code 2 and acts on the message needs no process documentation and no retry heuristics. It is also why the docs suite executes every documented command and asserts its exit code: a refusal that stops being accurate is a lesson that stops being true.
What it costs. Writing them is slow, and every new refusal is a small piece of interface design. Commission accepts that cost explicitly; a lazy refusal is a product defect, not a rough edge.
Decision 4 — claims are leases, and the race is decided in one statement
What it looks like. A claim is a row in claims with task_id UNIQUE, an
actor, a claimed_at, an expires_at (default eight hours), and a heartbeat.
The entire concurrency story is one conditional upsert in
src/core/claims.ts:
INSERT INTO claims (task_id, actor, session_id, claimed_at, expires_at, heartbeat_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?4)
ON CONFLICT(task_id) DO UPDATE SET
actor = ?2, session_id = ?3, claimed_at = ?4, expires_at = ?5, heartbeat_at = ?4
WHERE claims.expires_at IS NOT NULL AND claims.expires_at < ?4It wins if and only if the task is unclaimed or its lease has expired. Two agents
issuing it at the same instant cannot both win; the loser is told the task was
claimed concurrently and takes the next one. commission next --claim and
commission claim both bottom out here, so there is exactly one place the race is
decided.
The alternative that was rejected. Assignment — a column naming who owns the
task. Assignment cannot express what a mixed human/agent team needs at once: work
owned by a person while an ephemeral agent session is holding it, and work a
person owns that no agent may take at all. Migration v5 exists precisely because
the two facts had been sharing tasks.assignee, and separating them was worth a
column drop.
Why the lease. An agent process dies mid-task far more often than a human
walks away from one. Without expiry, every crash wedges a task until somebody
notices and administers it — and “somebody notices” is exactly the kind of human
maintenance Commission is built to remove. With expiry, the recovery path is nobody
doing anything: the lease lapses and the task returns to the frontier. --lease 0
opts out, and the documentation says plainly that it is how a task gets wedged.
A detail worth the odd look. The claim upsert also stamps a context
fingerprint — a hash of the project’s tracked guidance files — onto the claim.
It is done inside the primitive rather than in the calling command, because
next --claim reaches the primitive directly. One place the race is decided, one
place the fingerprint is taken. It is best-effort: a repository that is not on
this machine yields no fingerprint, and unknown never warns, because refusing a
claim over a missing guidance file would be absurd.
Decision 5 — gates and approvals are data, and only one of them is forceable
What it looks like. A gate is a named list of evidence kinds stored on the
project as JSON: {"shipped": ["test", "commit"]}. Evidence is rows. done is a
predicate, evaluated in transition() over four checks in order: open children,
unchecked criteria, outstanding approvals, missing gate evidence.
The asymmetry is the point. Two of those four cannot be forced:
- Open children. A parent’s exit criterion is its children; a parent with unfinished children is not finished by definition, so there is nothing for an override to mean.
- Outstanding approvals.
--forceoverrides commission’s own checks. If it could also override a person’s sign-off, then “a human must approve this” would be a naming convention rather than a control, and the entire approvals feature would be decoration.
Unchecked criteria and missing evidence are forceable, with a mandatory reason that is written to the log — because “I know, criterion 1 became obsolete when the API changed” is a real and defensible position, and a system that cannot express it gets worked around instead.
Why approval is stored separately from evidence. Schema v8 introduced the
approvals table rather than adding an evidence kind, because a gate says what
must be attached and an approval says who accepted it, and no quantity of the
first substitutes for the second. Each approval records satisfied_by and
bound_to — the task’s version at the moment of acceptance — so editing the work
afterwards makes the approval stale rather than silently carrying it forward onto
something the approver never saw.
And why an agent cannot approve. actorIsHuman() in src/core/ctx.ts
returns true only when the identity came from the operating system’s own user
record: no COMMISSION_ACTOR, no agent runtime. Everything else is treated as a
process. The asymmetry is deliberate and is stated in the source — a wrongly
declared human dissolves the boundary the whole mechanism exists to hold, while
a wrongly declared agent costs one line of configuration. It has one cost users
meet immediately: a solo founder who followed the install page and exported
COMMISSION_ACTOR is not a human until they declare themselves under actors in
.commission.json. The refusal says exactly that, and the
solo founder guide puts the declaration before the
first escalation.
Decision 6 — the log is append-only, and status has no setter
What it looks like. Every claim, transition, note, evidence attachment,
approval, question, and answer writes a row to events with an actor, a
timestamp, a type, and a JSON payload. It is read forward with a cursor. Nothing
updates or deletes an event.
There is no --status flag anywhere in the CLI. The only way a task’s status
changes is through transition(), which means the log is a complete account
rather than a partial one — and “complete” is what makes it worth reading at all.
One detail that looks over-thought and is not. The events table has both ts
and occurred_at. ts is always commission’s write time, so the log stays monotonic;
occurred_at carries source time when an importer brings in history that
happened elsewhere. Collapsing them would make an import of last quarter’s Jira
issues reorder your log.
Cancellation carries a reason or a replacement, enforced in transition().
Six months later, “why did we drop this?” is answerable from the log, which is the
entire reason the log exists.
Decision 7 — the schema is an array, and the version is its length
What it looks like. src/db/ddl.ts exports MIGRATIONS: string[], an
ordered, append-only array of DDL statements — currently eleven, from the initial
schema through claims-as-leases (v5), declared actors (v6), execution policy
(v7), approvals (v8), context fingerprints (v9), optimistic concurrency (v10),
and escalation questions (v11). PRAGMA user_version is the level a given
database has reached. Opening a store applies whatever is missing.
The schema level is MIGRATIONS.length, exported as LATEST_SCHEMA. There is
deliberately no SCHEMA_VERSION constant beside the array, and the source
says why: a second copy of a number the array already states can only drift from
it, and the drift is invisible until an upgrade reports the wrong level. The same
reasoning governs the release version, which lives only in package.json.
The alternative that was rejected. A migrations directory. Commission ships as a
compiled binary; a folder of .sql files is a second artifact to distribute and
a second thing to get out of step with the code that reads it. Inside the binary,
the migrations cannot be missing.
Three guarantees, and why each one exists:
- A database newer than the binary is refused, never touched. Guessing at a schema you do not carry is how data gets corrupted quietly. The refusal names the level to install and states that Commission never downgrades.
- A database with data in it is copied before the first migration, using
VACUUM INTOso the copy is transactionally consistent rather than a file-level snapshot of whatever bytes were on disk. The restore command — including removal of the-waland-shmsidecars, because a stale sidecar beside a restored file gives you neither database — is printed with it. v5 drops columns; no migration is reversible in general, so the copy is the only way back. - Each step re-checks the version inside its own write transaction, taken
with
.immediate(). Two commission processes opening the same stale database serialize there, and the loser observes the step as already applied rather than re-running it.
Why SQLite at all. It makes the whole security posture a consequence rather
than a feature: no server, no account, no network, no tenant, and a backup that
is a file copy. It is also what makes the free tier defensible — the cost of
serving a user is zero, so a limit would have to be justified as a business
decision rather than an engineering one
(business model §2 docs/business-model.md). WAL mode is
what lets many CLI processes read while one writes, which is the access pattern
of several agents on one graph.
Decision 8 — the workspace layer stores nothing in the database
What it looks like. src/workspace/ is six modules and a provider directory
behind one export surface: a provider contract, a registry with capability
negotiation, a chain runner that can undo itself, deterministic port allocation,
timed hooks, and one bounded way to run an external command. Providers are registered as a side effect of
importing the module, so a consumer gets a working registry without knowing what
is in it, and nothing outside providers/ names a vendor.
Two refusals to store are load-bearing:
- No environment variable values anywhere. The provider result type has
fields for the env files a provider materialized and the key names it
provides, and deliberately none for a value. Values cannot reach
--json, the event log, or the persisted record, because there is no field to put them in. Hook output is masked on the same principle. - Prepared workspaces live beside the database, not inside it. A workspace is
machine state — absolute paths, ports bound on this host, a directory only
this checkout has. It is not shared truth and not part of the task graph.
Putting it in a file next to
commission.dbalso inheritsCOMMISSION_DBscoping for free, so a test run, a sandbox, and a second machine are already isolated from each other without a second mechanism.
There is a third refusal worth naming: the bookkeeping file is not in the repository, because a project with no workspace configuration must leave the working tree byte-identical to how it found it.
What commission deliberately does not have
Absences are decisions, and each of these is one:
| not present | why |
|---|---|
a blocked status | derived from unmet dependencies; a settable one is a second truth |
| estimates, points, velocity | Commission would have no way to check them, and an unverifiable number in a system built on evidence corrodes the parts that are true |
| a configurable workflow engine | five statuses with fixed transitions is what makes a refusal predictable enough for a model to act on |
| an epic/story/subtask taxonomy | one recursive task entity; a parent is containment, not a type |
a --status flag | it would make the event log a partial account |
| a second copy of any number | LATEST_SCHEMA is the array’s length; the release version is package.json’s field |
| unattended outbound writes | commission proposes; a human or an agent with its own credentials disposes |
| semantic memory or retrieval | Commission stores what was decided, not what it means; the context bundle is assembled, not inferred |
Where to read the source
| you want to understand | read |
|---|---|
what may happen to a task, and the four refusals at done | src/core/transitions.ts |
| how two agents cannot claim the same task | tryClaim in src/core/claims.ts |
| what “ready” means, exactly | readyFrontier in src/core/queries.ts |
what an agent is handed by next | src/core/bundle.ts |
| what a human is handed instead | src/core/inbox.ts |
| the storage decisions, each with its reason | src/db/ddl.ts |
| the upgrade guarantees | src/db/client.ts |
| how a whole graph lands atomically | src/core/apply.ts |
| where work physically happens | src/workspace/index.ts |
The repository is private (licensing), so these paths are for people who have access to it rather than links.
Related
- Guides → parallel work — decision 4 as a working practice.
- Guides → execution policies — decision 5 as configuration.
- Guides → upgrades — decision 7 as an operation.
- Reference → exit codes — decision 3 as a contract.
positioning.md§7 — the absences argued at product level.
Part of Concepts.
This page is docs/concepts/architecture.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.