Spec database OPAL command reference
Every task that reaches the spec database, and the rules each one enforces
Spec database OPAL command reference
The spec.* family is the only way anything reaches the spec database. No
session opens the file; every access goes through a task, so it is mediated,
permissioned and attributable.
Two companion families are documented here too: trove.dispatch, which turns a
described work unit into a running session, and node.probe/fleet.describe,
which say what a machine can do.
Reads never ask permission
spec.search, spec.get, spec.tree, spec.relations, spec.history,
spec.blame, spec.dod.standards and spec.dod.check declare no permission
scope, are idempotent, and never prompt.
That is deliberate and it is what makes the database useful: a session can read a spec before anything is committed. A model that had to ask consent to look something up would guess instead.
Writes are the opposite — one thing per call, always. There is no batch mutate, no glob, no "update all matching". Those are the surfaces where one careless call corrupts fifty rows, so they are not offered. Bulk reads are fine.
Keys, not paths
Every spec, section and work unit has a stable key — sqb4gsd1zn9 — issued
by the database, never chosen by a caller. The human-readable path (CSCC.EFC.3)
is a display address derived from position, so renumbering a section changes
paths and changes nothing else. Always pass keys.
Reading
| Task | What it answers |
|---|---|
spec.search q=<text> |
Full-text over titles, instructions and notes. Returns key + title only. |
spec.get key=<key> [body=1] |
One entity. Bodies are opt-in, because a read that returns everything is a token bill somebody pays every call. |
spec.tree [spec=<key>] |
The shape: specs, their sections, their units. |
spec.relations key=<key> |
Units naming this one in depends_on or depended_on_by. |
spec.history key=<key> |
Every change, appended and never altered. |
spec.blame key=<key> field=<field> |
Who last wrote one field. |
Deleted entities still resolve — that is what soft delete means. Ask the
returned deleted flag rather than expecting a miss.
Authoring
| Task | Notes |
|---|---|
spec.put kind=spec|section … |
Create or edit one spec or one section. Returns its key. |
spec.workunit.edit key=<key> … |
Edit one unit. |
spec.workunit.reparent key=<key> section=<key> |
A redirect: the unit keeps its key, status and history. An empty section leaves it free-standing. |
spec.workunit.delete key=<key> |
Soft delete. The one task that requires physical presence — it is the only irreversible-shaped operation, so it cannot be invoked from a remote lane. |
Every work unit needs a Definition of Done. The field is NOT NULL from the
first migration and authoring rejects an empty one: a unit whose done-ness
cannot be checked is a unit that will be argued about later.
Definition of Done
| Task | Notes |
|---|---|
spec.dod.standards |
The catalog of standards a Definition of Done can cite. |
spec.dod.record key=<key> standard=<id> passed=1 node=<box> [target=…] |
Record one mechanical result. |
spec.dod.check key=<key> |
Is it met, and if not, what is missing. |
The catalog is fixed: STD-UNIT, STD-COVERAGE-NEW, STD-E2E, STD-BUILD,
STD-DOC, STD-VERIFY. A Definition of Done cites them by name — "meets
STD-UNIT and STD-BUILD" — instead of re-describing testing every time.
A citation the catalog does not know is refused when you write it. A typo'd
STD-COVERAGE would cite nothing at all, leaving a Definition of Done that reads
rigorous and enforces nothing.
node= is not optional for STD-BUILD. A green build says nothing without
the box it was green on — the same reason a green Linux build says nothing about
Windows-only code. A pass recorded without one counts as unattributed, and blocks
completion exactly as a failure would.
STD-VERIFY cannot be recorded at all. It is settled by a separate work unit
of type Verify that names its subject in depends_on and finishes
successfully. The implementer does not grade its own homework for the parts that
need an opinion.
Status, claims and plans
| Task | Notes |
|---|---|
spec.workunit.claim key=<key> owner=<who> |
Take a lease. A live lease blocks another taker; an expired one does not. |
spec.workunit.renew key=<key> owner=<who> |
Extend it while working. |
spec.workunit.set-status key=<key> status=<status> |
Report where the work stands. |
spec.plan.put … |
Create or edit a plan: whole specs plus individually chosen side quests. |
spec.plan.progress key=<key> |
Derived rollup — nothing is stored, so a spec can change mid-flight with nothing to resync. |
spec.plan.regroup key=<key> [include_rejected=1] [dry_run=1] |
Re-open unsatisfactory or stale work for another attempt. |
donesuccess is the one status the database will not take on trust. If the
unit's Definition of Done cites standards, they must all be satisfied first, and
the refusal names the gap. Every other terminal status is a report rather than a
claim: doneerror says it was not met, rejected says it will not be attempted,
and neither needs evidence.
A claim is a lease, not a lock. If a session dies mid-unit the lease expires and the work becomes available again — a crashed session cannot make a unit permanently unclaimable.
spec.plan.regroup runs one round and reports what it re-opened. It does not
loop: how many rounds are worth trying depends on the budget you have left, which
only you know. Rejected is left alone unless you ask, because it is a decision
rather than a failure.
Publishing
spec.publish key=<key> [root=<worktree>] [spec_path=<tree>] [node=<box>]
Renders a spec into a .md under the repository's spec tree so the code arrives
with a description of what it was for. It publishes terminal work only, and
says how many units it left out.
The file says in its own header that it is generated and that editing it changes nothing. It is a re-derivable snapshot; the database is the source.
This is the one spec.* task that takes a coarse resource scope. It touches the
working tree rather than the database, and there "another publish is already
running" is the right answer.
Running work
trove.dispatch lane=<n> key=<plan-or-unit> [model=<model>]
Selects the next runnable unit of a plan (or takes one named unit), claims it,
writes its briefing into the lane's CLAUDE.md, and queues the session.
It claims before it queues, because a queued-but-unclaimed unit is one a second lane can take. A plan with nothing left says "no work left" rather than failing — those two call for opposite responses.
What a machine can do
| Task | Notes |
|---|---|
node.probe |
What this node brings: OS, arch, cores, disk, load, and the toolchains actually on PATH. |
fleet.describe |
Every node's advertisement. Today that is one node, in a list. |
Everything advertised is measured, not configured. An advertisement that can be optimistic routes work to a box that cannot run it, and the failure lands much later as a confusing error on somebody else's machine.
Calling these from the CLI
The --opal forms and their two silent traps are documented in
the audio-recovery task reference and apply identically
here:
hub --opal 'spec.put kind=spec code=gait title=Gait' -c
hub --opal '{"steps":[{"task":"spec.search","args":{"q":"gait"}}]}' -c
Remember that the short form splits on spaces, so any value containing one needs the plan form.