Skip to content
commission

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 < ?4

It 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. --force overrides 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:

  1. 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.
  2. A database with data in it is copied before the first migration, using VACUUM INTO so the copy is transactionally consistent rather than a file-level snapshot of whatever bytes were on disk. The restore command — including removal of the -wal and -shm sidecars, 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.
  3. 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.db also inherits COMMISSION_DB scoping 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 presentwhy
a blocked statusderived from unmet dependencies; a settable one is a second truth
estimates, points, velocityCommission 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 enginefive statuses with fixed transitions is what makes a refusal predictable enough for a model to act on
an epic/story/subtask taxonomyone recursive task entity; a parent is containment, not a type
a --status flagit would make the event log a partial account
a second copy of any numberLATEST_SCHEMA is the array’s length; the release version is package.json’s field
unattended outbound writescommission proposes; a human or an agent with its own credentials disposes
semantic memory or retrievalCommission stores what was decided, not what it means; the context bundle is assembled, not inferred

Where to read the source

you want to understandread
what may happen to a task, and the four refusals at donesrc/core/transitions.ts
how two agents cannot claim the same tasktryClaim in src/core/claims.ts
what “ready” means, exactlyreadyFrontier in src/core/queries.ts
what an agent is handed by nextsrc/core/bundle.ts
what a human is handed insteadsrc/core/inbox.ts
the storage decisions, each with its reasonsrc/db/ddl.ts
the upgrade guaranteessrc/db/client.ts
how a whole graph lands atomicallysrc/core/apply.ts
where work physically happenssrc/workspace/index.ts

The repository is private (licensing), so these paths are for people who have access to it rather than links.


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.