---
name: lettuce
description: Use lettuce, the work tracker and coverage store, correctly from a CLI or HTTP server — discover work, claim it with leases, transition status, record runs/artifacts, grade coverage cells, and validate. For agents and humans driving the `lettuce` binary.
---

# Using lettuce

`lettuce` is a work tracker **and coverage/quality store**. Canonical state
(projects, tasks, comments, runs, artifacts, cells) is held in a path-jailed
store; every mutation is validated and recorded as an event. The same binary is
a CLI and an HTTP server, and the same CLI becomes an HTTP client when pointed
at one with `--server-url` — or with `LETTUCE_SERVER_URL`, unless a repo-local
`.lettuce` is discovered, which overrides the env (the flag always wins).

**The store is internal. Drive it through the binary — never by reading or
writing its contents directly.** Two roles are the exception, and an agent driving
the binary is neither: an **implementer** of a store backend reads the layout from
the filesystem spec (`docs/specs/lettuce-filesystem-spec-v0.15.md`), and an
**operator** un-wedging a broken store acts on it directly under the conflict
spec's `manual-only` tier (`docs/specs/conflict-resolution-v0.15.md`). The storage
layout is versioned, and is not an agent-facing
interface: an out-of-band write bypasses the validation and ledger that are the
whole point, and in client mode the CLI addresses no store at all — `--root` is
ignored and every command goes to the server, which owns the store even when it
runs on localhost. Every question the store can answer has a command:
`task show`, `task list`, `query`, `export`, `--format json`.

This document is the embedded, living agent guide — the **model-first entry
point**. It is printed by `lettuce skill` and served at `/` and `/SKILL.md` by
`lettuce serve` (against a hosted server, the server's copy is authoritative —
§0.1). Read it once top-to-bottom and you understand the whole tool;
for exhaustive per-command detail the binary documents itself (next section).
When this prose and the binary disagree, the binary wins.

HTTP identity caution: in Wordmade-ID server mode, omit `X-Lettuce-Actor`
entirely, including on reads. A present header is rejected with HTTP 400
`FW-API-ACTOR-UNSUPPORTED`; the server resolves attribution from verified
context. Shared-bearer mode still uses a self-declared actor for mutations.
With `--auth-mode wordmade-id` (`LETTUCE_AUTH_MODE`) the CLI sends no actor
header: it refuses an explicit `--author` and ignores (with a warning) an
inherited `LETTUCE_ACTOR`/`LETTUCE_AUTHOR`. The planned `@@`
named identities are not enabled by this header-boundary change; see
`docs/plans/ORG-SERVICE-IDENTITY.md` for the staged migration.
Wordmade agents must have a handle and grant profile scope so `/v1/verify`
returns it. An absent/invalid handle yields `FW-API-ID-HANDLE-REQUIRED` (401).
Do not send the handle in an Actor header to work around this refusal.

## 0. First contact — the binary, the store, your identity, your lease

Four questions decide how you drive lettuce. Answer them in order; every later
section assumes them.

**The binary is always the interface.** Every operation — local or against the
Huru hosted service — happens through the `lettuce` binary. Do not hand-craft
`curl`/`wget` or raw JSON against `/v1/…`: direct HTTP is discouraged and
unsupported for agents, it bypasses the client's identity/revision handling, and
every operation already has a command. The ONE exception is the bootstrap in
§0.1 — before you have a binary you read `GET /v1/version` to learn which
release to install. Only `GET /`, `/SKILL.md`, `/docs`, `/docs/flat.md` and
`/v1/health` are unauthenticated; everything else under `/v1/` (including
`/v1/version`) needs your bearer token.

### 0.1 Obtain the binary — pinned to the server's release

**Your client must be the release the server runs.** A mismatched client may
speak an older or newer wire contract. The server tells you its release:
`GET /v1/version` (authenticated) returns `{version, commit, build_date}` — the
same fields `lettuce version` prints — and every authenticated response carries
it in the `X-Lettuce-Server-Version` header. Production servers run tagged
releases only. Huru agents may be provisioned with `lettuce` already on `PATH`;
check it with `lettuce version` against the server before trusting it.

```bash
# 1. Ask the server which release it runs (the one raw-HTTP call; you have no
#    binary yet). Needs your bearer token — see §0.2 for what it is. In
#    Wordmade-ID mode also send X-Lettuce-Organization + X-Lettuce-Organization-Token.
V=$(curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$LETTUCE_BEARER") https://api.lettuce.huru.ca/v1/version | jq -r .data.version)
# 2. Download exactly that release for your platform, plus its checksum, FROM
#    THE SERVER: it serves the client binaries of its own build (any role; your
#    token is all it needs — no GitHub access).
OS=$(uname -s | tr '[:upper:]' '[:lower:]'); ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$LETTUCE_BEARER") -o "lettuce-$OS-$ARCH" "https://api.lettuce.huru.ca/v1/client/$OS-$ARCH"
curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$LETTUCE_BEARER") -o "lettuce-$OS-$ARCH.sha256" "https://api.lettuce.huru.ca/v1/client/$OS-$ARCH.sha256"
#    Fallback when the server has no baked binaries (FW-CLIENT-BINARY-UNAVAILABLE)
#    and you have read access to the release repository:
#    gh release download "$V" -R huru-io/lettuce -p "lettuce-$OS-$ARCH*"
# 3. Verify, install, confirm.
sha256sum --check "lettuce-$OS-$ARCH.sha256"   # macOS: shasum -a 256 --check …
chmod +x "lettuce-$OS-$ARCH" && mv "lettuce-$OS-$ARCH" ~/.local/bin/lettuce
lettuce version                                  # must print "$V"
```

Install into a directory YOU own and can write (`~/.local/bin`, first on
`PATH`): that is what lets `lettuce self-update` keep it current later.
Release assets and baked binaries exist for linux/darwin × amd64/arm64. Without `jq`, read the
header instead: `curl -fsS -o /dev/null -D - -H @<(printf 'Authorization: Bearer %s\n' "$LETTUCE_BEARER")
https://api.lettuce.huru.ca/v1/version | grep -i '^x-lettuce-server-version'`.
The header is read from a process substitution (bash/zsh) so the token never
enters curl's argv (`ps`, shell history); with a token file use
`"$(cat "$LETTUCE_BEARER_FILE")"` in place of `"$LETTUCE_BEARER"`.
If no asset matches your platform → build that tag from source or run the
published image `ghcr.io/huru-io/lettuce` (Huru-private GHCR).

**Version skew is a warning, never a refusal — act on it at once.** The client
sends `X-Lettuce-Client-Version` and compares the server's header with its own
version. On a mismatch every command still runs and adds
`FW-CLIENT-VERSION-SKEW` — in the envelope's `warnings[]` (a refusal carries it
as a trailing entry in `error.diagnostics[]`), or on stderr for human formats —
naming both versions. **When you see it: stop writing, run `lettuce
self-update`, then re-run `lettuce status`** (it must show no skew). The
warning's first `suggested_actions` entry is exactly that; the second is the
fallback `gh release download <server-release> -R huru-io/lettuce -p
'lettuce-<os>-<arch>*'` for your platform. A dev build (`vX.Y.Z-N-gSHA`) is
compared on its release-tag prefix and the warning says so; a `dev` build with
no tag is not compared.

**`lettuce self-update` catches you up with nothing but your token.** It asks
the server you are bound to (the `.lettuce` pointer, `--server-url`) for its
release, downloads the matching binary from `GET /v1/client/{os}-{arch}`,
verifies its sha256 against the server's `.sha256`, runs the new binary's
`version` to prove it runs here, and replaces its own executable atomically (a
temporary file beside it, renamed over it — a crash leaves the old binary).
`--check` only reports (current vs server release, whether a binary is served
for your platform, whether this executable may be replaced); `--dry-run` does
everything but the replace. It refuses, replacing nothing, on a checksum
mismatch (`FW-SELF-UPDATE-CHECKSUM-MISMATCH`), a binary you do not own or a
directory you cannot write, or a run under sudo — it never escalates
(`FW-SELF-UPDATE-PATH-NOT-WRITABLE`), a new binary that does not report the
server's release (`FW-SELF-UPDATE-VERIFY-FAILED`), and a server with no binary
for your platform (`FW-CLIENT-BINARY-UNAVAILABLE`): then use the recipe above.

**A server may set a floor.** An operator can run `serve --min-client-version
vX.Y.Z` (off by default). Below it every MUTATION is refused `426
FW-CLIENT-TOO-OLD` (exit 1; first action `lettuce self-update`); reads keep
working, so you can always read this guide and your tickets. Operators see who
is behind with `lettuce client list --stale` (admin) and the `clients` line of
`lettuce doctor --summary`.

**A binary too old to send its version is told too (LET-2002, v0.20.5).** A
lettuce older than v0.19.0 sends no `X-Lettuce-Client-Version`, so it never
computes `FW-CLIENT-VERSION-SKEW` and no floor can judge it. The server
recognizes it (no version header, Go's default User-Agent) and adds
`FW-CLIENT-VERSION-UNKNOWN` where that old binary prints it: in `warnings[]` of
`task list`, `task show`, `task audit`, `project list`, `author list`, `comment
list|show`, `run list`, `artifact list`, `query audit|timeline` (on stderr for
table/plain), and as the trailing entry of any refusal's `error.diagnostics[]`
(table output prints its action lines). At most once per token subject and
actor every 10 minutes; it never changes a status, a refusal's code or an exit
code. **When you see it: stop writing and run its suggested actions in order** —
the recipe above against your server (download from `GET
/v1/client/{os}-{arch}`, verify the `.sha256`, replace the `lettuce` on your
`PATH`, check `lettuce version`), because such a binary has no `self-update`.
An operator may also run `serve --refuse-unversioned-clients` (off by
default): mutations from such a binary are then refused `426 FW-CLIENT-TOO-OLD`
with the same recipe, while reads keep working. Operators see these agents as
`unknown (pre-v0.19.0)` in `lettuce client list` and count them as
`unversioned_subjects` in `doctor --summary`.

**The server's copy of this guide is authoritative for that server.** The
guide you are reading is embedded in the binary: `lettuce skill` prints the
copy your binary carries, and the server serves its own at `GET /SKILL.md`
(no token needed). With a pinned binary they are the same bytes. When they
differ (you saw `FW-CLIENT-VERSION-SKEW`), the server's `/SKILL.md` describes
the server you are talking to — follow it, and run `lettuce self-update`.

**Invocation name is a per-persona default; `.lettuce` always decides.** The same
binary is `lettuce` generally and `flt-issue` for **Lattice personas**. A
repo-local `.lettuce` file or folder beats any ambient default. Absent one,
`flt-issue` defaults to the fleet service when configured, else prints setup
guidance; plain `lettuce` defaults to local discovery. Make the store and project
explicit enough to be unambiguous regardless of how you were invoked.

### 0.2 Where the store is — connect, bind a project, pick a domain

`.lettuce` at the repo root (discovered by walking up) decides the store.

| Situation | `.lettuce` | Store |
|---|---|---|
| Local work, no server | a **FOLDER** (or `--root`) | an in-repo filesystem store |
| Huru agent on the hosted service | a **FILE** | the server named by its first line |
| Lattice persona (`flt-issue`) | as above; absent any `.lettuce`, the fleet service if configured | the fleet server |

> **Git-tracked `.lettuce/` FOLDER: reads can be stale right after `git pull`.**
> A pull/merge/checkout changes the store's files without a lettuce mutation, so
> `task list` / `task show` / `cell list` / registry lists may keep answering from
> the read projection recorded before the pull until the next lettuce mutation.
> After pulling, read with `LETTUCE_PROJECTION=off` (or run any mutation first).
> This caveat stands until LET-1782 (projection invalidation on out-of-band
> changes) lands; the hosted FILE mode is unaffected (the server is the only
> writer). Details: "Read projection" below.
>
> **After a `git merge`/`pull`/`rebase` that touched `.lettuce/`, also run `lettuce
> reconcile --project PROJECT` (and `doctor` for the read-only probe).** A blind git
> merge can leave a DERIVED scalar (a cell's `state`/`revision`, a task's `status`,
> …) diverged from its own event log — the shape `derived == f(events)` exists to
> catch (MH-11 / store-merge-healing). `doctor` already runs this recompute as part
> of its probe set and will flag the drift; `reconcile --apply` is the one command
> that HEALS the deterministically-recomputable (Class-A) cases. Run it once after
> any merge, not only when something looks wrong — the drift produces no other
> symptom until a read or a later mutation trips over it.

A `.lettuce` **FILE** is **remote (client)** mode; a **FOLDER** is **local**. The
hosted store URL is **always the credential-only form** — the token is the
bare userinfo (no `user:password`) — and the project is bound either in the URL
path or on a `project:` line; the two are equivalent:

```text
https://<token>@api.lettuce.huru.ca/<projectname>
# or
https://<token>@api.lettuce.huru.ca/
project: <projectname>
```

**Do not state both and disagree** — that is refused as ambiguous. `#` comments
are allowed; only the first non-comment line is the URL. The same URL works
one-off as `--store https://<token>@api.lettuce.huru.ca/` (or `LETTUCE_STORE`)
with `--project`. The legacy `https://<project>:<token>@host/` form is still
parsed but discouraged — do not write it. **A `.lettuce` file committed to Git
must carry no token**: write `https://api.lettuce.huru.ca/` plus the `project:`
line, and supply the token through `LETTUCE_BEARER` / `LETTUCE_BEARER_FILE`. A
`domain: <name>` line binds the repository to a domain; the effective domain is
`--domain` → `LETTUCE_DOMAIN` → the pointer's `domain:` line → your token's
default domain (an override of the bound domain prints a warning). Two
disagreeing `project:` or `domain:` lines are refused.

**Effective project:** `--project` → `LETTUCE_PROJECT` → the `.lettuce` binding
(project line or URL path). An explicit `--project` or `LETTUCE_PROJECT` that
differs from the binding still runs but prints a warning, so cross-project work
is never silent. The binding is a default, not a typed flag: root-scoped
commands (`author add`, `author list`) work inside a bound repository. With no
binding, a project-scoped command needs `--project`/`LETTUCE_PROJECT`, and you
discover the project with `project list` (section 0.5).

**Author registration is store-wide; project membership is separate.**
`LETTUCE_PROJECT=p lettuce author list` still returns the store-wide roster,
including authors not linked to `p`; configuration and pointer defaults behave
the same way. Use `lettuce project author list p` for membership. Likewise,
`author add NAME` registers a name without linking it to the default project;
use `project author add p NAME` to link it. An explicit `--project` on `author
add` or `author list` is refused with guidance to the project command.

**Check what you are connected to: `lettuce status`.** In remote mode its
`data.connection` names the server (never the token), the server's release, the
effective domain and project and where each came from (`flag`, `env`, `pointer`,
`token-default`), and `project_exists`. A bound project that does not exist in
the effective domain is a warning naming the fix — the usual cause is a missing
`domain:` line or `LETTUCE_DOMAIN`.

**Effective bearer:** `--bearer` → `LETTUCE_BEARER` → `LETTUCE_BEARER_FILE` →
the token embedded in the store URL.

Where the STORE is, first wins: `--store`/`LETTUCE_STORE` → an explicit
`--server-url` flag → a discovered `.lettuce` file/folder → the ambient
`LETTUCE_SERVER_URL`. So the `--server-url` flag always wins over a repo-local
`.lettuce`, but a repo-local `.lettuce` overrides the ambient
`LETTUCE_SERVER_URL` env — so `LETTUCE_SERVER_URL` alone reaches the server only
when no `.lettuce` is in scope.

**An explicit local root wins over the ambient remote sources (LET-1893).**
`--root DIR` or `LETTUCE_ROOT` selects that LOCAL store even inside a
repository whose `.lettuce` is a hosted pointer, and even with
`LETTUCE_SERVER_URL` exported; stderr says which remote source it shadowed, and
the server is not contacted. So scratch work is `--root $TMPDIR/scratch` (or
`LETTUCE_ROOT=...`) from anywhere, with a bearer exported or not. Two
exceptions: `--root .lettuce` naming the pointer file itself means the
repository's store and stays remote; and a TYPED remote selector (`--store`/
`LETTUCE_STORE` URL, `--server-url`) still beats `--root` — stderr then says
the root is ignored.

**Bounding discovery (`LETTUCE_DISCOVERY_CEILING`, env only, no flag).** The
upward `.lettuce` walk above is unbounded by default: from any working
directory it climbs to the filesystem root looking for the nearest entry.
Set `LETTUCE_DISCOVERY_CEILING=<dir>` to stop that walk AT AND EXCLUDING
`<dir>` — it is never itself inspected, nor is anything above it. This matters
whenever a process's working directory sits inside a checkout whose `.lettuce`
it must NOT reach: this repo's own CI/test harnesses set it to each test
package's own directory (see CLAUDE.md's engineering rules) so a `go test`
binary — whose default working directory IS the package's source directory —
can never silently discover this repo's own real hosted pointer above it and
authenticate against it with whatever bearer happens to be exported. An
operator scripting against an unfamiliar directory tree can use it the same
way.



**What your token grants.** On the hosted service a bearer token is a named
credential (the server's `--tokens-file`). It decides three things: **who the
server authenticates you as** (the token's name, which the server's
authorization policy maps to roles such as reader/writer — with no policy the
token may do anything a route accepts); **which domains you can reach** (the
token's listed domains plus `default`, unless the operator opted the token out
of `default`); and **your default domain** (used when
you send none). It does **not** fix your actor name — in shared-bearer mode you
still declare your own `--author` (§0.3). A legacy single `--secret` reaches
only `default`. Wordmade-ID principals reach only `default` for now. A grant
change takes effect when the operator reloads the tokens file (`SIGHUP`, no
restart, LET-1809) — if a newly granted domain still answers
`FW-DOMAIN-FORBIDDEN`, ask the operator whether the reload was sent and accepted.

**Domains.** One hosted server can serve several independent stores
("domains"); tasks, ids, leases and idempotency keys never cross them. Select
one per command with the global `--domain NAME` (or `LETTUCE_DOMAIN`); omit it
to get your token's default domain. `lettuce domain list` shows exactly the
domains your token can reach and marks the default. `403 FW-DOMAIN-FORBIDDEN`
means "not yours OR does not exist" (the server will not say which); `--domain`
is refused with a local store. Details: §11 "Domains".

**What the client handles for you (retries).** Against a server you do not
need retry loops for conditions that clear on their own: the client re-sends,
within one bounded budget per request, a live-writer refusal
(`FW-RUNTIME-WRITER-CONTENDED`), a read that overlapped a write
(`FW-RUNTIME-SNAPSHOT-UNSTABLE`), a `429` whose `Retry-After` is at most 10s,
a refused/reset/timed-out connection and a gateway `502/503/504` during a server
restart. It never applies a mutation twice: every mutation carries an automatic
`Idempotency-Key` (yours when you pass `--idempotency-key`), so a retry after a
lost response gets the server's replay of the ORIGINAL result. It never retries
what a retry cannot fix: `FW-RUNTIME-WRITER-ACTIVE` (needs `recover`), revision
conflicts, validation/auth/`FW-DOMAIN-*` refusals, TLS/DNS/scheme errors. The
budget is 30s (network failures: 10s); `--writer-wait D` bounds it to `D` and
`--writer-wait 0` turns client retries off (fail fast end to end); the same holds
for the destination of `migrate --to URL` (LET-1881). A command
that retried says so: `meta.client_retries` `{count, reasons, replayed}` in
JSON, one `lettuce: note:` line on stderr otherwise. If the budget runs out on a
mutation, the refusal says its outcome is unknown and names the key: inspect the
target first; re-running with `--idempotency-key <that key>` (commands that
accept it) applies it at most once.

### 0.3 Identify yourself — never a shared operator name

Attribution depends on the server's admission mode; the two are not
interchangeable.

**Wordmade ID (preferred, the usual way once enabled).** Register your own agent
identity, present it as the bearer with `LETTUCE_AUTH_MODE=wordmade-id` (plus the
organization header in that mode). The server verifies the token and resolves
attribution from verified context. **Never send `X-Lettuce-Actor`** — a present
header is refused with `FW-API-ACTOR-UNSUPPORTED`.

**Shared bearer (fallback until Wordmade is enabled for Huru).** The token is a
named token a Huru human provides (§0.2 "What your token grants"); keep it in
`LETTUCE_BEARER_FILE` (or `LETTUCE_BEARER`) — never in a committed `.lettuce`
pointer or on a command line. **You MUST declare your own stable
actor name** — `--author <your-name>`, sent as `X-Lettuce-Actor`, or
`LETTUCE_ACTOR`. Do **not** borrow `lettuce-operator` or another agent's name,
and never use a `@@handle` in this mode; this name is trusted self-declaration
(attribution, not verified identity).

**First time on a hosted project (a new teammate).** Your author name must be
registered in the store AND linked to the project before you can write. A server
started with `--auto-provision-actors` (chart `autoProvisionActors: true`) does
both on your first project write, so there you just write as `--author
<your-name>` and skip steps 3–4. The default is OFF, so otherwise take the
manual path. A writer token is enough for every step (LET-1885):

```sh
# 1. Token from the operator, in a file only you can read; pin the client (§0.1).
export LETTUCE_BEARER_FILE=~/.config/lettuce/token LETTUCE_ACTOR=<your-name>
# 2. Confirm server, release, domain and project (fix the domain first if
#    status warns the project does not exist there).
lettuce status
# 3. Register your author name — root-scoped, works inside the bound repo.
lettuce author add <your-name> --idempotent
# 4. Link YOURSELF to the project (acting as your own name).
lettuce project author add <projectname> <your-name> --idempotent --author <your-name>
```

Steps 3 and 4 in one call: `lettuce project author add <projectname> <your-name>
--bootstrap-author --idempotent --author <your-name>`. Once you are a member you
may link another REGISTERED teammate: `lettuce project author add <projectname>
<their-name> --idempotent --author <your-name>`. Or they link themselves.

Every refusal on this path names its next step:
- An unregistered name answers `FW-CMD-MISSING-AUTHOR` "not registered", which
  points to step 3.
- A registered but unlinked name answers `FW-CMD-MISSING-AUTHOR` "not linked to
  this project", which points to step 4. The same refusal comes back when a
  non-member tries to link someone else: join first.
- Under a server authz policy, `FW-API-AUTHZ-DENIED` names the role it wanted.
  Registering ANOTHER name needs `admin`, both with `author add <other>` and with
  `--bootstrap-author` for another author. So does `author
  deactivate|reactivate`, and so would removing a member (there is no unlink
  command). Let the teammate register itself, or ask the operator.
- On an auto-provisioning server, a refusal on this path starts with "just
  perform the first write as --author <name>". Do that.

**If you are a long anonymous session, not a named persona: PICK A NAME NOW and
keep it in your context/memory for the whole session and every restart** (e.g.
`agent-<short-token>`). Use it on every command so the ledger credits one
consistent author instead of a new stranger per step.

### 0.4 Lease short — about 3 hours

Acquire a task lease before exclusive work; `--expires-at` is required and must be
in the future, capped by the store's maximum window. **Aim for ~3 hours:**

```sh
lettuce lease acquire <project>/<TASK> --author <you> \
  --expires-at "$(date -u -d '+3 hours' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+3H +%Y-%m-%dT%H:%M:%SZ)" --format json
```

A short lease keeps the task recoverable: another agent can take over an
**expired or abandoned** lease with `lease steal <ref> --expires-at … --reason …`,
or re-acquire it once expired. Hold longer work by **renewing**, never by taking
one long window — a window that never ends is a permanent lock whose only remedy
is a forced steal. (The example above works on GNU and BSD/macOS alike: it tries
GNU `date -d` and falls back to BSD `date -v`.)

### 0.5 Know your project, then work

Know the project before you act: take it from the `.lettuce` binding, the
operator/task instruction, or `project list`. If you cannot tell which project
the work belongs to, **ask the human/operator** — do not guess, and **do not
create projects unasked**.

```sh
lettuce status --format json                       # store, server, release, domain + project (and their sources)
lettuce domain list --format json                  # hosted: the domains your token reaches (default marked)
lettuce project list --format json                 # which projects may I use? (no project needed)
lettuce task list --project <projectname> --format json
lettuce board next --project <projectname> --format json
lettuce skill                                      # this guide
lettuce usage                                      # exhaustive flag reference
```

**`board next` on a hosted store (LET-1846).** The board (and the `query search`
index) is an O(project) build: about 1-1.5 minutes on the ~490k-file `lettuce`
project. The server never runs it inside your request (a proxy timeout used to
cancel it half-way, so the board was unusable). It builds in the BACKGROUND,
once per write, shared by every agent, and answers within a few seconds with:

- the board **current** at this store generation (repeat reads: well under 1s);
- after someone wrote, the **last built board, marked STALE**, while it rebuilds.
  It still tells you what to work on (the `[TICKETS]` half of `board next` is
  always read live); human output prints `lettuce: board: STALE ... age ...` on
  stderr, JSON carries `meta.stale`, `meta.generation`, `meta.age_seconds`,
  `meta.read_refresh`;
- when no board was ever built (a server restart with new options, a brand-new
  project), `FW-API-READ-PENDING`. **The client waits for it by default**,
  polling with the server's `Retry-After` and printing progress on stderr, for
  up to 3 minutes (`--wait=10m` to wait longer, `--no-wait` to not wait). If it
  is still not ready, the command **exits 8**: "try again later", not an error.

So an agent picking work just runs `lettuce board next --format json`: exit 0
means you have a board (check `meta.stale` if freshness matters, or pass `--wait`
to wait out a rebuild); exit 8 means rerun in a minute. `query search` behaves
the same way (`meta.read_kind: search-index`).

**Every whole-project read works this way (LET-2003, v0.20.5).** Which reads are
heavy is declared in the catalog: `lettuce usage <command> --format json` shows
`read_cost` (`bounded`, `listing`, `project`, `store`, `store-inline`, `local`).
Every `project` read — `board next|export|render`, `query search`,
`query timeline`, `agents`, `dod show` (a declared DoD's verdict joins the board
build) and `graph-run-case refs-to` — takes `--wait[=DURATION]` / `--no-wait`,
waits 3m by default, carries `meta.read_kind` / `meta.read_source` / `meta.stale`,
and exits 8 while the build is pending. `query timeline` builds the merged ledgers
once per write and applies `--author/--kind/--since/--until` to the build, so an
inbox polled with a moving `--since` is fast after the first call.

**`lettuce status` is bounded (LET-2003).** It lists the OPEN runtime operation
records and the newest 20, and `runtime.operations_summary` counts the rest
(`retained`, `open`, `listed`, `truncated`). `lettuce status --full` lists every
retained record (it was the default, and on the hosted store it returned 6,529
records, 3.5 MB, and timed out at 30 s).

`project list` needs no project context. A project-scoped command with no context
is refused `FW-CMD-MISSING-PROJECT-CONTEXT`; pass `--project`, set
`LETTUCE_PROJECT`, or pin `project:` in `.lettuce`. To retire a project,
`project archive` (soft, reversible) hides it from `project list`, refuses new
tasks, and makes every read of it ask (`--include-archived`) or say ARCHIVED
(`FW-READ-PROJECT-ARCHIVED` / `-SHOWN`, §3); `project unarchive` reverses it.

### 0.6 Moving a local store onto the hosted service

A project tracked in a repo-local `.lettuce/` FOLDER moves to the hosted API
with `migrate --to`. **Online `migrate --to` no longer has a memory ceiling tied to the store size (bounded server memory plus the `FW-MIGRATION-TOO-LARGE` admission guard, default 1,000,000 manifest entries per group); what scales with the store is the domain-wide fence, which since LET-1836 lasts from session begin to release.** Measured 2026-09-25 on the 454k-file dogfood store into a 512 MiB docker server (development machine): activation (activate + live parity + release) about 6 minutes, end to end about 40 minutes including a ~13-minute source check that runs before the session begins, so the domain is offline for roughly the remaining 27 minutes; re-measure on the target node before a cut-over. The offline import (PVC-CUTOVER-RUNBOOK §8.11) remains
the alternative when that window is not acceptable. The run REFUSES a source
with errors (`FW-MIGRATION-SOURCE-INVALID`, dry run included, no override): fix
or acknowledge each listed error first (MIGRATE-TO-SERVER.md "The source must be
clean"). It prints a progress meter on stderr (`--progress`; NDJSON with
`--format json`), and after live parity compares server-side validate/doctor
with the source (`FW-MIGRATION-DESTINATION-REGRESSED`; `--skip-doctor-compare`). Run it with a binary pinned to the server (§0.1), from the
repo whose local store holds the project. Put the token in `LETTUCE_BEARER_FILE`
(a 0600 file; or `LETTUCE_BEARER`) and give `--to` the token-free URL. `migrate --to`
never takes the token on the command line: a token in the `--to` URL
(`https://<token>@host/`) or `--bearer` is REFUSED `FW-CMD-USAGE` before any
network contact (argv lands in shell history and `ps`). It never appears in any
output, receipt or pointer. **Before step 1, block every local writer** (no lettuce mutation in
this or any other clone/worktree of the repo) until the handoff is committed.

```sh
# 1. DRY RUN FIRST — plans the frozen manifest, author closure and transfer, and
#    PROBES the server: bearer accepted, --domain granted, server release. A bad
#    token, dead port or ungranted domain fails here. Writes nothing anywhere.
lettuce migrate --to https://api.lettuce.huru.ca/ --domain <domain> \
  --project <projectname> --dry-run --root .lettuce --author <you> --format json
# 2. The real run: upload → staged parity → finalize → activate → live parity,
#    then it STOPS with the group activated, live-verified and still FENCED
#    (awaiting_release: true), so the repository switch happens while nothing
#    can write the hosted copy. --write-pointer-candidate is the separate
#    confirmation that writes the credential-free .lettuce pointer to a NEW path.
lettuce migrate --to https://api.lettuce.huru.ca/ --domain <domain> \
  --project <projectname> --root .lettuce --author <you> \
  --write-pointer-candidate ./lettuce.pointer --format json
```

**Interrupted? Re-run the identical command.** At ANY step — upload, complete,
finalize, activate, live parity, release — the re-run locates the group's
durable session on the server and resumes it (or finishes an interrupted
rollback); nothing is re-uploaded or duplicated, and no manual API call is ever
needed. A step whose outcome is unknown (client timeout, dropped connection,
gateway 502/504) is re-issued automatically as an idempotent replay. Every
refusal after a session exists names the exact resume command plus the
session commands, and carries `error.details` (session_id, domain,
failed_step, steps):

```sh
lettuce migrate status   --server-url https://api.lettuce.huru.ca/ --domain <domain> --session <mig_id> --author <you>  # state + the one next step
lettuce migrate release  --server-url https://api.lettuce.huru.ca/ --domain <domain> --session <mig_id> --author <you>  # re-verify live parity, then open the group
lettuce migrate rollback --server-url https://api.lettuce.huru.ca/ --domain <domain> --session <mig_id> --author <you>  # activated, unreleased group -> frozen baseline
lettuce migrate abandon  --server-url https://api.lettuce.huru.ca/ --domain <domain> --session <mig_id> --reason "<why>" --author <you>  # not yet activated: free its quarantine, lift the fence (an admin adds --takeover)
lettuce migrate list     --server-url https://api.lettuce.huru.ca/ --domain <domain>  # admin: every session of the domain — owner, state, progress, fence
```

**Migrating into a domain takes that domain OFFLINE for the whole run.** The
session takes the domain's fence at BEGIN (before the first manifest page) and
holds it through upload, parity, activation and live parity, until release,
rollback, abandon or expiry. Every ordinary request to that domain — **reads
included** — answers `FW-MIGRATION-FENCED` (503, `Retry-After`) naming the
session, its owner, state, progress and ETA; only the migration's own commands,
`migrate status`/`list`/`abandon`, and the health/version/domain probes answer
(`migrate status` and `migrate list` also answer while the activation holds the
domain's write lock, so you can watch it, LET-1949).
So **migrate into a new or empty domain** (`lettuce domain create NAME`), or
plan a window with everyone who uses the target domain. One migration per
domain: a second group is refused `FW-MIGRATION-BUSY` naming the holder
(re-running YOUR identical command resumes it instead); the server also caps
concurrent migrations across domains (`FW-MIGRATION-BUSY`) and refuses a volume
too small for the session (`FW-MIGRATION-INSUFFICIENT-CAPACITY`). A crashed or
killed client never fences the domain forever: the server abandons a session
past its TTL (24h) and lifts the fence; `migrate abandon` does it at once. The
same rules hold for a LOCAL destination (`--to DIR`): commands against `DIR` are
refused until `migrate release|rollback|abandon --root DIR --session <mig_id>
--author <you>`.

One migrate per SOURCE: the run holds an advisory lock on the source store
(`FW-MIGRATION-SOURCE-LOCKED` for a second run) and stops
`FW-MIGRATION-SOURCE-CHANGED` at the next batch if the source is written mid-run
(its own pre-activation session is abandoned so the domain reopens) — which is
why every local writer is blocked first.

**The handoff.** The receipt's `data.next_steps` prints these steps with the
run's real values filled in; follow them in order (writers still blocked):

```text lettuce-example proven-by=TestLET1750HandoffStepsMatchTheDocs
0. receipt: the credential-free receipt (client and server build identity, step ledger, parity digests) is saved at <RECEIPT>   # keep it with the archive; under the source store's .runtime/ it travels in the step-1 tarball, never in the step-6 commit
1. archive: keep every local writer blocked (no lettuce command may write the old .lettuce/ in this or any other clone/worktree), delete its git push credential (an archived store never pushes, and the token must not travel in the tarball or the committed archive), then keep a hashed tarball and a read-only copy: rm -rf .lettuce/.runtime/credentials .lettuce/.runtime/github-askpass.sh .lettuce/.runtime/github-credential && tar -czf ../lettuce-store-<DATE>.tgz .lettuce && shasum -a 256 ../lettuce-store-<DATE>.tgz > ../lettuce-store-<DATE>.tgz.sha256 && git mv .lettuce .lettuce-archive-<DATE> && chmod -R a-w .lettuce-archive-<DATE>   # discovery only finds an entry named .lettuce, so the archive is never picked up as a store
2. pointer: mv <CANDIDATE> .lettuce   # the credential-free candidate written by --write-pointer-candidate (receipt authority_switch.pointer_candidate_sha256)
3. discovery: lettuce domain list --format json && lettuce migrate status --session <SESSION> --author <you> --format json   # from the repo root with LETTUCE_BEARER set, THROUGH the new pointer: the domain list must include <DOMAIN> and the status must find session <SESSION> (proves the pointer reaches https://<host>/, domain <DOMAIN>)
4. release: lettuce migrate release --session <SESSION> --author <you> --format json && lettuce task list --project <PROJECT> --format json && lettuce version   # re-verifies live parity, then opens the group; reads now come from the hosted copy; version must show no FW-CLIENT-VERSION-SKEW
5. notice: add this block to CLAUDE.md (AGENTS.md is a symlink to it): > **Lettuce store moved (<DATE>).** This repo's tasks live on the hosted store https://<host>/ (domain <DOMAIN>, project <PROJECT>); `.lettuce` is a credential-free pointer file. The old store is archived READ-ONLY at `.lettuce-archive-<DATE>/` (tarball ../lettuce-store-<DATE>.tgz, sha256 beside it) — never write to it. Ask the store operator for a token and export it as LETTUCE_BEARER (never put it in the pointer or on a command line). Pin your client to the server's release (`GET /v1/version`, SKILL.md §0.1).
6. commit: git rm -r -q --cached --ignore-unmatch .lettuce-archive-<DATE>/.runtime && git add -A .lettuce .lettuce-archive-<DATE> ':(exclude).lettuce-archive-<DATE>/.runtime' CLAUDE.md && git commit -m "chore(lettuce): move the store to https://<host>/ (domain <DOMAIN>, project <PROJECT>)"   # .runtime/ is per-clone runtime state (receipts, operation host/pid records), never committed (LET-1889)
```

Without `--write-pointer-candidate`, step 2 instead writes the receipt's
`authority_switch.pointer_candidate` inline:

```text
2. pointer: write authority_switch.pointer_candidate byte for byte to .lettuce:
cat > .lettuce <<'EOF'
# lettuce pointer — credential-free (written by `lettuce migrate --to`, session <SESSION>).
# The bearer is NOT stored here: export LETTUCE_BEARER (or LETTUCE_BEARER_FILE).
https://<host>/
project: <PROJECT>
domain: <DOMAIN>
EOF
```

**Several projects in one run (LET-1812).** A pointer binds ONE project, so a
multi-project run (`--project a --project b`, or `--all-projects` migrating more
than one) never binds the first one silently: the receipt's
`authority_switch.pointer_candidates` holds one candidate per project, the
top-level `pointer_candidate` has NO `project:` line (only for a repository that
spans them all — its commands then name `--project`), and
`--write-pointer-candidate PATH` writes `PATH` plus `PATH.<project>` for each.
Step 2 then tells each repository to take the candidate of the project it tracks,
and step 4 checks every project.

With `--release` (rehearsals, or a move that needs no repository switch) the
run releases right after live parity and step 4 becomes:

```text
4. release: already done by --release — confirm reads through the pointer: lettuce task list --project <PROJECT> --format json && lettuce version   # version must show no FW-CLIENT-VERSION-SKEW
```

The pointer always names the domain (the run pins one: your `--domain`, or your
token's default resolved once), so an agent reading it never silently lands in
another domain.

**Going back.** Before release: `lettuce migrate rollback --session <mig_id>
--author <you>` restores the hosted domain to its frozen baseline; then remove
the pointer file and restore the folder (`rm .lettuce && chmod -R u+w
.lettuce-archive-<DATE> && git mv .lettuce-archive-<DATE> .lettuce`) — local
stays authoritative and nothing was lost. After release: only while NO hosted
write has happened, `lettuce migrate rollback --session <mig_id> --author <you>
--allow-released` (the server refuses it after any post-release write), then
restore the folder the same way. After any hosted write, going back would drop
that work: fix forward on the hosted store instead. `migrate --to` never
replaces `.lettuce/` itself. Full walkthrough, the resume matrix and every
recovery case: `docs/guides/MIGRATE-TO-SERVER.md`.

## Working with peers — the pull-based protocol

Coordination is **pull-based over the shared ledger**; nothing is pushed to you.
This is the minimum an agent needs to work alongside others without colliding:

- **One command for the whole picture:** `lettuce work --project <p>` — IN FLIGHT
  (task, status, holder, expiry, last event), NEEDS HELP (blocked / needs-human,
  or a stale EXPIRED lease), AVAILABLE (no active lease). Use it to see who is
  working and what needs a hand.
- **See the actor roster:** `lettuce agents --project <p>` — every identity seen,
  with last-seen, what they last did, event count, and active leases. Use it to
  **avoid reusing an existing name** before you pick one.
- **Find available work:** `lettuce task list --unleased` (or `--status ready
  --unleased`). `--leased` shows what is taken; `--with-lease` adds the holder.
- **Take over stalled work:** `lettuce lease list --status expired` names stale
  leases; take one over with `lettuce lease steal <ref> --expires-at … --reason …`
  (an expired lease can also simply be re-acquired).
- **Review queue:** `lettuce task list --status review`; move a task through
  `submit-review` → `approve`.
- **Unfinished graph runs:** `lettuce graph-run-case list --open` (and `--state
  <node>`) shows runs parked awaiting a branch or join — the places a graph walk
  needs another arm.
- **Recent activity:** `lettuce query timeline --project <p> --since <ts>` (add
  `--author` / `--kind`) is the ledger of who did what. It is a MERGE of
  per-object ledgers (spec §20.8). An operation on a child object
  (comment/run/artifact) is stored on that object AND on the task, but the
  timeline lists it once (LET-589 dedup), so a task's timeline count equals
  `lettuce task audit <ref>`. `query run 'from events'` lists it once too
  (LET-1855: the task-ledger copy, which carries the revision); the child object's
  own copy is read with `lettuce query audit <child ref>`. Both read EVERY ledger of
  the project — tasks, the project's own, registry objects, saved queries, graph defs,
  run cases, carriers, dimensions, states, cells — so they list the same events
  (LET-1894). Events in the same second
  are ordered by their causal position (revision, else a legacy numeric slot; a
  creation first), so a create always precedes the archive that followed it
  (LET-1857, LET-1953) — in the timeline, in `query audit` and in `from events`
  (exactly reversed there). Two same-second `evt_` events of a revisionless ledger
  (the project's own, a dimension's) carry no recorded order and tie-break by
  operation id. FQL `from events` also selects `reason` and `declared-absent` (LET-1978):
  `from events where declared-absent = true select event, field, reason`.
  An event has ONE reference everywhere: an author link's events are `P/event/ID`, also
  in `query audit P/authors/NAME` (LET-1968).
  `query audit P` (a bare live project name) reads the project's own ledger (LET-1969); a
  root author of the same name is `query audit authors/NAME`, and the read then warns
  `FW-READ-AUDIT-NAME-AMBIGUOUS`.
  `query audit` addresses every ledger by the reference the timeline prints (LET-1967),
  the project ladder's too (the ladder, a hidden state or transition, a ladder transition,
  a ladder gate): copy the event's object reference from `query timeline` (or `P/ladder`);
  `lettuce usage query audit` lists every reference shape.
- **Who really wrote it (LET-2004, LET-1276, v0.20.5):** `--author` is a name you
  choose; every event a HOSTED mutation writes also records the credential that
  wrote it and the server build that enforced the rules. `query audit`, `task audit`
  and `query timeline` show `provenance: {method, subject}` (`method` is `token` for a
  named token and `subject` is the token's NAME, never its secret) and
  `build: {version, revision}`; FQL: `from events select author,auth-subject,build-version`.
  Local CLI writes and older events show neither (absence is history, not guilt).

**Operational facts that bite in practice:**
- **Branch on `next_actions`, do not assume a verb terminates.** A workflow can route
  `complete` to a NON-terminal state (a custom workflow's `complete` may land in
  `review`, which then needs `approve`), so a scripted close loop that assumes
  `complete` reaches `done` silently leaves tickets in `review`. `task show REF
  --format json` and every `task transition` RESULT carry `next_actions`: the
  transitions legal from the landed status, each `{action, to, ...gates}`. An EMPTY
  (absent) `next_actions` means the state is terminal; a non-empty one names the
  follow-up. Read it BEFORE firing, and check the landed `status` after.
- **A terminal transition releases your lease.** `task transition … complete`
  emits the transition event AND releases the author's own lease, so the trailing
  `lease release` in older examples is a harmless no-op
  (`kind:"lease-release-noop"`, `present:false`) — don't treat it as a failure.
- **The central store is single-writer.** If you fire mutations in parallel the
  losers get `FW-RUNTIME-WRITER-CONTENDED` (a live writer holds the lock, or is
  still publishing it — a young EMPTY `write.lock` is never a repair, LET-1880 —
  `meta.retryable: true`, retry shortly; against a server the client already
  retries it for you, §0.2); serialize your own mutations, retry, or
  pass `--writer-wait 10s` (or `LETTUCE_WRITER_WAIT=10s`) to WAIT for the live
  writer, bounded (max `2m`), instead of failing — the lock is taken before any
  write, so waiting/retrying is safe. Waiting writers take the lock in ARRIVAL
  order, across processes (LET-1897), so a long waiter is not overtaken by writers
  that arrived after it. A read that overlaps a live write gets the
  same CONTENDED (or `FW-RUNTIME-SNAPSHOT-UNSTABLE` if the write finished mid-read,
  also retryable) and `--writer-wait` re-runs it. `lettuce serve` waits **10s by
  default** (it queues its own requests, so hosted reads no longer fail during a
  write); `--writer-wait 0` is fail-fast. A request that waited reports
  `meta.writer_wait_ms`. `FW-RUNTIME-WRITER-ACTIVE` (`meta.retryable: false`, read
  or write) means the holder is dead (`lettuce recover` clears it) or cannot be
  verified (another host, or a pid owned by another user: confirm it is gone,
  then `lettuce recover --abandon`) — retrying will NOT help, and no wait is
  spent on it. On a hosted server a lock left by a replaced pod is cleared at the
  next server start only when the operator runs `serve
  --single-writer-host-takeover` (ADR-0024); otherwise tell the operator.
- **HTTP query booleans are strict.** Supply an optional boolean query parameter
  at most once, as a real boolean value such as `true` or `false`; malformed,
  empty, or repeated forms are refused with `FW-API-BAD-QUERY`. JSON
  `expected_revision`, when present, must be at least 1 and is validated before
  mutation admission and idempotency-key reservation.

**Rules:** know your project; declare your own actor name (pick one and keep it
for an anonymous session); coordinate with **comments**; **never mutate a peer's
lease**; leases are short (~3h) and held longer by renewing; file every discovery
so the next agent inherits a diagnosis, not a headline.

## Why lettuce (and not just Markdown + a convention)

A Markdown board in your repo is only as honest as the agent editing it: an agent
can write `done` or `hardened` with nothing behind it, and the next reader
believes it. lettuce moves the guarantee from *instruction-following* to *code*.
State transitions are validated rather than requested; leases are atomic,
expiring, and audited rather than a naming convention; and a coverage cell
**cannot** reach `hardened` — or earn hardening *depth* — without linked evidence:
by default the tool refuses the write. The one deliberate exception is
`--facilitate`, which proceeds past a failed gate but RECORDS the failed verdict
(and its `--reason`) on the ledger, so a green that was not earned is visible
rather than silent (§8). And what the gate establishes is *provenance* — the green
traces to a completed, graph-backed ticket — not proof that the cell's subject was
actually checked. State stays greppable, exportable and auditable **through the
binary** (`query`, `export`, `task audit`, `--format json`), but what it *claims*
is enforced by the binary, not by the goodwill of whoever wrote it.
That is the whole point — **determinism over "please follow the convention."**
If a rule in `CLAUDE.md` reliably did the job you would not need lettuce; you
reach for lettuce when *verified* must mean **proven**, not merely **asserted**.

## Contents

1. [Mental model](#1-mental-model)
2. [Getting details — the binary is self-documenting](#2-getting-details--the-binary-is-self-documenting)
3. [Defaults — the shipped configuration](#3-defaults--the-shipped-configuration)
4. [Agent rules (non-negotiable)](#4-agent-rules-non-negotiable)
5. [Command map (every command, grouped)](#5-command-map-every-command-grouped)
6. [The core agent loop](#6-the-core-agent-loop)
7. [Reading and querying (FQL)](#7-reading-and-querying-fql)
8. [Cells: the coverage model](#8-cells-the-coverage-model)
   - [8.1. Running lettuce as an agentic development loop](#81-running-lettuce-as-an-agentic-development-loop)
9. [Graph authoring & enactment](#9-graph-authoring--enactment)
   - [9.1. Every work item is a ticket; every ticket runs a graph-run-case](#91-every-work-item-is-a-ticket-every-ticket-runs-a-graph-run-case)
10. [Output formats and exit codes](#10-output-formats-and-exit-codes)
11. [Modes](#11-modes)
12. [Invariants and gotchas](#12-invariants-and-gotchas)

## 1. Mental model

- **The store is the source of truth, and it is internal.** Inspect and
  validate it through the binary — but the two checks are not equivalent.
  `lettuce validate --strict` verifies the store is well-formed (grammar); it
  is deliberately SILENT on a derived scalar forged out of band — a hand-edited
  task `status` passes it, valid and rc 0. `lettuce doctor` is what tells you
  the store is HEALTHY: it additionally catches that derived/status drift
  (`FW-STORE-DERIVED-DRIFT`) and tells you how to repair it. For a health check
  run `doctor`; `validate --strict` alone is not one (their divergence is
  tracked in LET-1723). `query`, `export` and `--format json` answer everything
  else. Validate IS pack-aware where the board is: a project `pack` pointer
  naming a pack this binary does not bundle is `FW-PACK-UNRESOLVED` (LET-533),
  and when the pack resolves a cell `state` outside its vocabulary (pack states +
  declared ladder states, compared raw — ` hardened` is not `hardened`) is
  `FW-CELL-STATE-UNKNOWN` (LET-537). `FW-CELL-STATE-UNKNOWN` stays repairable
  (`cell set`) because the mutation gate downgrades it. `FW-PACK-UNRESOLVED` is
  still gate-downgradable too (it will not itself block other mutations), but as
  of LET-1234 (2026-09-23) there is no supported command that repairs the pointer
  — `pack enable` never had a CLI arm and its one HTTP-only door is now removed.

  **Scope your check to what you own.** A repo-local store has one owner, who
  may run `lettuce validate --scope store` / `lettuce doctor` over the WHOLE
  store as a CI gate. On a SHARED multi-tenant server the caller owns one project,
  so pass `--project <p>`: `validate --project <p>` validates ONLY that project
  (an explicit `--project` with no `--scope` implies a project scope), and
  `doctor --project <p>` reports only that project's grammar findings and probe
  results. An explicit `--scope store` is still honoured verbatim and validates
  every project — use it deliberately, not by default.

  **On a hosted server, health is read, not re-scanned (ADR-0022, LET-1835).** A
  full `doctor`/`validate` there is an O(store) scan on a shared process, so it
  needs the **admin** role (a project-scoped admin may scan only its project;
  `validate --scope runtime` stays a cheap reader call). Every other agent runs
  `lettuce doctor --summary`: the server's LAST COMPUTED health for the domain
  (status, counts by code, the generation it was computed at, its age, and
  whether a write happened since). It never starts a scan and always exits 0 — it
  is a report, never a gate. An admin `lettuce doctor`/`validate` against a
  server **never runs the scan inline**: scans are server-owned background jobs
  (a disconnect never cancels one). You get, within a few seconds, the report
  current at this writer generation; or, after a write, the LAST completed report
  with `meta.stale: true` while the server refreshes it (`meta.health_refresh`,
  human line starts `health: STALE`); or, when the server has no report yet
  (a restart, new options, a huge store), **`FW-API-HEALTH-PENDING` and exit code
  8** — "not ready, try again later", never a finding. Rerun later, or pass
  `--wait[=DURATION]` (default 30m) to poll with the server's `Retry-After`
  until a current report is ready (progress on stderr). Concurrent requests for
  the same report share one scan; the JSON envelope says which kind of answer it
  is (`meta.health_source: fresh|cached`, `meta.generation`, `meta.computed_at`,
  `meta.age_seconds`, `meta.stale`), so never read a cached or stale report as a
  new scan, and never gate a mutation on it. A full scan queue answers 503
  `FW-API-HEALTH-SCAN-BUSY` and a spent hourly budget 429
  `FW-API-HEALTH-SCAN-RATE-LIMITED` (both with `Retry-After`) when there is no
  earlier report to serve. The server also refreshes known reports on its own
  (every 6h, and after a migration release/rollback). Hosted doctor skips the
  local-only git and runtime-skeleton families and lists them in
  `not_applicable`. Operator guide: `docs/operations/HOSTED-HEALTH.md`.
- **Mutations are explicit and attributed.** Always pass `--project` and
  `--author`. Never infer them from the OS, Git config, or store contents.
  Every mutation is recorded as an event (`task audit`, `query timeline`).
- **Concurrency is coordinated by leases and revisions.** Claim a task with a
  lease before working it; pass `--expect-revision N` when retrying or racing
  other agents. Writes are serialized by a single-writer generation lock.
- **Output is a stable envelope.** With `--format json|yaml` every result is
  `{ ok, data, warnings, meta }`; errors are `{ ok:false, error:{ code,
  message, diagnostics:[...] } }` with machine-readable diagnostic codes.
- **`meta.command` names the command you INVOKED, not the internal one it routed to
(LET-381).** `milestone close v1` reports `milestone close`, even though it delegates to
the registry updater — so an agent can match a reply to the request it sent. Two edges
this does *not* cover, both deliberate: on a **replay** the recorded bytes of the first
call are re-delivered, so `meta.command` names the route that made the **original**
call, not the replaying one; and the **idempotency record's own `Command` is a different
field that did not change** — both routes stay one operation so they keep deduplicating
against each other. Do not read the envelope's `command` as the idempotency key's.
- **`data` NESTS ONE LEVEL DEEPER ON MUTATIONS — read this before writing a parser.**
  The envelope is stable; the shape of `data` inside it is not the same for both
  command classes, and that catches every automated consumer once:

  | class | shape | example |
  |---|---|---|
  | **read** (`show`, `list`, `audit`, `query …`) | payload is `data` itself | `.data.tasks`, `.data.present` |
  | **mutation** (`create`, `set`, `add`, `transition`, `acquire`, …) | payload is `data.data`, sharing the level with the operation attribution | `.data.data.id`, alongside `.data.operation_id` |

  So reaching for the READ path on a mutation — one `data` short of the payload —
  yields **null**, not an error, and every later command that consumes it then runs
  against an empty value. (That wrong path is deliberately not spelled out here as a
  copyable token: SKILL.md is what agents copy from, and a literal example of the
  broken form gets pasted no matter what the surrounding prose says.) Censused
  empirically:
  6/6 mutations nest, 7/7 reads are flat, and the split follows the emitter, so it
  holds for commands not listed here.

  **Unwrapping both with one expression:** `.data.data // .data` (jq) works *today*
  because no read payload has its own `data` key — but treat that as an observation,
  not an invariant, but it is now guarded by a **census enumerated from the usage
  catalog** (LET-1217), not a hand list. `TestLET1217CensusPartitionsTheCatalog`
  requires every command the catalog classifies `read` or `mutation` to be either
  driven by the census or excluded — and **excluded only because its own usage line
  requires a positional argument**, never because it was awkward. A newly added
  command satisfies neither and fails BY NAME until someone classifies it; that
  tripwire, not the coverage number, is what a hand list structurally could not do.
  The **population is complete and pinned; coverage within it is partial and named**:
  of 164 classified commands it drives **41** (the other 123 need an argument
  fixture), and of those 41 it asserts payload shape on **29** — the remaining **12**
  return an error envelope on a bare store and are pinned by name in
  `let1217NotShapeChecked`, so that set cannot grow silently either. Do not read it
  as "the catalog is checked".

  The nesting is deliberate — `operation_id`, `event`, `kind`, `author`, `at` and the
  `revision_before`/`revision_after` pair are audit evidence a mutation must return and
  a read has none of. Flattening it would mean dropping that evidence or inventing it
  for reads; whether the operation fields should instead move to `meta` is open
  (LET-923), and would be a schema-version change, not a silent fix.
- **Two data planes:**

| Plane | Objects | Question it answers |
|---|---|---|
| Work | projects, tasks, workflow, leases, runs, artifacts, comments | what is being done, by whom, with what evidence |
| Coverage | packs, dimensions, members, cells, gates, boards | how *proven* each part of the product is |

## 2. Getting details — the binary is self-documenting

This guide teaches the model; the **full reference lives in the binary**, so it
works with no store and no network. When this guide is not enough, the binary
carries the **whole wiki** too:

- `lettuce docs` — print the embedded wiki HTML explorer; `lettuce docs list`
  enumerates every concept id; `lettuce docs show <concept>` streams one page as
  Markdown (concepts and guides by slug, e.g. `guide-first-contact`); `lettuce
  docs export --out DIR` writes the HTML + flat Markdown for an agent.
- `lettuce okf docs render --site DIR` — render lettuce's OWN embedded wiki
  bundle into one self-contained navigable HTML site; `lettuce okf docs export
  --out DIR` and the mounted okf CLI (`validate`, `lint`, `render`, `graph`,
  `search`) also run against it.
- `lettuce usage --format okf --out DIR` — the machine OKF command-reference
  bundle.
- The wiki is also served at `/docs` and `/docs/flat.md` by `serve`.

For any command, get the exhaustive, always-current contract with:

- `lettuce usage` — the complete CLI documentation (all commands, all flags).
- `lettuce usage <command-prefix>` — filtered, e.g. `lettuce usage query saved`.
- `lettuce <command> --help` — quick per-command help.
- `lettuce usage <cmd> --format json` — machine metadata per command: `kind`
  (read | mutation | maintenance | local | server), `requires_root`,
  `requires_project`, `requires_author`, `stable_snapshot`, `idempotency_key`,
  `external_input_keys`, flags, examples. **Trust this over any prose.**
- `lettuce dimension list --project <p>` — the project's effective coverage
  dimensions; `dimension show <slug> --project <p>` reads one axis in full.
- `lettuce doctor --format json` — when the store is unhealthy: diagnoses plus
  repair suggestions. Add `--project <p>` on a shared server to scope the report
  (and its probe findings) to the project you own; omit it to diagnose the whole
  store (the repo-local owner's CI gate).

  **A gate must read the verdict fields, not `ok`.** `ok:true` is the ENVELOPE's
  success and is true on any right-root run; the verdict is `data.valid` and
  `data.summary.errors`. Several findings are deliberately WARNING-severity (e.g.
  `FW-LEASE-ON-TERMINAL-TASK`, a lease held on a done task), so they do NOT flip
  `valid` and do NOT change the exit code — a cron/CI gate keyed only on the exit
  code or `valid` will miss them. To gate on warnings, read `data.summary.warnings`
  or filter `data.diagnostics[].code`; `lease list --task-terminal` is the
  categorical form of the terminal-lease condition.

**Agent Playbook (do-this-now guides).** For step-by-step walkthroughs beyond
this reference, the embedded wiki carries a three-part playbook — read them with
`lettuce docs show <slug>` (or browse `lettuce docs`):

- `guide-first-contact` — a fresh agent's exact first moves (skill/docs/usage/status/board).
- `guide-project-setup-playbook` — prepare a project: scopes, dimensions (+ methodology), an evaluable DoD.
- `guide-agentic-loop-demo` — one dev loop end to end, showing what you GET and DO at each step.

## 3. Defaults — the shipped configuration

Precedence everywhere: **explicit flag → environment variable → `--config`
JSON file → built-in default**. A blank value is a statement, not an omission,
in every mode (local and hosted): EVERY value-taking global flag given an empty
or whitespace-only value (`--root/--store/--project/--author/--config/--mode/
--domain/--server-url/--bearer/--format/...  ""`) is refused `FW-CMD-USAGE`, and
so is a SET-but-empty (or whitespace-only) variable wherever it is consulted —
`LETTUCE_ROOT/PROJECT/AUTHOR/MODE/CONFIG/STORE/SERVER_URL(_FILE)` locally,
`LETTUCE_STORE/PROJECT/DOMAIN/ACTOR(_FILE)/BEARER(_FILE)/AUTH_MODE/
ORGANIZATION(_FILE)/ORGANIZATION_TOKEN(_FILE)` in hosted (client) mode,
`LETTUCE_DOMAINS_ROOT`, the `serve` credential sources
(`LETTUCE_API_SECRET(_FILE)/TOKENS_FILE/AUTHZ_POLICY_FILE/
WORDMADE_ID_ORGANIZATION_TOKEN_FILE`) and `LETTUCE_GITHUB_PAT(_FILE)` — rather
than falling through to the next source (`unset` the variable to fall through;
LET-639, LET-1939). A blank that something outranks decides nothing and is not
refused (`--bearer` beats `LETTUCE_BEARER`, which beats `LETTUCE_BEARER_FILE`; a
`.lettuce` pointer beats `LETTUCE_SERVER_URL`). `LETTUCE_SERVER_URL=` is no longer
an exception (v0.20.4): it used to "not activate client mode", so the command ran
against whatever local store discovery found. Tuning knobs (`serve` rate/health/
migration/runtime settings, `LETTUCE_WRITER_WAIT`, `LETTUCE_PROJECTION`, …) keep
"blank = not configured = the default".

| Flag | Env var | Default |
|---|---|---|
| `--root PATH` | `LETTUCE_ROOT` | the nearest `.lettuce` directory found by walking up from the working directory (dedicated-git mode: current directory). **No home fallback — if no store is named or discoverable the command refuses.** |
| `--project NAME` | `LETTUCE_PROJECT` | none — explicit where required |
| `--author NAME` | `LETTUCE_AUTHOR` | required for local/trusted mutations; **forbidden as explicit input in Wordmade-ID HTTP mode**, where the verified token selects identity |
| `--mode` | `LETTUCE_MODE` | `filesystem` (other: `dedicated-git`) |
| `--format` | — | `table`. Others: `plain` (references only, §10), `json`, `yaml`; `markdown` only for `doctor`, `usage`, `skill`; `html`, `okf`, `mermaid`, `dot` only for the commands that produce them |
| `--server-url URL` | `LETTUCE_SERVER_URL` (`_FILE`) | none. The `--server-url` flag switches the CLI to HTTP-client mode; the `LETTUCE_SERVER_URL` env does too **unless a repo-local `.lettuce` is discovered, which overrides the env** (the flag still wins). Fallback URL `http://127.0.0.1:8727` |
| `--bearer` / actor | `LETTUCE_BEARER` (`_FILE`), `LETTUCE_ACTOR` (`_FILE`) | none; Actor is a trusted self-declared name in shared mode only, not a verified identity |
| `--auth-mode` | `LETTUCE_AUTH_MODE` | `trusted-shared` for backward-compatible CLI use; `wordmade-id` requires verified agent credentials and sends no Actor header |
| `--organization` | `LETTUCE_ORGANIZATION` (`_FILE`) | required with organization token in Wordmade-ID mode |
| `--organization-token-file` | `LETTUCE_ORGANIZATION_TOKEN_FILE` | secret file for the additional organization admission token; no raw-secret flag |
| `--domain NAME` | `LETTUCE_DOMAIN` | none — the bearer token's default domain. Client mode only (and `migrate --to URL`); sent as `X-Lettuce-Domain`. See "Domains" below |
| `serve --listen` | `LETTUCE_LISTEN` | `127.0.0.1:8727` |
| `--config PATH` | `LETTUCE_CONFIG` | none |

Other `serve` flags (secret, IP allowlist, rate limit, authz policy,
auto-provision actors, push interval) map to `LETTUCE_API_SECRET`,
`LETTUCE_ALLOWED_IPS`, `LETTUCE_RATE_LIMIT`, `LETTUCE_AUTHZ_POLICY_FILE`,
`LETTUCE_AUTO_PROVISION_ACTORS`, `LETTUCE_PUSH_INTERVAL` — defaults: see
`lettuce usage serve`. GitHub bootstrap: `LETTUCE_GITHUB_PAT` (`_FILE`),
`LETTUCE_GITHUB_API_URL`.

**Read projection — `LETTUCE_PROJECTION` (default on; `off` disables).** `task
list`, `task show` (without `--with-body`), `cell list` and `registry list` /
`milestone list` / `workflow list` answer from an in-memory projection of the
store recorded at the current writer-generation and persisted under
`.runtime/state/projection/` (ADR-0019 Phase 1, LET-1714): a repeat `task list`
between two mutations costs milliseconds instead of a full store walk, with
byte-identical output. Every lettuce mutation retires it. An edit made **outside**
lettuce (hand edit, `git pull`/merge, rsync) does not advance the generation, so
reads keep showing the pre-edit answer until the next lettuce mutation — run the
read with `LETTUCE_PROJECTION=off` (or delete `.runtime/state/projection/`, always
safe) to see the files as they are now. Validation, gates and every write path
never use it.

**Content-Type on POST — mutations enforce JSON, three read routes do not.** A
mutation POST with a non-JSON `Content-Type` is refused `415`
`FW-API-UNSUPPORTED-MEDIA-TYPE`. `v1/query/run`, `v1/query/tasks` and
`v1/repair/check` are POSTs that only READ, and are exempt: they accept any
`Content-Type` and decode the body as JSON, so a genuinely wrong body fails at
decode rather than with a clean `415` — a worse error message, not a weaker
gate, since the decoder accepts JSON only.

The exemption is a consequence of read semantics, not laxity. Those three
routes are classified non-mutating so they take a stable snapshot, skip the
`X-Lettuce-Actor` impersonation check, and skip idempotency-header handling.

Role is decided separately, and earlier: `v1/query/run` and `v1/query/tasks`
need only reader, but `v1/repair/check` is a **maintenance** route and needs
**maintainer** — `authorizationMaintenanceRoute` matches `POST v1/repair/{check,
apply,automatic}` and returns before the mutation predicate is consulted at all.
A route that is content-type-exempt *and* gated harder on role is the clearest
evidence that the two are decided by different predicates. **If a uniform
Content-Type policy is ever wanted, it needs its own predicate over "this request
carries a body the server decodes" — widening the mutation predicate to reach the
content-type gate would silently change all of those behaviours instead.**

**Internal `.runtime/` paths appear in diagnostic `path` fields.** A caller with
any authenticated role can see them: with a write lock present, `GET v1/doctor`
returns `"path":".runtime/locks/write.lock"` alongside
`FW-RUNTIME-WRITER-CONTENDED` (live owner) or `FW-RUNTIME-WRITER-ACTIVE` (dead or
unverifiable owner); on a healthy store no such path appears. This is
intended, not a leak — the paths are relative, operator-oriented, and carry no
secrets, and a diagnostic that named no path would tell the operator to repair
something without saying what. Documented so that the exposure is a stated
property rather than a surprise to anyone auditing the surface.

**What every new project ships with (the `default` workflow and registries):**

- **Workflow `default`** — states `open` (initial) → `ready` → `active` →
  `review` → `done`, plus `blocked`, `needs-human`, and terminals
  `done`/`failed`/`canceled` (hard sinks — only `task reopen` leaves them).
  Happy path: `mark-ready` → `start-work` (**requires an active lease**) →
  `complete` (or `submit-review` → `approve`). Detours:
  `block`/`unblock`, `request-human`/`human-resolved`, `request-changes`,
  `cancel`, `fail` — every non-happy-path action requires `--reason`.
  Terminals are hard sinks; leave them only via `task reopen`. Full edge
  list: `lettuce workflow show default`.
- **Severities**: `low`(rank 10), `medium`(20), `high`(30), `critical`(40).
- **Task types**: epic, story, task, subtask, feature, bug, research, design,
  ops, doc, incident, chore. Priority is an integer `0..100`.
- **Artifact types**: patch, test-report, screenshot, design, benchmark,
  migration, api-spec, log-excerpt, review-report, deployment-receipt, other.
- **Coverage pack**: `coverage` (a project with no pack pointer is on
  `coverage`); an untouched cell coordinate reads as the pack default state
  (`untested`) with `stored=false`.
- **Lease token**: generated when omitted on `acquire`
  (form `lease-YYYYMMDD-HHMMSS-<4..32 alnum>`).
- **Archived TASKS are hidden** from every list/query surface by default;
  `--include-archived` opts in.
- **An archived PROJECT is never read silently (v0.20.0).** It hides from
  `project list` and refuses new work, and every read of it is either refused or
  labelled ARCHIVED:
  - a project-wide read scoped to it (`task list`, `query tasks|run|search|
    timeline|graph`, `query saved list|run`, `board *`, `dod show`, the project's
    lists, `work`, `agents`) is **refused** `FW-READ-PROJECT-ARCHIVED` (exit 1);
    the refusal prints your command with `--include-archived` appended;
  - with `--include-archived` it is answered and **labelled**:
    `FW-READ-PROJECT-ARCHIVED-SHOWN` on stderr (table/plain; stdout still carries
    only the references) or in `warnings[]`, plus `data.project_archived_at`
    (json/yaml);
  - a read by explicit reference (`task show REF`, `comment show`, `registry
    show`, `comment list REF`, …) is answered and labelled — the reference is the
    ask;
  - a store-wide `query tasks`/`query run` (no project context) leaves archived
    projects' rows OUT unless `--include-archived`.
  Treat any `FW-READ-PROJECT-ARCHIVED-SHOWN` result as retired history, never as
  live backlog. Scope a read with `--project`.
- **Archived comment parents do not hide their live replies.** `comment list
  REF` keeps the reply and emits `FW-READ-COMMENT-PARENT-HIDDEN` when its existing
  parent is archived and omitted from that list. `--include-archived` returns
  both and silences the advisory. `comment show REF COMMENT` reads one explicit
  comment, including an archived parent, and does not emit that list advisory;
  it accepts no `--include-archived` flag.

## 4. Agent rules (non-negotiable)

- Prefer `--format json` for automation.
- **Know your project before you act.** Take it from the `.lettuce` binding, the
  operator/task instruction, or `project list`. If you cannot tell which project
  the work belongs to, **ASK the human/operator — do not guess** and do not
  scan projects hoping one fits.
- **Do NOT create projects.** `project create` is an operator decision (adding
  an additional project requires `--yes`) and unaccounted projects linger with
  no purpose. Create one only when explicitly instructed. Retire an unneeded one
  with `project archive` (reversible) rather than leaving it active.
- **Pick ONE identity name and keep it.** Named personas use their persona name;
  a long anonymous session must choose a stable name (e.g. `agent-<short-token>`)
  and reuse it in its context/memory across restarts. Never `lettuce-operator`.
- **Lease short (~3h), then renew.** A short, expiring lease lets another agent
  take over (`lease steal` / re-acquire) when you are gone; a long window is a
  permanent lock. See section 0.4.
- Pass `--project` and `--author` explicitly on every mutation.
- Use fully qualified references like `project/TASK-1` when context is ambiguous. A
  qualified reference wins over `--project`; if you also TYPE a different `--project`,
  the command warns `FW-CMD-AMBIGUOUS-PROJECT-CONTEXT` (LET-1799) — drop one of the two.
  Registry objects, milestones, workflows and workflow transitions resolve the same way
  (`registry show q/label/red`, `milestone show q/milestone/m1`, `workflow show
  q/workflow/default`, `workflow transition show q/workflow/default/transition/3`), with
  or without a project context (LET-1916).
- Use `--expect-revision` on event-bearing mutations when retrying/coordinating.
- Use `--idempotency-key` only on commands that advertise `idempotency_key=true`
  (see `lettuce usage <cmd>`). The record is runtime-only and **per working copy**:
  it does not deduplicate across dedicated-git clones — retry from the SAME clone,
  and use `--expect-revision`/leases for cross-clone coordination. A replay of a
  completed key is answered even while the clone is behind its upstream. The key
  names the MUTATION, not its rendering: a replay may use any `--format` (LET-1867).
- Run `validate --strict` and `doctor --format json` after repair, import, or
  conflict work. `doctor` (and `reconcile --project P`) also report, as Class-B
  conflicts that are never rewritten, a registry scalar no event explains (LET-1797)
  and an artifact whose payload files no longer match the `content-hash` its creation
  event recorded (LET-1263). `validate` WARNS `FW-EVENT-BEFORE-OWNER-CREATED-AT` when an
  event is dated more than 24h before its object's `created-at` (LET-540).

## 5. Command map (every command, grouped)

`M` = mutation/maintenance (mutations need `--author`), `R` = read, `L` = local
(no store needed). Flags and examples: `lettuce usage <command>`.

**Core & runtime** — `usage`(L) print CLI docs · `skill`(L) print this guide ·
`okf`(L) emit the OKF command-reference bundle (`usage --format okf --out DIR`) ·
`version`(L) · `self-update` install the bound server's client release in place
(`--check`, `--dry-run`; §0.1) · `init`(M) create a store (`--bootstrap-project`,
`--bootstrap-author`, `--idempotent`; the bootstrap project is created only
while init creates the store — on a store that already holds projects an absent
`--bootstrap-project` is skipped with the warning `FW-PROJECT-BOOTSTRAP-SKIPPED`
and nothing is written, unless `--yes` asks for that SECOND project) · `status`(R)
store/runtime/Git health ·
`validate`(R) (`--scope store|project|task|runtime`, `--strict|--loose`; an
explicit `--project` with no `--scope` implies a PROJECT scope, while
`--scope store` still validates every project) ·
`doctor`(R) diagnosis + repair guidance (`--project P` scopes the report — and
its probe findings — to P; hosted: admin-only scan, `--summary` reads the last
computed health without scanning) · `recover`(M) heal interrupted
operations / a stale writer lock / an orphaned idempotency claim (`--report-only`
inspects without writes and lists orphaned idempotency claims, unindexed active
operations and leftover recovery undo journals with their change counts in
`warnings[]`; `--abandon` force-clears an unprovable owner) ·
`cleanup`(M) drop released runtime data (a hosted `serve` also does this on its
own every `--runtime-cleanup-interval`, keeping `--runtime-retention` of history).

**Authors & projects** — `author add|list|deactivate|reactivate` (root-scoped —
global authors only; an explicit `--project` on `author add|list` is REFUSED with
`FW-CMD-USAGE`, naming `project author add|list` instead of silently acting on the
wrong scope. **Author
names are case-insensitive, stored spelling preserved:** `--author alice` against a
registered `Alice` acts and is recorded as `Alice`, locally AND over HTTP
(`X-Lettuce-Actor: alice` folds the same way). Author-VALUED fields fold too, on both
transports: `--assignee/--reviewer/--reporter alice`, `task set … assignee alice`,
`watchers`, and author-kind custom values are stored as `Alice`. Only a name matching
no single registered spelling is refused, naming the stored spelling(s). Minting a
second case spelling of an existing author is refused with `FW-AUTHOR-CASE-COLLISION`,
and a custom-field allowed value that differs from an existing one only by case is
refused too (author names and allowed values are the only mixed-case path names). **Authors are never
deleted:** `author deactivate NAME --reason TEXT --author <you>` revokes a name
append-only — history stays valid, the decision is an `author-deactivated` event
(`query audit authors/NAME`), and the gate then refuses that name with
`FW-AUTHOR-INACTIVE` as the acting author of EVERY mutation — root-scoped ones
(`author add`, `project create`, `project id-block grant`), dry-runs and idempotent
no-ops included, locally and over HTTP (`X-Lettuce-Actor`) — and as an
assignee/reviewer or new project member. The last active author cannot deactivate
themselves. Another active author reverses it with `author reactivate` (both work in client mode:
`POST /v1/authors/{author}/deactivate|reactivate`, admin role). This revokes a NAME,
not a credential — revoking a server API token is separate) ·
`project create|list|show`
(**one store = one project.** `project create` refuses an ADDITIONAL project with
`FW-PROJECT-ADDITIONAL-UNCONFIRMED`, naming what is already there, unless you
pass `--yes`/`--force` — over HTTP, `"confirm": true`. The board, cell coverage,
run-cases and most queries are project-scoped, so a store split across projects
has no single view able to relate its tickets, cells and run-cases. It is a
confirmation, not a ban) ·
`project set-name` (display name) · `project rename OLD NEW` (change the slug:
re-slugs the project across the whole store, rewriting every stored OLD-qualified
reference so the store stays strict-valid; whole-store, atomic; preserves the
display name and all data; refuses with `FW-PROJECT-REF-REWRITE-UNSAFE` if a ref
to OLD sits inside a content-addressed identity; delete tombstones are write-once and
move unrewritten, and `query audit NEW/…` still answers an object deleted before the
rename, LET-1945) ·
`project merge SRC DST` (consolidate: folds SRC
into DST and removes SRC — every task/cell/artifact/comment/run/saved-query/
graph object moves in, shared registry vocabulary is reconciled, every stored
`SRC/…` reference is rewritten; whole-store, atomic, nothing overwritten or
dropped — it REFUSES with `FW-PROJECT-MERGE-CONFLICT` if an id/coordinate exists
in both projects, a shared registry slug differs semantically, or a ref to SRC
is folded into a CONTENT-ADDRESSED identity) ·
`project archive|unarchive` (soft, reversible;
archived projects hide from `project list` and refuse NEW work — tasks, cells,
carriers, run-cases and other new objects; edits of existing ones stay allowed;
a project-wide READ of one is refused `FW-READ-PROJECT-ARCHIVED` unless
`--include-archived`, and every answered read is labelled
`FW-READ-PROJECT-ARCHIVED-SHOWN` + `project_archived_at`) ·
`project delete` (irreversible; `--yes`, `--cascade` for non-empty, optional `--reason`, recorded
on the tombstone AND the `project-deleted` event; its
tombstone keeps the project's earlier delete tombstones and their deletion events (v0.20.5), so `query audit P/TASK` and
`query timeline --project P --task TASK` still answer) ·
`project move P --to-domain DEST` (hosted: move P to ANOTHER DOMAIN of the same
server — one atomic rename under both domains' locks, nothing inside P rewritten;
admin token reaching BOTH domains; `--dry-run` first, then `--yes`; refused
`FW-PROJECT-MOVE-CROSS-REFERENCE` while P references, or is referenced by, another
project. Afterwards every repository bound to P changes its pointer's `domain:`
line — the old one is refused `FW-REF-MISSING-PROJECT` naming the new domain) ·
`project author add|list` (project membership).

**Registries & workflow** — `registry create|update|list|show KIND SLUG` with
KIND ∈ label, component, milestone, workflow, task-type, artifact-type,
severity, custom-field · `milestone create|list|show|set-stage|close|reopen` (sugar over the
milestone registry; `show` includes task-completion progress; `set-stage`
progresses the first-class hypothesis-ladder stage) ·
`workflow list|show|revise`, `workflow transition list`.

**The workflow is versioned, and a judgement pins the version it was made under
(LET-727).** A workflow is the bar a task is judged against, so it is recorded
append-only as an immutable, versioned spec, and a transition into a TERMINAL
state stamps `workflow-version` + `workflow-effective-hash` onto its event. This
does not stop anyone lowering a gate — anyone with write access can — it stops
them lowering it *retroactively*: the old version is still recorded and the
closed task still names it. `workflow show` reports which recorded version the live
policy matches; `doctor` raises `FW-WF-POLICY-UNVERSIONED` when it matches none,
which is what an out-of-band edit looks like. `validate --strict` deliberately
stays quiet — a hand-edited workflow is still well-formed, and this is a semantic
finding, not a grammar one. A workflow with no recorded version (any store
predating LET-727) is reported as unversioned, never as a violation.

A `requires-*` gate (assignee, field, label, artifact type) is checked only when the
transition runs. If a task in a terminal state later loses what the gate required
(`task unset`, `custom clear`, a label removed), the task stays terminal and `validate`
stays clean. `doctor` then WARNS `FW-TASK-COMPLETION-GATE-UNSATISFIED` (LET-634) when
every transition into that state is gated, the task now satisfies none of them, and its
ledger records the field change after the admitting transition (a task completed before
the gate existed, or through a recorded `--facilitate`, is not flagged). The gate it judges
is the one PINNED on the admitting transition (`workflow-version`, re-hashed against
`workflow-effective-hash`) when that pin resolves, so a gate added by a later `workflow
revise` never re-judges a task it did not admit (LET-1517). It
never blocks anything; restore the field with `task set` / `custom set` / `task
set-list`, or leave the warning if the removal was intended.

**`workflow revise` is how you CHANGE that policy — including on a store that
already has tasks (LET-904).** `registry create workflow` cannot: `--idempotent`
refuses a changed definition (`FW-CMD-IDEMPOTENCY-CONFLICT` — idempotent means
no-op, not overwrite) and a task cannot be moved between workflows, so before
this a gated workflow could be authored but never applied.

```bash lettuce-example proven-by=TestCLIWorkflowReviseUnconfirmedNamesTheBlockedTasksAndWritesNothing
# Dry run FIRST — without --yes it refuses and lists every task the new gates
# would block, and writes NOTHING.
lettuce workflow revise default --workflow-file ./gated.json \
  --root .lettuce --project lettuce --author agent-1 --format json
# Then commit to it.
lettuce workflow revise default --workflow-file ./gated.json --yes \
  --root .lettuce --project lettuce --author agent-1 --format json
```

`--workflow-file PATH` / `--workflow-json JSON` carry the COMPLETE new
definition (same schema as `registry create`); there is no default, because
defaulting would silently reset a customised policy to stock. The definition
file you pass is your own, kept outside the store. It mints the next workflow
version, re-pins its `effective-hash`, and makes it the live policy; prior
versions are never touched. An **identical definition is a no-op** (`revised:
false`, no version minted) so re-running a deploy script cannot inflate the
history. An unsound definition is refused with nothing written. Local,
dedicated-git, and hosted backends are supported. Over HTTP, revision needs
the **admin** role on the project and an acting author; the same applies to
`--declare-default-outcomes` (LET-1865). Note `requires_lease` /
`requires_reason` are never reported as breakage — the caller satisfies those at
transition time, so they strand nobody.

**Terminal outcomes (LET-1261, ADR-0025).** A terminal state declares what kind
of ending it is — `"outcome": "completed" | "abandoned" | "failed"` in the
workflow definition — and every delivered count reads that declaration, never
the state's name (milestone `completed_count`; `done_count` still counts every
terminal task). New stores are born with the default outcomes (`done` =
completed, `canceled` = abandoned, `failed` = failed). A workflow that declares
no outcome keeps the legacy rule (a state named `done` counts; the milestone
says `completed_basis: legacy-name`) and `doctor` names it
(`FW-WF-OUTCOME-UNDECLARED`). To adopt the defaults on an existing store — one
recorded, idempotent policy version, never an automatic write:

```bash
lettuce workflow revise default --declare-default-outcomes \
  --root .lettuce --project lettuce --author agent-1 --format json
```

It refuses (`FW-WF-OUTCOME-NOT-DEFAULT`) a workflow whose terminal states are
not exactly `done`/`canceled`/`failed`; declare those by hand in a
`--workflow-file` revise.

**Tasks** — `task create|show|list|exists` · `task set|unset` (`task set REF FIELD --none [--reason
TEXT]` declares a milestone's absence, below; scalar fields:
title, priority, severity, type, assignee, reporter, reviewer, component,
milestone, estimate, due-at, parent, workflow, external-url, external-ci,
external-merged) ·
`task set-list` (labels, depends-on, blocks, relates-to, watchers) ·
`task set-where` (bulk set via FQL `--where`; `--confirm` for >1 match,
`--dry-run` previews) · `task transition REF ACTION` · `task clone` ·
`task reopen` (terminal → initial) · `task archive|unarchive` (soft) ·
`task delete` (hard; `--yes`, `--cascade` for children) · `task body add`
(bodies are versioned, never edited in place; read an old one with `task show REF
--with-body --body-version V`, V a slot id or a 1-based position oldest first —
`--body-versions` lists both; `comment show` takes the same selector) · `task audit`
(event history) ·
`task graph` (`--relation depends-on|blocks|parent|children`, `--depth`) ·
`custom set|clear` (custom-field values; the field must exist in the registry; a blank
value for an OPTIONAL `string` field is the same act as `custom clear`, LET-1909).

**A milestone is set, DECLINED, or UNTRIAGED (declared absence, LET-1517, ADR 0029).**
A milestone is never required. A task with none is either *declined* — somebody recorded
that it belongs to no milestone — or *untriaged* — nobody decided (every task filed without
one before v0.20.4 reads untriaged; nothing is backfilled).

```bash
lettuce task create LET-9 --title "rotate CI token" --no-milestone     # declined at filing
lettuce task set lettuce/LET-9 milestone --none --reason "process work" # declare later (clears a value)
lettuce task set lettuce/LET-9 milestone m22-agent-self-service         # a value supersedes it
lettuce task unset lettuce/LET-9 milestone                              # back to untriaged
lettuce task set-where --where 'milestone untriaged and labels contains "ci"' \
  milestone --none --reason "CI hygiene" --dry-run                      # bulk triage (then --confirm)
```

A declaration is ONE `field-set` event (`declared-absent: true` + the optional reason, the
value cleared) plus the at-rest marker `declared-absent/milestone`; `task show`/`task list`
report `absence: {"milestone": {"state": "declined"|"untriaged", "declared_at": …}}`. FQL:
`where milestone declined` / `where milestone untriaged` (`milestone missing` matches both).
`board next` prints the live untriaged count and the query that lists them. A create or
close of an UNTRIAGED task carries the advisory `FW-TASK-MILESTONE-OMITTED` in
`warnings[]`, which names both repairs; a declined task is silent. `--none` is refused with a
value or on any other field, and `--reason` without `--none`. To ENFORCE triage (opt-in, per
project), add `triaged/milestone` to a transition's `requires-field` with `workflow revise`
(it lists the in-flight tasks it would refuse first): it passes when the milestone is set OR
declined. The default never refuses a missing milestone (LET-1232).

The three `external-*` scalars link a ticket to its branch/PR and that change's
state: `external-url` (the branch or PR URL), `external-ci` (a free-form CI
status), and `external-merged` (exactly `true`, `false`, or `unknown`). They are
ordinary settable scalars — set at create with `--external-url/--external-ci/
--external-merged`, or later with `task set`/`task unset` — and are surfaced by
`task show`, `task list`, `query`, and the board. When a ticket is in a
**terminal** workflow state while `external-merged=false`, the read surfaces add
a derived `awaiting_merge: true` field and a non-fatal `FW-TASK-AWAITING-MERGE`
warning, so a closed-but-unlanded ticket is not presented as finished. An
`unknown` or absent merge state stays silent, and the advisory never refuses:
closing before a PR lands is legitimate.

**Review semantics (LET-1753 / GitHub #837).** `reviewer` is an optional,
author-valued scalar (like `assignee`) naming who should review the ticket: set
it at create with `--reviewer`, or later with `task set`/`task unset`, and it is
surfaced by `task show`, `task list`, `query`, and the board. In the default
workflow `complete` (`active -> done`) is a terminal close, while
`submit-review` (`active -> review`) parks the ticket for review and `approve`
(`review -> done`) is the review-passed step — check `next_actions` on
`task show` to see which one your `complete` will take. `approve` is a WORKFLOW
TRANSITION, not an independence gate: any project author may drive it, and a
self-approval is NOT refused. The approve event records the acting author as
`approved-by` (visible in `task audit`), and when that author is the ticket's
own author or its last worker the transition additionally emits the non-fatal
advisory `FW-REVIEW-SELF-APPROVAL`, so an independent verification is
distinguishable from a rubber stamp. True independent verification is enforced
one layer down, by the graph-run-case walk (`conform`'s
`FW-CONFORM-DECLARED-EPHEMERALITY-VIOLATED`).

**Task-local objects** — `comment add|edit|status|list|show|archive|unarchive|delete` ·
`lease acquire|renew|release|steal|show` (task-scoped) and `lease list`
(project-scoped; `--holder`, `--status active|expired`) · `run start|finish`,
`run log add|list|show`, `run summary add|list|show`, `run list|show` ·
`artifact add|replace|list|show|archive|unarchive|delete`, `artifact file get`
(payloads are immutable after creation; `replace` makes a new revision) ·
`version add|list|show` (named, versioned documents under a task, grouped by
CATEGORY/NAME).

**Queries** — `query run FQL` · `query tasks` (structured filters; `--fields`,
`--refs-only`) · `query search TEXT` (full-text;
`--scope tasks|comments|runs|artifacts|versions|registry|all`) · `query audit REF` ·
`query timeline` (merged events; `--task --author --kind --since --until`; a
`--task` that a hard delete removed answers `deleted: true` + its tombstone, like
`query audit`, LET-1801, with the surviving task-deleted event, filtered as usual, LET-1937;
a `--cascade` child answers with its parent's tombstone, LET-347; a task of a DELETED
project answers too, with the project-deleted event, never FW-REF-MISSING-PROJECT; a
comment or artifact deleted BEFORE its task keeps its deletion event, which the task
delete preserves in its tombstone and the deleted task's answer lists too, v0.20.5) ·
`query graph` (dependency DAG: cycles + critical path) ·
`query saved create|update|archive|list|show|run` (stored FQL).

`task list --format json` always returns tasks under `data.tasks`, including
when milestone/component/type or another rich filter is present; filters never
switch that command to the generic `data.rows` query envelope. FQL rows always
carry `reference` plus its queryable compatibility alias `ref`. Timeline author
filters reject malformed names and warn `FW-FILTER-UNKNOWN-AUTHOR` for a
well-formed name outside the project roster.

**Import/export/repair/sync** — `export --bundle PATH` (faithful whole-store
backup; an existing file at PATH is refused `FW-PATH-EXISTS` unless it is an
earlier export bundle AND you pass `--force` — the same no-clobber rule covers
every command that writes a path you name, LET-1972) · `import --bundle PATH` · `repair plan|check|apply|automatic`
(plan-driven; `--dry-run`; `--allow-high-intrusion` gates risky actions) ·
`sync status|push|pull` (dedicated-git mode) · `conflict bundle`. In
dedicated-git mode a `repair apply` commits ONLY the plan's declared scope
(LET-599/LET-1767): `scope.allowed_paths`, each action's `path`, and the task a
`reapply-as-new-mutation` action wrote. An unrelated dirty path is never swept
into the repair commit, and the git-excluded `.runtime/` resolution summary is
never staged. A conflict-resolution plan also CONCLUDES the merge or rebase the
conflict interrupted (LET-1833): it records exactly one `lettuce: repair` commit
even when the kept side equals HEAD, and after a `sync pull` rebase it continues
the rebase back onto the branch. If a later replayed commit conflicts too, the
apply still succeeds and warns `FW-GIT-SYNC-REQUIRED`: run `conflict bundle` and
resolve the next conflict the same way.

**Same task changed on two clones (LET-701).** When two clones of a dedicated-git
store both mutate one task, the merged history holds two events that consumed the
same revision: its chain forks (`FW-REVISION-CHAIN-BROKEN`). `sync pull` refuses it
(the post-sync gate, or a mid-rebase conflict on the task's files), `reconcile`
reports it and never rewrites it, and a resolution plan cannot clear it. Do not
hand-edit `revision` files: replay your side, and lettuce records it after
upstream's events (the refusals print these same steps):

1. if a rebase or merge is in progress, abort it: git rebase --abort (or git merge --abort)
2. list your unpushed lettuce commits: git log --format='%h %s' @{u}..HEAD — each subject names the mutation and its target; git show <sha> shows its values (save any comment or body text you need)
3. drop them: git reset --hard @{u}
4. re-run each of those mutations with lettuce --mode dedicated-git (lettuce records it after upstream's events), then lettuce validate --strict and lettuce sync push

**Server & GitHub** — `serve` (HTTP API; `--listen`, `--secret`, `--allow-ip`,
`--rate-limit`, `--authz-policy`, `--auto-provision-actors`, `--push-interval`;
`GET /` and `GET /SKILL.md` serve this guide unauthenticated, API under
`/v1/…`, `GET /v1/version` reports the release clients pin to (§0.1); `--domains-root BASE` + `--tokens-file F` serve several DOMAINS — see
§11; `GET /v1/client/{os}-{arch}[.sha256]` serves its own client binaries to
`lettuce self-update`; `--min-client-version` refuses mutations from older
clients and `--refuse-unversioned-clients` from pre-v0.19 clients that send no
version, both off by default) · `domain create|list` (server-side domain folders;
`domain list` in client mode shows what your token can reach) · `client list`
(admin: which subjects/actors run which client release, `--stale`) ·
`github init-repo` (bootstrap a GitHub-backed store repo).

**Where `github init-repo` keeps the PAT (LET-1219, LET-1877).** The PAT comes from
`--pat-file`, `--pat`, `LETTUCE_GITHUB_PAT_FILE`, `LETTUCE_GITHUB_PAT` or `GITHUB_TOKEN`
(prefer a file). It is never written to `.git/config` or the remote URL. With an HTTPS
remote, lettuce writes it to `.runtime/credentials/github-token` (mode 0600, in a 0700
directory that git never tracks) and points `core.askpass` at
`.runtime/credentials/github-askpass.sh`. That script holds no secret and no path, so a
store path containing `'`, `$` or spaces works. Later pushes, including the server's
auto-push, authenticate through it without a prompt. The credential is never included in
`export`, a `migrate` upload, a board, a receipt or command output, and `doctor` accepts
the directory. The `migrate` archive step deletes it before the tarball. Re-run
`github init-repo` with a new PAT to rotate it; on a store that already exists, add
`--mode dedicated-git` (or set `LETTUCE_MODE`). Delete `.runtime/credentials/` to revoke
local push access. A store set up by v0.19.2 or v0.19.3 keeps the token in
`.runtime/github-credential` until the next `github init-repo` moves it (`doctor`
accepts both layouts).

The four public document routes — `/`, `/SKILL.md`, `/docs`, `/docs/flat.md` —
carry an `ETag`. **If you poll them, send `If-None-Match` and you get `304` with
an empty body instead of the full artifact** (`/docs` is ~601 KB, this guide
~112 KB). The tag is derived from the content, so it is stable across restarts
and changes only when the binary's embedded copy does. These routes accept
`GET`, `HEAD` and `OPTIONS`; `OPTIONS` answers `200` with an `Allow` header, and
any other method answers `405` with the same header.

**Move a local store to a server (`migrate --to`).** One-shot orchestration
that drives a LOCAL source through the runbook (plan → session → manifest →
upload → complete → staged parity → finalize → activate → live parity →
release) against a HOSTED destination reached over HTTP(S), or against a local
directory for a rehearsal. Full walkthrough:
`docs/guides/MIGRATE-TO-SERVER.md`.

```bash
# 1. DRY RUN FIRST — deterministic, NO-MUTATION: prints the frozen per-project
#    manifest, the deduplicated referenced-author closure, and the estimated
#    transfer. The source store is byte-identical afterwards.
LETTUCE_BEARER_FILE=~/.lettuce-token lettuce migrate --to https://host/ --project demo --dry-run \
  --root .lettuce --author agent-1 --format json

# 2. Then the real run — one project, or every project in the store. It holds
#    the group fenced after live parity; `migrate release --session ID` opens it
#    (or pass --release to release in the same run):
lettuce migrate --to https://host/ --project demo \
  --root .lettuce --author agent-1 --format json
lettuce migrate --to https://host/ --all-projects --release \
  --root .lettuce --author agent-1 --format json
```

- **URL forms.** `http://` for a plain-HTTP destination (loopback/dev), `https://`
  for a TLS one — **they are not interchangeable**: pointing `https://` at a
  plain-HTTP server (or vice versa) fails fast with `FW-TRANSPORT-SCHEME-MISMATCH`
  naming the fix, not a raw transport string (LET-1763). `--to` is ALWAYS the
  token-free URL (`https://host/`); the bearer comes only from the environment
  (`LETTUCE_BEARER_FILE`, preferred, or `LETTUCE_BEARER`). A token embedded in
  `--to` (`https://<token>@host/` or the legacy `https://<project>:<token>@host/`)
  and `--bearer` are refused `FW-CMD-USAGE` before any network contact, naming
  that safe spelling (LET-1750); the project is supplied via `--project`, and the
  receipt is credential-free.
- **`--project NAME` (repeatable) or `--all-projects`** — exactly one is
  required; passing both is refused.
- **The dry run shows destination collisions.** For a hosted `--to` it asks the
  server's read-only probe (LET-1813): `destination_probe.collisions` lists every
  selected path the destination already holds with DIFFERENT bytes or type (the
  real run would be refused at the manifest seal on each), next to
  `existing_projects` and the already-identical count. `collisions_inspected:
  false` with "not checked" means the server predates the probe — not that the
  destination is clean.
- **The receipt is durable and names both builds.** Every receipt (dry run,
  held, released; a failure's `error.details`) carries `client_build` and
  `server_build` (`version`, `commit`, `build_date`, `source`). A real run writes
  it to `--receipt PATH`, else the source store's
  `.runtime/migration-receipts/<session-id>.json`, before printing it, and names
  the path in `receipt_path` and `next_steps` step 0; a dry run writes a file only
  with `--receipt`. An existing `--receipt` file is overwritten only when it is an
  earlier migrate receipt; any other file is refused `FW-PATH-EXISTS` before
  anything migrates (LET-1972).
- **The destination auto-creates projects and authors it does not already
  have** — nothing needs to be pre-provisioned there.
- **A transport-level failure before any HTTP response** — scheme mismatch, DNS
  failure, connection refused, TLS certificate verification failure, timeout —
  is reported as one of the typed `FW-TRANSPORT-*` codes with an actionable
  `suggested_actions`, both at the top-level error and in the failing step's
  `detail` in the receipt; it is never the bare `FW-TRANSPORT-UNAVAILABLE`
  catch-all unless the underlying failure genuinely matches nothing more
  specific.
- **The run holds before release.** After a passing live parity check the
  group stays activated and FENCED (`awaiting_release: true`); switch the
  repository pointer, verify discovery, then `lettuce migrate release --session
  ID --author A` (it re-verifies live parity, then opens the group). `--release`
  releases in the same run instead. `migrate status` / `migrate rollback
  [--allow-released]` take the same `--session ID --author A`.
- **Interrupted run? Re-run the identical command** — at ANY step, before
  or after activation. The server locates the group's durable session (keyed
  by the frozen manifest and your principal) and the run resumes it, or
  finishes an interrupted rollback; nothing is re-uploaded or duplicated.
  Unknown-outcome failures (timeouts, dropped connections, gateway 502/504) are
  re-issued automatically as idempotent replays. A refusal names the exact
  resume command and the session commands (§0.6) and carries `error.details`
  (session_id, domain, failed_step, steps). A failed LIVE parity check after
  activation is rolled back before the command returns. Activation (and a
  rollback) runs server-side, detached from your client: a killed or timed-out
  client does not stop it, and the re-run joins it instead of starting another.
- **The whole destination domain is offline from activate until release.**
  Every ordinary request to ANY project in that domain answers `503
  FW-MIGRATION-FENCED`, naming the migration session, its projects and
  `"scope":"domain"`. The fence does NOT lift on its own: it lifts only when
  the session's operator explicitly runs `lettuce migrate release --session ID`
  or `lettuce migrate rollback --session ID`. If you hit it and are not the one
  migrating, ask that operator, then retry. If you ARE the operator, finish
  the handoff (`migrate status --session ID` names the next step).
- **`LETTUCE_BEARER_FILE=<token-file> lettuce migrate verify --store https://host/ --session ID --view
  staged|live --author A`** (add `--domain` when you migrated into one; pass the
  SAME `--author` the `migrate --to` run used — a session is bound to its creating
  credential + actor, as for `migrate status`, LET-1788) re-runs the parity check standalone against a session
  id from a prior receipt — `staged` while the session is still
  complete/finalized (pre-activation), `live` once it is activated-fenced or
  released.
- `--domain NAME` (or `LETTUCE_DOMAIN`) migrates into a named server domain
  (LET-1764): `LETTUCE_BEARER_FILE=<token-file> lettuce migrate --to https://host/ --domain acme`. The token must
  be granted that domain (§0.2 "What your token grants"). The agent recipe,
  dry-run → run → verify → switch `.lettuce`, is §0.6.

**Cells & coverage** — `dimension list|show`, `dimension member list`
(effective dimensions = the shipped coverage convention ⊕ project layer) ·
`dimension declare`, `dimension member add|update` (project runtime layer,
additive-only) · `dimension rename OLD NEW` (change a runtime dimension's slug:
renames the definition and its own event ledger AND re-addresses every cell whose
coordinate names it — a coordinate IS the cell's identity, so each affected cell
is re-keyed to its new coordinate, carrying state/note/evidence/ledger
across; whole-store, atomic; refuses `FW-DIMENSION-RENAME-UNSAFE`/`-CONFLICT`
rather than shipping a partial rename) · `defaults show` (read the effective LADDER — states, transitions and
gates each source-tagged `default|project`, plus hidden states and hidden transitions) ·
`defaults state declare|set-default|hide`, `defaults transition declare|hide`,
`defaults gate declare`, `defaults reset` (tune the convention per project —
additive-only; `state hide` subtracts a base state; `transition hide ACTION --from
STATE` retires one PROJECT transition with a recorded marker — the repair for a bad
ladder entry such as `FW-LADDER-UNGATED-DONE-ENTRY`, LET-1926; `reset` clears the tweak
layer) ·
`cell set|show|clear|list|transition`, `cell set-where|clear-where` (bulk via FQL
`--where`), `cell note`, `cell import` ·
`cell evidence add|remove|list` · `cell gate check` · `cell rollup --by DIM` ·
`cell verify|affirm` (FRESH-2 confirmation ledger — re-confirm a cell's current
grade; distinct-rev hardening DEPTH, verify=independent re-check vs affirm=restate;
never moves the ratio. The STRONG depth `depth_verify` — the one a DoD depth floor
keys off — additionally requires a DISTINCT `--evidence` ref PER CELL: re-citing the
same ref on the same cell records as affirm-tier even at a new store revision, so one
proof cited N times earns 1, not N. The de-duplication is per-cell, so one ref shared
across DIFFERENT cells — the `--coords-file` pattern — still deepens each of them. The
call returns `ok:true` either way; read `depth_verify` back rather than infer it) · `cell reconcile` (regress stale-green cells) ·
`board export` (BoardExport
JSON data contract) · `board next` (the ranked next-actions feed) · `board render` (self-contained HTML board with a
CSS-only light/dark switch, `--theme`; rendered natively from the store — no
external generator; pure-CSS except the grc replay simulator's one inline
script, present only when the project has graph-run-cases) · `grid scope add-unit|add-dim|show`, `grid show`
(Coverage-Grid: the scope × unit × dim grid) · `dod set|show|clear`
(Definition-of-Done policy — the grade/depth/recency floors).

**Graph authoring & enactment** (see §9) — `graph-def create|revise|show|list|lint|viz|compile`
(author a named, versioned process graph as a first-class object; `create` folds
in the soundness gate — an unsound spec is refused `FW-GRAPH-DEF-UNSOUND` and
never stored; `lint` reports advisory design smells — exit 0 clean, 2 when smelly; `viz` renders it as
a mermaid/DOT diagram to eyeball; `compile`
canonicalizes to an order-invariant effective-hash and resolves the `uses`
composition closure — a cycle is `FW-GRAPH-DEF-CYCLE`, a missing used def
`FW-PATH-NOT-FOUND`) · `graph-def catalog list|show` + `graph-def use` (browse the
shipped named-pattern catalog and materialize a pattern by name into a project —
usage by name; the materialized def compiles to the catalog's effective-hash) ·
`graph-run-case open|advance|close|abandon|show|list|viz|refs-to` (open a run-case
enacting a graph-def, walk it across guarded edges, close it — event-sourced,
`state == f(events)`; `abandon` ends a run that cannot be completed, recording why;
`list` enumerates them (`--open` or `--state`); `viz` draws the run with its live
state overlaid; `refs-to`
inverts the effect edge — from an object, list the run-cases that touched it) ·
`graph-run-case conform` (CONFORMANCE REPLAY: does the walk match the graph-def it
PINNED at open? `advance` checks only the NAMES it records (LET-1944) and the carriers
an edge declares against that pinned version, so this is
the read-time counterpart that makes the pinned hash a binding rather than a label.
Reports `FW-GRAPH-CONFORM-UNDECLARED-EDGE`, `-QUORUM-BELOW-DECLARED`, `-UNKNOWN-NODE`,
`-DEF-MISSING`, `-PIN-UNRESOLVABLE`; a VERDICT not a refusal: **exit 2** (`ok:true`) when
the walk does not conform (LET-1831; exit 1 before v0.20.2), so a script can gate on it
without parsing JSON. Note it replays
RECORDED TRANSITIONS: a branch OPENING records no traversal and is not checked, but a
`branch-advanced` event does record one, and LET-1224 extended the undeclared-edge and
unknown-node checks to reach those — so a fork/join graph is judged on its BRANCHES as
well as its main line) ·
`carrier produce|list|show` (the write-once, content-addressed
evidence an edge emits, read back by `advance`'s `--produces` post-check; `list`/`show`
read them back — a declared edge `carrier` is NOT auto-enforced, the caller must pass
`--produces KEY`).

## 6. The core agent loop

```bash
ROOT=.lettuce            # name the store explicitly; there is no home-directory default
P=myproject; A=my-agent

# 1. One-time: create/seed a store (idempotent). ONE store holds ONE project:
#    a second one must be confirmed with --yes (see §5, project create).
lettuce init --root "$ROOT" --author "$A" \
  --bootstrap-project "$P" --bootstrap-author --idempotent --format json

# 2. Discover work — at two levels.
#    (a) Concrete items: open tasks.
lettuce task list --project "$P" --root "$ROOT" --format json
lettuce query run "from tasks select task,title,status" --root "$ROOT" --format json
#    (b) Higher-order: the coverage board read FORWARD is a work MAP (see §8). The
#        rollup/board surface WHERE effort is owed — untested/gap cells, weak
#        dimensions — at aggregate scale, before any single task exists.
lettuce board next --project "$P" --root "$ROOT" --format json      # frontier: weakest cell first (works on an empty board)
lettuce board render --project "$P" --root "$ROOT" --format html   # the human view

# 3. Create work (if needed).
lettuce task create TASK-1 --root "$ROOT" --project "$P" --author "$A" \
  --title "Short title" --body "What to do." --format json

# 4. Claim it: acquire a lease, then start work.
lettuce lease acquire "$P/TASK-1" --expires-at "$(date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+1H +%Y-%m-%dT%H:%M:%SZ)" \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce task transition "$P/TASK-1" start-work \
  --root "$ROOT" --project "$P" --author "$A" --format json

# 5. Record progress as you work.
lettuce comment add "$P/TASK-1" --body "Investigated X." --root "$ROOT" --project "$P" --author "$A" --format json
lettuce run start "$P/TASK-1" --root "$ROOT" --project "$P" --author "$A" --format json
lettuce run log add "$P/TASK-1" 1 --type progress --message "Tests running." \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce artifact add "$P/TASK-1" --type test-report --primary-file report.md \
  --file ./report.md:report.md --root "$ROOT" --project "$P" --author "$A" --format json
lettuce run finish "$P/TASK-1" 1 succeeded --root "$ROOT" --project "$P" --author "$A" --format json

# 6. Finish: complete the transition, then release the lease.
lettuce task transition "$P/TASK-1" complete --reason "Done + verified" \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce lease release "$P/TASK-1" --root "$ROOT" --project "$P" --author "$A" --format json
```

**The creating author is `created_by` on every object (LET-383).** Task, comment,
artifact, run and run-summary results all name it `created_by`. A run also still
carries `agent` (the name of its on-disk scalar; always the same value, kept so
existing readers do not break) — read `created_by`. A mutation envelope's own
`author` is a different fact: the author of the EVENT that mutation recorded, on
every mutation. A lease's `holder` and a run-case's `opened_by` are their own
concepts and keep their names.

### Transitions need an active lease

`start-work` (and other lease-gated actions) fail with
`FW-WF-REQUIREMENT-UNSATISFIED` unless you hold the lease. Acquire first.
Transition syntax is positional: `lettuce task transition REF ACTION [--reason TEXT] [--expect-revision N]`.

## 7. Reading and querying (FQL)

```bash
lettuce task show "$P/TASK-1" --with-body --root "$ROOT" --format json   # or --full
lettuce task audit "$P/TASK-1" --root "$ROOT" --format json              # event history
lettuce query run "from tasks where status = active select task,title" --root "$ROOT" --format json
lettuce validate --root "$ROOT" --strict --format json
lettuce doctor --root "$ROOT" --format json   # diagnoses + repair guidance
```

**FQL** shape: `from SOURCE [where EXPR] [select f1,f2] [order by f [desc]]
[limit N] [offset N]`. Sources: `tasks`, `events`, `registry`, `authors`,
`projects`, `dimensions` (active pack's declared dimensions), `cells` (stored
cell assertions; sparse defaults are NOT rows). Strings use **double quotes**
(single quotes are rejected); `contains` matches case-insensitively unless
`--case-sensitive`. `--group-by FIELD` (tasks source only) buckets by
assignee, component, milestone, severity, status, type, or workflow.
Soft-archived tasks are hidden from every query/list surface by default;
pass `--include-archived`. A query of an ARCHIVED project is refused
(`FW-READ-PROJECT-ARCHIVED`) unless `--include-archived`, which also includes it
in a store-wide query; either way the answer is labelled ARCHIVED. An included
archived task is marked too: its row carries `archived_at`, and a `--group-by`
bucket carries `archived` (how many of its `count` are archived; omitted when 0),
shown as an ARCHIVED column in the table (LET-1858). `query search` never hides
comments or artifacts (archive hides them from lists, not from discovery), so an
archived comment or artifact hit always carries `archived_at` (LET-1793).

**An unset `priority` is `50` inside FQL, and `null` everywhere else (spec §20.4.5,
LET-1435).** FQL applies one task default: a task with no `priority` file compares
as `50`, and the default counts as present — `where priority exists` matches it,
`where priority missing` never does, and a projection (`select priority`, the
default `query tasks` columns) prints `50`. `task show` and `task list` report the
same task's `priority` as `null`. So `where priority = 50` also matches
never-triaged tasks, and FQL cannot select tasks whose priority was never set;
whether it should is an open decision (LET-1435). Every other optional task scalar
(`type`, `severity`, `estimate`, …) is `null` and `missing` when unset. For `milestone`,
`missing` splits into `declined` (a recorded decision) and `untriaged` (ADR 0029).

**Pagination is a TWO-TIER contract, and the tiers are intentional (LET-380).**
FQL's `limit`/`offset` above are clauses of the query language. The CLI flags
`--limit` / `--offset` are a separate thing, and exactly **four** commands accept
them:

| tier | commands |
|---|---|
| **paginated** | `task list`, `lease list`, `query search`, `query tasks` |
| **unpaginated** | every other `… list` surface — 23 of them |

An unpaginated surface REFUSES the flags with `FW-CMD-UNKNOWN-FLAG`; it does not
accept-and-ignore them. That refusal is the design: these enumerate bounded
registries (authors, projects, dimensions, workflows, a task's own comments and
versions), where the whole set is the useful answer and a partial page would
invite a reader to mistake it for one. The four paginated surfaces are the ones
that range over an unbounded population.

So do NOT expect an `offset` field in an unpaginated envelope — advertising a
flag the command refuses is the LET-39 no-op-flag defect wearing a response
field, and it is why this is documented rather than "fixed". If you need a subset
from an unpaginated surface, filter it or query it; do not page it.

**Querying coverage with `cells`** — the `cells` source lets you interrogate
the coverage plane the same way you query tasks. Stored fields: `coordinate`,
`state`, `hash` (plus `cell`/`project`/`pack` identity). It ALSO carries the
DOD-4 derived axes: `freshness` (`fresh|aging|stale`), `depth` (distinct-rev
confirmation count, an int), `dod` (`blocking|met`), and `dod-reason`
(`grade|depth|recency|unknown` for a blocking cell). These make "what is not
yet done?" a query:

```bash
# every cell still blocking the Definition of Done (grade/depth/recency too weak)
lettuce query run "from cells where dod = blocking select coordinate,state" --project "$P" --root "$ROOT" --format json
# cells reworked repeatedly since their evidence was last re-checked (freshness = fresh|aging|stale)
lettuce query run "from cells where freshness = stale select coordinate" --project "$P" --root "$ROOT" --format json
# cells confirmed at depth >= 2 distinct revisions
lettuce query run "from cells where depth >= 2 select coordinate" --project "$P" --root "$ROOT" --format json
# cells blocked specifically because their grade is below the DoD floor
lettuce query run "from cells where dod-reason = grade select coordinate" --project "$P" --root "$ROOT" --format json
```

FQL has no dedicated `scope`/`unit`/`dimension` *filter operator* today — slice by a
coordinate member instead, e.g. `where coordinate contains "layer=api"` or
`where coordinate contains "scope=login"`. (These ARE modeled dimensions: `scope` in
particular is the reserved partition the DoD/board evaluate by — see §8/§11. A cell
that declares no `scope=` is bucketed under the synthetic scope **`unscoped`** at
evaluation time, so it is never silently outside the DoD frame; you cannot assert
`scope=unscoped` on a cell — the name is reserved for that bucket.)

## 8. Cells: the coverage model

The coverage plane grades *how proven* each part of a product is — and, read
the other way, *what work remains*. Vocabulary:

- **Convention** — coverage, the convention-as-data lettuce ships: states,
  transitions, gates, dimensions, families. Bundled in the binary and active on
  every project by default; you inspect it (`defaults show` for the effective
  ladder — states, transitions and gates each source-tagged, plus hidden states;
  `dimension list` for the axes, `dod show`, `grid show`, `board render`) and
  tune it per project with the `defaults` layer rather than swapping it.
- **Dimension** — one quality axis (e.g. `test-coverage`,
  `input-validation`). Grouped into **families**; each has an
  **applicability** (`universal` | `conditional`) and is **closed** (fixed
  member enumeration) or **open** (members minted ad-hoc in coordinates). A
  project's *effective* dimensions = the pack's ⊕ an additive project runtime
  layer (`dimension declare`; never shadows pack vocabulary). Each dimension
  also carries a **`methodology`** — how to *apply* the axis to a unit
  (the 6-facet form: Procedure · Best practices · Tools · Current approaches ·
  Evaluation · Hardened-evidence bar), distinct from its `description`
  (what the axis *is*). **Read a dimension's methodology before grading any of
  its cells** — `dimension show <slug> --project <p>` (or `dimension list
  --project <p> --format json` for the whole set) surfaces it, and it is what
  tells you what a *smoke* vs a *hardened* grade actually requires.
  A runtime dimension's slug is renameable with `dimension rename OLD NEW
  --project <p>`: it preserves the family/closed-ness/members/ledger and rewrites
  the `OLD=` key in every stored cell coordinate (re-addressing those cells to the
  new coordinate). It refuses — writing nothing — when a reference cannot be
  rewritten faithfully (a content-addressed run-case/carrier ref, a stored saved
  query naming the dimension, or a declared grid/DoD keyed off a reserved axis).
- **Member** — one enumerated value of a dimension, first-class
  `{slug, name, description, rank}` (empty name renders as the slug; rank 0 =
  unranked, sorts by slug). `dimension member add|update` edit only
  project-declared dimensions.
- **Cell** — one sparse, evidence-asserted point: a canonical **coordinate**
  (`dim=member;dim=member` — pairs sorted by dimension, duplicates rejected,
  slugs validated) carrying one **state** from the active pack. An untouched
  coordinate is not stored; it reads as the pack default with `stored=false`.
- **State / grade** — pack states carry a category (`open`, `in-progress`,
  `review`, `done`, `excluded`, `flagged`) that drives engine-enforced honesty
  invariants: only the `done` state counts as green; an `excluded` state
  leaves the denominator; review states never count as done; machine-only
  transitions (e.g. `regress`) fire only via the sanctioned path
  (`cell reconcile`).
- **Gate** — authorizes a gated transition. Built-in evaluators:
  `guard-bite` (the cell has ≥1 AUTHORISING evidence link: `--kind task`, citing a
  task that is `done` and carries `custom/grc`) and `consistency` (store predicates
  recompute clean). A `--kind url` link ANNOTATES a cell and is stored, listed and
  displayed as before, but it never authorises a gated state — an opaque string
  nobody checked must not be what unlocks `hardened`. `cell transition`
  refuses a gated move that fails its gate (`FW-WF-GATE-UNSATISFIED`) unless
  `--facilitate` (records the verdict but allows the move).
- **Gate-entry-only state** — a state the active pack lets you enter ONLY
  through gated transitions (`hardened`: every `harden` edge carries
  `guard-bite`). A *direct* assert into one — `cell set`, `cell set-where`,
  `cell import` — must satisfy at least ONE of those gates, or it is refused
  `FW-WF-GATE-UNSATISFIED` naming the state, the gates and why each failed.
  `--facilitate` applies it anyway and records the bypass on the cell-set
  event, in the SAME shape `cell transition --facilitate` writes, so a forced
  set and a forced transition are indistinguishable in the ledger. A state
  with ANY ungated way in (or none at all, like the pack default) is
  unaffected — the guard never fires on honest work. The bulk writers SKIP
  (never abort on) a cell whose gate fails, and their `--dry-run` predicts
  exactly those skips with the apply's own reasons (`skipped`/`skipped_count`,
  LET-1576), so a preview never lists as applied a cell the apply will skip.
- **Evidence** — links on an asserted cell (`--kind task` = validated task
  ref, `--kind url` = opaque string). `cell reconcile` machine-regresses
  stale-green cells whose cited evidence no longer resolves. In HTTP mode its
  dry-run preview is the actorless `GET /v1/projects/{project}/cells/reconcile`;
  the attributed `POST` is apply-only and rejects `dry_run:true`.

### The coverage convention (concrete)

lettuce ships one convention, **coverage**, active on every project by default.
Its grade vocabulary:

| Grade vocabulary (default → … → done) | Meaning |
|---|---|
| untested* (never exercised) → planned → in_progress → gap (exercised, defect known) → evidence_linked → smoke (exercised, shallow proof) → **hardened** (a committed guard provably bites under fault-injection); blocked, regressed (flagged); excluded, waived (excluded, leave the denominator — **only when justified**: a bare `cell set --state waived` with no `--reason`/`--note` is recorded `unreasoned`, stays IN the denominator and is counted as `unreasoned_exclusions`, LET-417) | lettuce's own 88-dimension / 15-family quality taxonomy; `harden` is guard-bite-gated (requires evidence) |

`*` = the default an untouched coordinate reads as (a fresh project reads
`untested`). You do not swap the convention; tune it per project with `defaults`.
Every bundled state carries a stored one-line **description** (what it asserts
and what moves it on): look it up with `lettuce defaults show --project "$P"`,
read it in `board export` `states[].description`, and `board next` prints it on
the start-here line (`↳ state: …`) — LET-950, ADR-0025.

**Two readings: proof and roadmap.** The same cells serve a backward and a
forward reading. Backward, a cell is *proof* — evidence of quality already
earned. Forward, each coordinate is a point in the product's quality space and
every thin cell is work waiting to happen: `untested` (or sparse/unstored) =
not yet discovered or decided, `gap` = a known deficiency (a decided TODO),
`smoke` = shallow proof needing deepening, `hardened` = done, `excluded` =
decided out of scope. The grade ladder is thus also a work-discovery ladder,
and the board is a roadmap: an agent picks its next target from the frontier
of weak coverage (`cell rollup` for the weakest axes/members, `board export`
for the whole frontier), does the work as a task on the work plane, then
closes the loop by linking that task as evidence and grading the cell. Work
hardens cells; cells reveal work.

The `coverage` convention ships **88 dimensions in 15 families** (letter slugs;
inspect any with `lettuce dimension list --project <p>`):

| Family | Dimensions |
|---|---|
| correctness | A functional correctness · C spec-impl fidelity · N domain-model completeness · O compat/versioning/migration · Q release-compat contract · U internal consistency |
| usability | B UX/DX · S self-documentation · T error-handling & messaging · M docs & examples · AY accessibility (a11y) · BI i18n/l10n · VD visual & product design · AX agent experience · KB knowledge-base & self-doc quality · WL white-label & theming · BC browser/client compatibility |
| interface | H HTTP/API surface · J import/export round-trip · BX agent-readiness · CB multichannel parity · AP agent-protocol interoperability · DP data portability & anti-lock-in |
| security | I attack surface · V privacy · AO rate-limiting/abuse · AZ bounded resources · BD auditability · RT red teaming · PT purple teaming · TM threat modeling · RC regulatory compliance & enforcement |
| reliability | BZ temporal-correctness · D data integrity/atomicity · E resilience/recovery · F concurrency/race-safety · FP cross-process concurrency & lock-safety · EL service liveness & crash-isolation · AM idempotency · CC resource-cleanup · AN backup/DR · AT determinism · OP operations & incident response |
| distributed | G multi-node/sync · CA storage-backend equivalence |
| performance | L performance/efficiency · P observability · SC scalability patterns · DT distributed tracing & trace ownership |
| maintainability | K code-quality · AA maintainability · AB testability · R configurability · BL architecture · CS code smells & anti-patterns |
| delivery | AC licensing · AR supply-chain/SBOM · AS deploy/release · BM CI-CD health · BN packaging · BY stack maturity · BP cross-platform portability · LG legal, IP & terms · CN cloud-native / kubernetes deployment · RG reproducible generation & asset provenance |
| process | TD TDD discipline · GQ quality-gate quality · TK tracking & board discipline · QA QA procedure (full) · RV adversarial/peer review · AL agentic development-loop quality · PM engineering-process maturity & artifacts · RQ research & inquiry quality |
| product | PA product analytics & north-star instrumentation · XP experimentation & A/B testing · PD product discovery & prioritization |
| growth | PR pricing & packaging · MN revenue, billing & unit economics |
| customer | ON onboarding & activation · SU customer support & service · CX success, retention & churn · FB feedback & voice-of-customer |
| market | PO positioning & messaging · DG demand generation & campaigns · SE content, SEO & GEO/AEO discoverability · IR investor & stakeholder communications |
| lifecycle | FO cloud cost & FinOps efficiency · SN deprecation, EOL & sunset |

```bash
lettuce cell set "area=auth;layer=api" --state in_progress --project "$P" --author "$A"
lettuce cell evidence add "area=auth;layer=api" --ref "$P/TASK-1" --kind task --project "$P" --author "$A"
lettuce cell transition "area=auth;layer=api" link-evidence --project "$P" --author "$A"   # in_progress → evidence_linked
lettuce cell transition "area=auth;layer=api" harden --project "$P" --author "$A"   # gate must pass
# `cell set --state hardened` is NOT a shortcut past that gate: hardened is
# gate-entry-only, so a direct set must pass guard-bite too (or --facilitate,
# which records the bypass on the event).
lettuce cell rollup --by area --project "$P"    # per-member floor state + hardened ratio, N/A-excluded
lettuce board export --project "$P" --format json   # BoardExport v0.1 for a renderer
```

### Scope ≠ subsystem ≠ shape (board structure)

A **scope** is a top-level partition of the board (S1 Feature, S2 Component,
S3 Product, S4 Ecosystem) scored **separately and never blended** — blending
scopes hides gaps. Each scope has a **row-axis** (S1→command, S2→subsystem,
S3→none: its rows are the dimensions, S4→milestone) and a **shape** — how it
holds and renders data: `grid` (rows × dimensions × grade), `scalars` (one
grade per dimension), `ladder` (ordered milestone stages, no ratio). A
*subsystem* is specifically S2's row-axis entity, not a synonym for scope.
Today scope/subsystem are modeled as declared dimensions whose members appear
as coordinate axes; a cell's row lives under the `unit` axis for S1/S2
(`dim=a;scope=s1;unit=<command>`, `dim=am;scope=s2;unit=<subsystem>`) and
under `dim` alone for S3. Shape is renderer-side — its
promotion to first-class data is an approved design that has **not shipped
yet**.

**No-scope cells → the `unscoped` bucket.** A cell whose coordinate declares no
`scope=` member is not silently outside the per-scope frame: the DoD and `board next`
bucket it under a synthetic reserved scope named **`unscoped`** at evaluation time (the
stored coordinate is untouched — no rewrite, no hash change). This keeps the honesty
invariant intact — a grid of nothing but un-scoped untested cells reads as **unmet**,
never vacuously "done". Because the bucket is evaluation-only, the name is **reserved**:
`cell set`/`cell transition` refuse an explicit `scope=unscoped` (`FW-NAME-RESERVED`) so
a real cell can never collide with the bucket. Omitting `scope=` is the supported way
to land there; give a cell a real `scope=` to pull it into its own partition.

### Standing up a coverage board from scratch (worked example)

**The scoped-cell coordinate convention — learn this first.** A board cell's
coordinate names three axes that the board reads structurally:

    dim=<quality-dim> ; scope=<s1|s2|s3|s4> ; unit=<row>

- `dim=` — WHICH quality axis (a coverage dimension by its slug lower-cased:
  `dim=a` functional correctness, `dim=am` idempotency & delivery semantics; see
  `lettuce dimension list --project <p>`). The board upper-cases it to match the
  dimension.
- `scope=` — WHICH assessment tier (see "Scope ≠ subsystem" above). The slugs
  `s1`/`s2`/`s3`/`s4` are magic: they carry the shapes Feature-grid / Component-grid /
  Product-scalars / Ecosystem-ladder. A cell with no `scope=` lands in the reserved
  `unscoped` bucket.
- `unit=` — the row within an S1/S2 grid (a command / subsystem). **S3 scalar cells
  omit `unit=` entirely** (their rows ARE the dimensions): `dim=am;scope=s3`.

Coordinate axes are minted freely — you do NOT have to declare `dim`/`scope`/`unit` as
dimensions just to assert a cell (only a *closed* declared dimension enforces its member
list). Declaring `scope`/`unit` is optional but recommended: it gives the board real row
names, ordering, and per-scope shapes instead of the built-in s1..s4 fallback.

Copy-paste recipe (filesystem mode):

```bash
ROOT=.lettuce ; P=myproject ; A=my-agent

# 1. Store + project (coverage is the shipped convention — active by default,
#    nothing to enable).
lettuce init --root "$ROOT" --author "$A" \
  --bootstrap-project "$P" --bootstrap-author --idempotent --format json

# 2. (Recommended) declare the partition + row axes so the board reads richly.
#    --family must be one of the coverage convention's families (interface is apt here).
lettuce dimension declare scope --family interface --applicability universal \
  --name "Scope" --project "$P" --root "$ROOT" --author "$A" --format json
lettuce dimension member add scope s2 --name "Component" \
  --project "$P" --root "$ROOT" --author "$A" --format json
lettuce dimension member add scope s3 --name "Product" \
  --project "$P" --root "$ROOT" --author "$A" --format json

lettuce dimension declare unit --family interface --applicability universal \
  --name "Command / unit" --project "$P" --root "$ROOT" --author "$A" --format json
lettuce dimension member add unit store --name "store" \
  --project "$P" --root "$ROOT" --author "$A" --format json

# 3. Assert the FIRST scoped cell — an S2 component-grid cell (has a unit row)…
lettuce cell set "dim=am;scope=s2;unit=store" --state gap \
  --reason "durability not yet proven" \
  --project "$P" --root "$ROOT" --author "$A" --format json

# …and an S3 product-scalars cell (NO unit — its row is the dimension itself).
lettuce cell set "dim=a;scope=s3" --state untested \
  --project "$P" --root "$ROOT" --author "$A" --format json

# 4. Orient — the board and its frontier now have real content.
lettuce board next --project "$P" --root "$ROOT" --format json
lettuce board render --project "$P" --root "$ROOT" --format html > board.html
```

To later HARDEN a cell, walk it up the pack ladder and link the proving task as
evidence — the `guard-bite` gate refuses `hardened` without it. The link must be
`--kind task` and the cited task must be `done` and carry `custom/grc`; a `--kind url`
link is an annotation and will not authorise the move. The gap cell above goes
`gap → smoke` via `exercise-gap` (from `untested` the action is `exercise`); a fresh
`untested` cell goes `untested → smoke → hardened`. The proving task must already exist,
and `harden` is refused while it is still open:

```bash lettuce-example run bind=$P=demo bind=$A=agent-1 setup='cell set dim=am;scope=s2;unit=store --state gap'
lettuce task create TASK-1 --title "Prove durability" --project "$P" --author "$A"
lettuce cell transition "dim=am;scope=s2;unit=store" exercise-gap --project "$P" --author "$A"   # -> ok (gap → smoke)
lettuce cell evidence add "dim=am;scope=s2;unit=store" --ref "$P/TASK-1" --kind task --project "$P" --author "$A"
lettuce cell transition "dim=am;scope=s2;unit=store" harden --project "$P" --author "$A"          # -> FW-WF-GATE-UNSATISFIED (TASK-1 is open)
```

Close TASK-1 through its close walk (`docs/guides/CLOSE-WALK.md`), which completes it
and pins `custom/grc`; the same `harden` then moves the cell `smoke → hardened`.

### BoardExport v0.1 (the data contract)

`board export` emits a stable, versioned, read-only projection for dashboard
renderers — data strictly separated from visuals: `schema_version`,
`generated_at` + `source_rev` (provenance, not body content — `generated_at`
tracks the store's latest event and advances only when the store does, never a
wall clock; the body is deterministic), `families[]`, `axes[]` (with first-class `members[]`),
`dimensions[]`, `states[]` (the legend), `cells[]` (each with evidence links),
`rollups.by_axis` keyed by real axis names (`by_axis.scope`,
`by_axis.command`, …), `rollups.headline` (the ONE blended
Σhardened/Σnon-excluded ratio — for honest per-scope figures read
`by_axis.scope`), `milestones[]`, `tickets[]`, `registry[]`, `activity[]`.
It is **not** a backup — `lettuce export --bundle` is the faithful whole-store
bundle; the two share no schema.

### Definition of Done — the north-star "are we there yet?"

A project's **Definition of Done** is a tunable, stored policy — the objective
bar that answers "are we there yet, and what's blocking?". Lettuce is the
authoritative keeper of that answer; **an agent orienting on a project should
read the DoD verdict FIRST**, before picking any target.

- **Set it** — `lettuce dod set --grade STATE [--depth N] [--recency fresh|aging]
  [--scope SCOPE] --project "$P"`. `--grade` is the required grade floor (a pack
  state, e.g. `hardened`); `--depth` is an optional minimum distinct-rev
  confirmation depth (FRESH-2); `--recency` an optional minimum freshness bucket
  (FRESH-3). Without `--scope` you set the project defaults; with `--scope` you
  override one scope's floors (unset fields inherit the default).
  **Freshness is cell-local (LET-1612):** a cell ages only by ITS OWN events
  after its most-recent evidence anchor (an evidence link or an
  evidence-carrying confirmation) — re-grades, transitions, notes, evidence
  removals. `fresh` ≤ 3 such events, `aging` ≤ 6, `stale` beyond. Unrelated
  project activity and elapsed time do not age a cell, confirmations are not
  rework, and a new evidence-carrying confirmation re-anchors it to `fresh`.
  A cell with no anchor reads `presumed`, which a recency floor never credits.
- **Read it** — `lettuce dod show --project "$P"` prints the policy AND the
  current verdict: per-scope `met/unmet` with a k/n count (and any `unknown`),
  plus project-level `met_scopes/total_scopes`. The verdict is a strict
  per-scope **AND-gate** over applicable (non-excluded) cells — a scope is done
  only when EVERY cell clears the bar, never a blended percentage. A scope with
  NO applicable cell (every cell excluded) is **`n/a`, never `met`** (LET-689):
  it is counted in `na_scopes`, not `met_scopes`, it does not block a project
  whose other scopes are met, and a project whose every scope is `n/a` is unmet
  (nothing was verified). `board next` names it: `DoD: DONE (1/2 scopes met ·
  1 n/a — no applicable cell)`. Setting the
  DoD NEVER moves the hardened ratio: a scope can read 100% hardened yet be DoD
  **unmet** (stale or shallow evidence).
- **`board next` leads with it** — the orientation report's first line is the
  DoD verdict (`DoD: NOT DONE (0/1 scopes met)`), then the DoD-blocked scopes
  and the single "start here" pointer. So a bare `lettuce board next --project
  "$P"` both answers "are we there yet?" and hands you the next target. DoD is
  opt-in: a project that has declared no grade floor reports `declared=false`
  and the board renders unchanged.

The same tri-state verdict block also rides in `board export` under `dod` and in
the query surface (`from cells where dod = blocking`, above).

### Milestones and the S4 hypothesis ladder

A milestone is a first-class registry object carrying a **hypothesis-ladder STAGE**
(and optional CONFIDENCE) — real scalar data, not parsed from prose. `milestone
set-stage` progresses it:

```bash
lettuce milestone create sellable --title "Sellable to first customer" \
  --project "$P" --author "$A" --format json
lettuce milestone set-stage sellable --stage hypothesis --confidence low \
  --project "$P" --author "$A" --format json
# …later, once instrumented and evidenced…
lettuce milestone set-stage sellable --stage instrumented --confidence high \
  --project "$P" --author "$A" --format json
```

The board's built-in ladder rungs, weakest → strongest, are
**`hypothesis → validated → instrumented → proven`**. `--stage` is a free-form 1–64-char
label (NOT restricted to those four — the four are the default rung ordering the S4 panel
renders against; a project may declare its own rungs via a scope member's `stages`
attribute). Stage/confidence are each an evented mutation; they surface in `milestone
show`, `board render` (the S4 ladder badge), and `board export` under `milestones[]`.

### Wiring the work plane to the coverage plane (the S4 bridge)

**The S4 scope is the bridge between the two data planes.** S1/S2/S3 are graded from
*cells* (the proof plane). **S4 is different: its rows come from the milestone registry,
not from `scope=s4` cells.** Each milestone renders as one ladder rung (its
`stage`/`confidence`) plus a delivery bar (its N/M task completion). So the same
milestone is simultaneously a *work-plane* object (tasks point at it via `task set …
milestone <slug>`, and `query … --group-by milestone` buckets by it) and a *coverage-plane*
row (the S4 ecosystem ladder) — no `scope=s4` cell is needed or read.

```bash
# Work plane: a milestone with tasks attached.
lettuce milestone create beta --title "Public beta" --project "$P" --author "$A"
lettuce task set "$P/TASK-1" milestone beta --project "$P" --author "$A"
lettuce query tasks --group-by milestone --project "$P" --format json   # delivery progress

# Coverage plane: the SAME milestone as an S4 ladder rung.
lettuce milestone set-stage beta --stage validated --confidence medium \
  --project "$P" --author "$A"
lettuce board next --project "$P" --format json     # S4 shows beta's rung + N/M bar
```

This is why a project's overall Definition of Done layers two outer gates on top of the
per-scope cell rollup: DONE requires the S1–S3 coverage met AND every committed milestone
reached (hypothesis-stage milestones are excluded as bets, not commitments) AND zero
non-terminal tickets. Grid/scalar scopes are hardened by cells; the ecosystem scope is
progressed by advancing milestones.

## 8.1. Running lettuce as an agentic development loop

Lettuce is not primarily an autonomy tool — everything above works one command
at a time, human or agent. But one powerful way to use it: if you are an agent
with standing goals and a store, run lettuce **as your loop**. The store read
forward is the plan; the store written backward is the proof — no external
planner needed. Here is how it shines.

**`board next` requires — and what makes it *useful*.** `board next` (like `board
export`/`render`) is a pure projection of the store, so it needs: **a store and a valid
`--project`** (a missing/unknown project is refused — there is no default and no
cross-project scan); **at least one *stored* cell to be useful** (the frontier is built
from asserted cells only — sparse pack-defaults are never rows; an empty board is not an
error, every scope meter reads `n/a`, which the JSON encodes as a present
`"hardened_ratio": null` with `"testable": 0` — the same shape `board export` and `cell
rollup` use); and **a declared DoD to get the verdict line**
(the leading `DoD: …` line appears only once a project has declared a grade floor with
`dod set`). A project with ZERO stored cells reads **UNSCOPED** ("no cells defined yet")
on `board next` and `board render` alike, with or without a DoD — never a completion
line. Under a DoD, `✓ DoD met` (and the render's "all scopes at bar") prints only when
the verdict IS met: a project with no DoD-applicable cell (zero cells, or every cell
excluded) is NOT DONE and lists `[✕] coverage — no DoD-applicable cell` as its blocker.
With a declared grid, every surface (`cell rollup`, `dod show`, `board export`, `board
next`, `board render`) counts only IN-grid cells; the rendered panel announces any
off-grid cells (`lettuce grid scope show SCOPE`) instead of drawing them as grid rows. Assert cells first (see "Standing up a coverage board from scratch" above).
`--depth` accepts only `0`, `1`, or `2`; `--scope <name>` naming a scope no cell declares
returns an honest error listing the available scopes, never a silent empty report.

**The cycle** — orient → act → reflect → file, repeat:

```bash
# ORIENT — ask the store what matters most right now.
lettuce board next --project "$P" --root "$ROOT" --format json
#   → every scope's meter weakest-first, shape-aware suggestions, concrete
#     gap/untested coordinates — each with the exact drill command
#     (--depth 0|1|2 sets detail). Pick ONE target: gap (known defect)
#     before untested (undiscovered), weakest scope first. Act on the
#     coordinate it printed; don't hand-compose one.

# ACT — make the work claimed, attributed, and visible (§6 steps 3-5).
lettuce task create TASK-N ... --title "Harden <coord>"    # if no ticket exists yet
lettuce lease acquire "$P/TASK-N" ...
lettuce task transition "$P/TASK-N" start-work ...
# ...do the real work; narrate with `run log add` / `comment add`...

# REFLECT — record what happened WITH evidence, then close the cell (§6 step 6).
lettuce artifact add "$P/TASK-N" --type test-report ...    # the proof itself
lettuce task transition "$P/TASK-N" complete --reason "guard bites" ...
lettuce cell evidence add "<coord>" --ref "$P/TASK-N" --kind task ...
lettuce cell transition "<coord>" harden ...               # guard-bite gate must pass

# FILE — organize everything you discovered before looping.
lettuce task create ...                            # one ticket per unfixed finding
lettuce cell set "<coord>" --state gap --reason "<defect>" ...  # defects get addresses
lettuce dimension member add <dim> <member> ...    # new surface → new map row
lettuce milestone set-stage <slug> --stage <s> ... # progress the hypothesis ladder
lettuce cell reconcile ...                         # regress stale greens
lettuce validate --strict ...
```

Then run `board next` again. The frontier has changed — partly because you
hardened a cell, partly because you filed what you found. That is the whole
method: **work hardens cells; cells reveal work.** FILE is not bookkeeping —
it is how the map stays truthful enough to steer the next iteration.

**The invariant that keeps the loop honest.** A `gap`/`untested` cell spawns a
task (the cell is the work's address); the COMPLETED task — `done`, carrying its
`custom/grc` run-case pin, linked as `--kind task` evidence — is what authorizes
hardening (both doors are guard-bite gated: `cell transition
harden`, and `cell set --state hardened`, which is gate-entry-only); and if
the cited evidence later unresolves, `cell reconcile` machine-regresses the
cell. What that establishes is PROVENANCE — the green traces to a completed,
graph-backed ticket — not proof that the cell's subject was checked: the gate does
not test whether the ticket is RELEVANT to the cell, how MANY cells one ticket
backs, or whether the ticket was filed to justify the very cell it now authorises. Neither direction can lie to the next iteration — which is why you never
`--facilitate` past a failed gate to "make progress": one unproven green
poisons every future ORIENT, and the bypass is on the ledger forever.

**Self-organizing, not preconfigured.** The shipped defaults (§3) are a
starting point, not the map. Discover the project's *existing* plans, specs,
and milestones and reflect them into the store — milestones with stages,
scope/unit members for the real surfaces, dimensions the project actually
needs — coordinating with your operator per whatever authority you have been
granted over map structure. An unorganized project is not an obstacle; it is
the other entry point: lettuce is how it becomes organized. One rule either
way: goals, milestones, and actual work MUST be reflected in the store — work
that lives only in your context dies with your context.

**Direction: goals and subgoals.** Today direction is carried entirely by
declared data — milestones (whose stage ladder is the hypothesis being
progressed), scopes, severities — and `board next` surfaces progress toward
it. Each scope already implies a subgoal by its shape: a grid wants its
hardened ratio at 100%, a scalars scope wants every dimension green, a ladder
wants its final stage. A first-class *project goal* field (with explicit
per-scope subgoals) is the stated intent of this design but is **not a store
primitive yet** — until it ships, put the goal in milestone descriptions and
let the shapes carry the rest.

**Survive your own restarts.** Whatever harness runs you, scheduled wakes and
crons typically die on context compaction — and your CLAUDE.md (or equivalent
always-injected instruction file) is usually the only thing that comes back
every session. Anchor the loop there, not in memory: the store coordinates
(root/project/author), the instruction to re-register the loop's wake/cron on
every cycle (a cron-guard), and the loop itself. Then each iteration: read
your own state first (`board next`, `query tasks --status active`, your held
leases) — the store, not your context, is your memory; notice when you are
churning (the same coordinates cycling without hardening means stuck); and
when stuck, widen the map (the FILE-phase moves: new members, a deeper
ladder, the next milestone stage) instead of spinning. Nothing is hardcoded —
everything the loop needs after a restart is declared store data.

**This is the basics — deeper aid is on demand.** This section is
deliberately a hint, not a manual. For richer per-command guidance,
`lettuce usage <cmd>` (and `--format json` for machine metadata); for the
loop at full depth, work the cycle and let the store teach you. If you want
standing, harness-specific loop instructions, write them into your own
CLAUDE.md — the tool stays harness-agnostic by design.

## 9. Graph authoring & enactment

Beyond one-off tasks, lettuce lets you encode a **process** once — as a sound,
named, versioned graph — and enact it repeatably. Two objects:

- A **graph-def** is the reusable authored DATA: nodes (typed by *concern*)
  wired by edges, with a start node. It is versioned + content-addressed like a
  task body. It is the "keeper": every stored def is *sound by construction*.
- A **graph-run-case** (grc) is ONE enactment of a def against the real ledger.
  It is event-sourced — `state == f(events)` — so it replays exactly.
  **Carriers** are the write-once, content-addressed evidence its edges emit.

The loop is **author → verify → compose → enact → replay**. `--project` and
`--author` apply as everywhere (§4); `--root` as in §6.

### Author — write a spec, `create` gates it

A spec is JSON: a `start` node, `nodes` (each a `concern` ∈
`producer|reviewer|router|gate|human|verifier|terminal|fork|join`), and `edges`
(`from`/`to`/`edge`; a loop back-edge must carry a positive `cap`; a `router`'s
out-edge may carry a `when` guard). Keep the spec file **outside the store root**
— a stray file under `$ROOT` trips `FW-PATH-UNKNOWN`.

**Loop iteration (`min_times`/`until_stable`/`until_drain`/`circuit_breaker`).**
A capped loop back-edge (`cap > 0`) MAY refine *how* it iterates and exits:
`min_times: K` (a floor — at least K iterations before it may exit),
`until_stable: N` (converge — exit after N consecutive clean/stable iterations),
`until_drain: true` (exit when the work-queue drains), and `circuit_breaker: M`
(trip/fail the loop after M unproductive iterations). Each is valid **only** on a
`cap > 0` back-edge — on a `cap == 0` edge it is `invalid-iteration` — and every
bound must satisfy `0 ≤ bound ≤ cap` so the exit conditions can never outlast the
hard cap. All fold into the `effective-hash` (changing `until_stable` moves it).
Like fork/join, this is authored, canonicalized and hashed here — and **enacted at
runtime** by `graph-run-case` (ENACT-3): a run-case really does close waves, count
consecutive clean ones, drain a queue and trip the breaker, refusing an early exit
with `FW-GRAPH-ITERATION-FLOOR` / `FW-GRAPH-LOOP-NOT-CONVERGED`.

**Structured parallelism (`fork`/`join`/`quorum`).** A `fork` splits control
into concurrent branches (it must branch — out-degree ≥ 2, else
`degenerate-fork`); a `join` merges them (it must merge — in-degree ≥ 2, else
`degenerate-join`). A `join` may carry `"quorum": K` — a K-of-M threshold over
its M inputs (`0` or omitted = an AND-join requiring **all** M). `K` must satisfy
`0 ≤ K ≤ M`, and a non-zero `quorum` is valid **only** on a join — anything else
is `invalid-quorum`. The quorum folds into the `effective-hash` (changing K moves
the hash). It is authored, canonicalized and hashed here — and **enacted at
runtime** by `graph-run-case` (ENACT-1/2): a fork really does open concurrent
branches and a join fires only once K of M have arrived, else
`FW-GRAPH-JOIN-UNSATISFIED`.

**A declared fork and a declared join must PAIR** (LET-1660). Beyond the degree
rules, the soundness gate refuses two *structural* shapes, judged over the
AUGMENTED edge set (bind-implied edges included, so a composed subgraph is judged
on its effective wiring): `fork-without-join` — a declared `fork` with **no
declared join reachable from every branch** — and `join-without-fork` — a declared
`join` that is **the reconvergence of no declared fork**. The reason is the
close-time consequence: the fired-join gate demands that a walk which opened
branches also FIRED its join, so a fork with no reachable join is a def *every*
walk of which is refused at close, and a join no fork reaches is a station that
can never fire and therefore never gates. Refusing the def at authoring time is
strictly better than refusing every walk of it. A **plain (undeclared) fan-out**
is untouched — it is an OR-split, promises no convergence, and only raises the
advisory `fan-out-no-join` lint smell. Across COMPOSITION the verdict DEFERS
rather than refuses: a subgraph (`uses`) node is opaque (its child need not even
be stored yet), so a fork reconverging at one — or a join a subgraph node reaches
— is accepted at authoring time and DECIDED on the expansion, where `compile`
runs the same gate with every subgraph node inlined. Full dominator matching (a
*unique* join per fork, no crossed or unbalanced split-merge) stays deliberately
deferred.

**Firing a join: `--edge` is the ARRIVING BRANCH edge, never `<fork>-><join>`.**
A fire is not an edge traversal — it *collapses* the main state from the fork
(`--from`) onto the join (`--to`) — so `--edge` names one of the edges a branch
came in on (`v1->join`). The value `--from`/`--to` make obvious, `fork->join`, is
the one value that is always wrong, and it is now refused
`FW-GRAPH-JOIN-EDGE-UNARRIVED` **before** anything is written, naming the edges
branches did arrive on (LET-914). The refusal reads THIS run-case's branch ledger,
not the graph-def — so it also refuses a *declared* in-edge no branch arrived on
(`--edge v3->join` when v3 never arrived), which `conform` cannot catch:

```bash lettuce-example proven-by=TestFireOnAnEdgeNoBranchArrivedOnIsRefused
lettuce graph-run-case advance "$GRC" --from fork --to join --edge 'v1->join' \
  --fire-join --quorum 3 --root "$ROOT" --project "$P" --author "$A" --format json
```

**The quorum counts ARRIVALS, never verdicts (LET-1579).** A join fires once K
distinct branches have arrived; no path (fire, advance, conform, close) reads what
a carrier SAYS. "Fired at 3/3" therefore means three branches arrived, not three
approvals. The convention that keeps a dissent out of the count: **a dissenting
arm produces its verdict carrier but does NOT advance its branch to the join.**
Every successful fire prints the INFO advisory `FW-GRAPH-JOIN-FIRED-ON-ARRIVALS`
(stderr and `warnings[]`) naming each counted branch and the first line of the
carrier on its arrival edge — read it, and abandon the walk if a counted verdict
dissents.

**...but WHICH arrived edge you name is not part of the fire's identity (LET-918).**
The join-fired event is content-addressed over the fork→join transition alone —
`grc·fork·visit·join` — independent of the arrived-set, the quorum *and* `--edge`.
That matters the moment two clones fire the same join: with three branches arrived
there are three equally TRUE edges, and a clone that saw `{v2,v3}` arrive **cannot**
name `v1->join` at all (LET-914 refuses it), so the divergence is forced rather than
avoidable. Folding it minted two event ids for one revision step, and the merged
store came back `FW-REVISION-CHAIN-BROKEN` → consistency-gate → *every* mutation
refused. Now both clones mint the same event and the chain stays contiguous.

The recorded `--edge` remains **provenance, and accountable**: the derived check
requires a fire to cite an edge one of its recorded arrived branches genuinely came
in on, so a hand-edited edge is `FW-STORE-MERGE-CONFLICT` at heal even though it is
no longer inside the hash. Note what "de-dupe" does and does not promise: the
directory *name* converges, which is what keeps the chain intact — but `at`,
`operation-id` and now `edge` may still differ between two clones' copies of that
one event, and git stops on those for a human to resolve. Either resolution leaves a
valid store.

**Conditional routing (`when` guards on a `router`).** A `router` node's out-edges
MAY each carry a `"when": GUARD` — the condition, over data the run-case actually
*records*, under which that branch is taken. The grammar is deliberately tiny,
total and deterministic (no CEL, no new dependency):

```
REF == "VALUE" | REF != "VALUE"      combined with  and / or / not / ( )
REF ::= carrier.<key>                # a carrier value this run-case produced (this visit)
      | outcome.<clean|progress|level-up|queue-remaining>   # the LATEST closed wave
      | effect.<cell-hardened|task-opened|artifact-added|dod-progressed|transition>
```

Every fact is a **string** and comparison is byte equality; an **unrecorded** fact
reads as `""` (a produced carrier value is never empty, so `carrier.x == ""` means
exactly "not recorded"). No clock, no randomness, no map order — the same events
always route the same way.

Semantics are **exactly-one-match**: at most one out-edge guard may hold, and a
guarded router may declare at most **one unguarded** out-edge as its **default
(else)** route. A `when` on a non-router out-edge, a malformed/unknown-vocabulary
guard, or a second default edge is refused `invalid-guard` at **authoring** — you
never learn about a bad guard mid-run. The guard folds into the `effective-hash`,
so a stored def can never route differently under the same hash. Runtime
enactment is live (ENACT-5, below).

**SHAPE vs BEHAVIOR — two orthogonal vocabularies (`concern` vs `instruction`).**
A node has **two** independent attributes, and lettuce keeps their vocabularies
strictly separate:

- **SHAPE vocabulary** (`concern`) — the *structure/geometry*: what the node **IS**.
  Terms: `producer`, `reviewer`, `verifier`, `gate`, `human`, `router`, `fork`,
  `join`, `terminal` (+ a `join`'s K-of-M `quorum`, a loop `cap`). The **runtime
  contracts enforce STRUCTURE** from these (fork branches, quorum-join, until_stable,
  gate). A shape term is a role label only — it must **not** imply behavior.
- **BEHAVIOR vocabulary** (`instruction` + the pattern **name**) — what is **DONE**
  on the shape: the node's behavioral contract, in prose. e.g. an interrogator's
  *"generate probing questions over edge cases, failure modes, and assumptions;
  require a satisfactory answer to each"*. The **instruction enforces MEANING** — it
  is folded into the `effective-hash`, so **behavior is part of identity**. Two defs
  of identical shape but different instructions hash **differently**; a pattern named
  `question-based-verification` is *unable* to not carry its behavior.

So: two names, two guarantees. `question-based-verification` is a **behavior** that
happens to sit on a `verifier`→`gate` **shape**. `instruction` is a **leaf-node**
attribute (a `uses:` subgraph node inherits behavior from the referenced def and
carries none) and is **optional** — a leaf def without one still validates; only the
advisory `missing-instruction` lint warns on a **behavioral** node (concern ∈
`producer`/`reviewer`/`verifier`/`gate`/`human`/`router`) that lacks one. The
structural nodes (`fork`/`join`/`terminal`) carry no behavior and are exempt. Every
shipped catalog pattern carries a real instruction on each behavioral node — the
catalog holds itself to that higher bar (a guard test enforces it). `graph-def show`
renders both fields; `graph-def viz` shows the shape plus a labelled `behavior: …`
line so you can SEE what a node does.

**EXECUTION — the third vocabulary (`exec_policy`).** Beside SHAPE (what a node
IS) and BEHAVIOR (what it DOES), a leaf node MAY declare **how it is RUN**:

```jsonc
{"id": "reviewer", "concern": "reviewer", "instruction": "…",
 "exec_policy": {"model": "opus-5", "effort": "high", "residency": "resident"}}
```

- **`model`** — the model *or* capability-tier the agent at this node should use.
  Deliberately an **open** vocabulary (only grammar-checked: 1–64 chars, starting
  with a letter/digit, then letters/digits/`. _ - : / + @`) — model names are
  vendor- and time-varying, so closing the set would rot. Tier words
  (`frontier`/`balanced`/`fast`) are equally valid by convention.
- **`effort`** — reasoning effort, from the **closed** ladder
  `low | medium | high`.
- **`residency`** — the **closed** actor lifecycle `resident | ephemeral`:
  whether the actor **keeps its context across loop iterations** (`resident` —
  it remembers the previous wave) or is **spawned fresh each time**
  (`ephemeral` — it re-reads from scratch). This is the **load-bearing** knob
  for loops: the same topology behaves differently depending on it.

Every knob is independently optional (declare only `residency` if that is all
you mean), but an **empty** `"exec_policy": {}` is refused — it would move the
hash while saying nothing. Bad values are refused at authoring with
`FW-GRAPH-DEF-UNSOUND` / `invalid-exec-policy`, naming the node *and* the closed
set. Like `instruction` it is a **leaf-node** attribute (a `uses:` subgraph node
inherits its atoms' policies and carries none of its own) and it **folds into the
`effective-hash`** — so *how* a node runs is part of the content-addressed
identity: flipping `resident` → `ephemeral` mints a **new version** rather than
silently mutating a pinned one, and composition carries each atom's policy onto
the inlined children. An advisory **`missing-residency`** lint warns when a
*behavioral* node **inside a declared loop** leaves its residency implicit
(advice, not a gate: the def is stored and runs; `graph-def lint` answers "smelly"
with exit 2). `graph-def show` renders `exec_policy`;
`graph-def viz` adds a labelled `exec: model=… effort=… residency=…` line, so the
three vocabularies stay visually distinct on the diagram.

```bash
# review-linear.json (author it anywhere but inside $ROOT):
# {
#   "start": "producer",
#   "nodes": [
#     {"id": "producer", "concern": "producer", "instruction": "produce the artifact to be admitted"},
#     {"id": "gate",     "concern": "gate",     "instruction": "admit only if it meets the criteria; else send it back"},
#     {"id": "terminal", "concern": "terminal"}
#   ],
#   "edges": [
#     {"from": "producer", "to": "gate",     "edge": "producer->gate"},
#     {"from": "gate",     "to": "terminal", "edge": "gate->terminal"}
#   ]
# }
lettuce graph-def create review-linear --spec-file ./review-linear.json \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

`create` folds in the **soundness gate** (liveness ∧ boundedness): every edge
must reference a declared node, every node must be reachable from the start,
there must be a declared start, and every loop back-edge must be capped. An
unsound spec is refused `FW-GRAPH-DEF-UNSOUND` (the diagnostic names the
offending node/edge) and **nothing is written** — so a run-case can never enact
a broken topology. `create` also pins the compiled `effective-hash`.

**Unknown keys are WARNED, not refused (LET-734) — read the advisory.**
A spec is stored **verbatim** and its bytes are folded into `effective-hash`, so
a key the schema does not recognise does the opposite of vanishing: `graph-def
show`'s `spec` field reads it back, the hash covers it, and a run-case pins that
hash — so the phantom declaration looks *pinned and immutable* while **nothing
reads it**. `create` and `revise` therefore scan the raw JSON and report every
unrecognised key with its path (`nodes[<id>].<key>`,
`edges[<from>-><to>].<key>`, `nodes[<id>].exec_policy.<key>`, or a bare
top-level key) on **both** surfaces (LET-913): the human line on **stderr**, and
the same diagnostic in the success envelope's **`warnings`** array under
`--format json|yaml`. If you automate this command, parse `.warnings` — a
non-empty array is the only signal that a key you wrote means nothing.
The same is true of `registry create workflow` / `workflow revise` (LET-718).
Exit stays **0** and the def is still stored: unknown-field
tolerance is deliberate, and refusing would make every already-stored spec
unrewritable. The truth is always in the **parsed** view — the `nodes`/`edges`
arrays of the same payload — and it shows by *absence*, which is the hardest
thing to notice. If you meant the key to do something, it does not.

### Verify — `lint` (advisory) and `compile` (canonical hash)

```bash
lettuce graph-def lint    review-linear --root "$ROOT" --project "$P" --format json
lettuce graph-def compile review-linear --root "$ROOT" --project "$P" --author "$A" --format json
lettuce graph-def show    review-linear --root "$ROOT" --project "$P" --format json
lettuce graph-def list                  --root "$ROOT" --project "$P" --format json
```

- `lint` reports **advisory design smells** (an UNDECLARED fan-out with no join —
  a *declared* unpaired fork/join is refused by soundness, not smelled — a terminal
  with an out-edge, a gate/verifier that dead-ends, a review edge with no
  evidence carrier, a behavioral node with no `instruction`, and a behavioral
  node **inside a loop** with no `exec_policy.residency`) as warnings. It is a
  VERDICT: exit 0 when clean, **exit 2** (`ok:true`) when any smell is found — a
  smelly def is still sound and still runs. Distinct from soundness, which is
  fatal.
- `compile` canonicalizes the def (nodes sorted by id, edges by tuple,
  whitespace normalized) and content-hashes it → the `effective-hash`. It is
  deterministic and order-invariant (any spelling of the same topology → the
  same sha256) and idempotent (re-compiling an unchanged def rewrites the same
  hash). A grc pins this hash at open, so a running case is reproducible even if
  the def is edited mid-flight. A rename does not move the hash; a topology
  change does.

### Revise — append a new spec version (`revise`)

```bash
lettuce graph-def revise review-linear --spec-file ./review-linear-v2.json \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

`revise` is the in-band spec-version authoring path — the counterpart to `create`:
`create` mints a NEW slug (an existing slug is refused), `revise` **appends** a
new spec version to a slug that already exists (a missing slug is refused
`FW-PATH-NOT-FOUND`). It runs the **same soundness gate** as `create` and — only
if sound — appends the new version and **re-pins the effective-hash from it**, so
after `revise` `graph-def show`/`compile` reflect the LATEST spec and its new
hash. An UNSOUND revise is refused `FW-GRAPH-DEF-UNSOUND` and **nothing is
appended** (the latest version is unchanged).

The append is **version-safe**, and this is the composition payoff: a
`<slug>@<version>` reference **pins** a specific spec version. A floating
`<slug>@latest` (or an omitted version) is **resolved and pinned to the current
highest version AT AUTHOR TIME** — the stored spec always holds a version-exact
ref, so an immutable pinned spec version is reproducible and its `effective-hash` can
never move when a child later gains a version. So a parent that pins
`uses: review-linear@1` still compiles to the OLD expansion after a revise;
to pick up a newer child a consumer **re-authors** (`revise`) and its floating
`@latest` re-pins to the new highest at that moment. (A `@latest`/omitted ref to
a child that does not exist yet cannot be pinned and is refused
`FW-GRAPH-DEF-UNPINNABLE` — name an explicit `<slug>@N` or author the child first.)

### Compose — subgraphs by name (`uses` / `bind`)

A node may be a SUBGRAPH that inlines another def instead of carrying a
`concern`: `uses: "<slug>@<version>"`, with `bind` wiring the subgraph's open
ports (`start` / `terminal`) to nodes in the PARENT. The subgraph is entered via
the parent edge that targets the subgraph node.

```bash
# pipeline.json — inline review-linear@1 as one node, bind its exit to `done`:
# {
#   "start": "intake",
#   "nodes": [
#     {"id": "intake", "concern": "producer"},
#     {"id": "review", "uses": "review-linear@1", "bind": {"terminal": "done"}},
#     {"id": "done",   "concern": "terminal"}
#   ],
#   "edges": [{"from": "intake", "to": "review", "edge": "intake->review"}]
# }
lettuce graph-def create  pipeline --spec-file ./pipeline.json \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce graph-def compile pipeline --root "$ROOT" --project "$P" --author "$A" --format json
```

`compile` is the composition ENFORCEMENT point: it resolves the transitive
`uses` closure, inlines every referenced def (namespacing inlined node ids), and
hashes the WHOLE expansion. Because every stored ref is version-exact (floating
`@latest` was pinned at author time), recompiling a parent is DETERMINISTIC —
improving a child does NOT move a parent's `effective-hash`; a consumer picks up
a child improvement only by **re-authoring** (`revise`), which re-pins its
`@latest` to the new highest version. A composition cycle is
refused `FW-GRAPH-DEF-CYCLE`; a missing used def `FW-PATH-NOT-FOUND`; an unbound
port or unsound expansion `FW-GRAPH-DEF-UNSOUND`. (A forward/cyclic `uses` ref is
tolerated at `create` — the hash is pinned provisionally — and enforced at
`compile`.)

### The shape vocabulary — say which topology you are building

A graph-def is assembled from a small set of named **shapes**. The terms below are
the whole vocabulary; every one of them is a real primitive with a real refusal
behind it, not a diagram word. Say the shape out loud when you author, and reach for
the catalog pattern that already ships it rather than re-deriving the topology.

**A shape without its guardrail teaches the shape and not the discipline.** Each row
names the refusal that makes that shape honest, because the topology alone is
decorative: the enactment runtime is graph-def-FREE (`advance` reads no spec), so a
def never enforces anything *by itself* — what refuses is the primitive the enactor
drives it with, and `graph-run-case conform` replays the walk against the pinned def
afterwards.

<!-- SHAPE-VOCABULARY: every backticked token in this table is guarded against the
     code by TestSkillShapeVocabularyMatchesBehavior (LET-917). Do not hand-edit a
     term here without checking it still names something real. -->

| shape | built from | the refusal that makes it honest | ships as |
|---|---|---|---|
| **fan-out** | a `fork` node + `--open-branch` per out-edge | `FW-GRAPH-FROM-MISMATCH` — a `--branch` advance is guarded on THAT branch's own tip, so a branch cannot be walked from a state it never reached | `review-panel@v1` |
| **fan-in at a barrier** | a `join` node + `--fire-join --quorum` | `FW-GRAPH-JOIN-UNSATISFIED` below K, and `FW-GRAPH-JOIN-EDGE-UNARRIVED` if the fire names an edge no branch arrived on | `claim-verification@v1` |
| **the diamond** | fan-out then fan-in on one `fork`/`join` pair | as above — the house shape, and the one to reach for by default | `red-team@v1` |
| **routing** | a `router` node + `--route-edge` guards over recorded evidence | `FW-GRAPH-ROUTE-UNSATISFIED` / `FW-GRAPH-ROUTE-AMBIGUOUS` / `FW-GRAPH-ROUTE-NO-MATCH` — the caller cannot overrule the guards | `escalating-review@v1` |
| **verification** | a `verifier` or `gate` node + a `carrier` on its out-edge (and `--produces` to declare it) | `FW-GRAPH-PRODUCES-UNSATISFIED` — you cannot leave a station without the carrier its edge declares, whether or not you claimed one (LET-1676) | `question-based-verification@v1` |
| **converging cycle** | a capped back-edge + `--wave-close` then `--loop-exit` | `FW-GRAPH-LOOP-CAP-EXCEEDED` / `FW-GRAPH-LOOP-NOT-CONVERGED` / `FW-GRAPH-CIRCUIT-TRIPPED` — the exit is a fold over the wave log, not a claim | `audit-wave@v1` |
| **tournament** | a `fork` of competitors, a quorum `join` as the match barrier, and a `router` with **no** default edge | `FW-GRAPH-ROUTE-NO-MATCH` — every crown edge is guarded on the recorded verdict AND on both entries existing, so a winner that beat nobody is a dead-end, not a fall-through | `tournament@v1` |
| **generate-and-filter** | a `gate` that pins the rubric first, a `fork` of candidates, an AND-`join`, and a `router` whose UNGUARDED edge lands on the empty terminal | `FW-GRAPH-ROUTE-UNSATISFIED` — reaching the shortlist needs a recorded **keep** verdict; rejecting every candidate is the DEFAULT route, so the filter can always return zero | `generate-and-filter@v1` |

<!-- /SHAPE-VOCABULARY -->

The supporting terms, in the order you meet them: a **carrier** is the write-once,
content-addressed datum an edge produces (`carrier produce`), and it is what makes an
edge REAL — order is not an edge and proximity is not an edge; only data crossing is.
A **visit** is the loop-iteration index a carrier and an edge traversal are keyed by. A
**branch coordinate** is the content-addressed id `--open-branch` mints. A **wave** is
one closed pass round a cycle. **Quorum** is K-of-M — a *partial* barrier, so a join
is evidence that K independent things agreed rather than a mere synchronisation point.
**Residency** (`exec_policy`) says whether a station's actor is `resident` across
iterations or spawned fresh (`ephemeral`). A **`uses`-composite** is a def that inlines
another by version-exact reference, and **`cost_hint`** is a pattern's ordinal
worst-case band — catalog metadata that the runtime never reads.

### Usage by name — the pattern catalog

Beyond authoring a def from scratch, lettuce ships a **catalog** of canonical,
versioned, VERIFIED graph-def **patterns** embedded in the binary — the org
authors a sound process once and every consumer pulls it in *by name*
(mission-graph-engineering SCH-5a). Every shipped pattern is sound by
construction and compiles to a stable `effective-hash`.

```bash
# Browse the shipped patterns (name@vN + one-line description + effective-hash).
lettuce graph-def catalog list --format json
# Inspect one pattern's spec + nodes/edges + hash.
lettuce graph-def catalog show review-panel@v1 --format json
# MATERIALIZE it as a new def in your project, then compose/compile/enact it.
lettuce graph-def use review-panel@v1 --as my-review \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce graph-def compile my-review --root "$ROOT" --project "$P" --author "$A" --format json
```

Shipped patterns — browse them with `catalog list` rather than trusting this list to
stay complete: `gate@v1` (producer→gate→terminal), `tracer-bullet@v1`
(producer→verifier→terminal), `review-panel@v1` (fork→3 reviewers→join
`quorum=2`→terminal) and `review-panel@v2` (5 reviewers, 4-of-5 supermajority),
`claim-verification@v1` (claim→fork→3 verifiers→join `quorum=2`→terminal) and
`claim-verification@v2` (unanimous 3-of-3), `red-team@v1` (an adversarial panel whose
unanimous join gives every branch a VETO), `question-based-verification@v1` (an
interrogator feeding a must-answer gate), `audit-wave@v1` (a review panel looped back
through a gate until N consecutive clean waves — the convergence QA loop, `cap=5
until_stable=2 min_times=1`), `harden-loop@v1` (a producer→work→verify cycle with a
capped `circuit_breaker=3` back-edge — iterate a fix→verify until it trips or
converges), `external-audit@v1` (a convergence loop signed off by a distinct
out-of-loop auditor), `tournament@v1` (pairwise competition whose crown router has no
default edge, so a winner must have BEATEN someone), `generate-and-filter@v1` (many
candidates against one pre-declared bar, where an EMPTY survivor set is the default
route), `deep-review@v1` (an 8-stage `uses:`-composite of the atoms above), and
`escalating-review@v1`/`@v2` (the cheap-first cost gradient — routers that escalate
only on positively recorded evidence).
`catalog list`/`catalog show` are **read-only and project-independent** (the
catalog is embedded, not stored per-project); an unknown `name@version` is
`FW-PATH-NOT-FOUND`.

`use` is a **straight materialize**: it stores the pattern's spec as a new def
via the same path as `create`, so the soundness gate + effective-hash pin apply
and the new def compiles to the **same** hash `catalog show` reports (a faithful
reproduction). The result is an ordinary def — composable (a `uses` node),
compilable, enactable. A slug already in use is refused exactly as `create`.
**Deferred:** `--bind` re-wiring of a pattern's open ports at use time
(specialize a materialized pattern the normal way — a parent def that `uses` it
and binds its ports).

### Enact — open a run-case and walk it

Open a grc at the def's start node, then advance edge by edge. Before writing a
`case-advanced` event, `advance` runs a **produces post-check** — TWO readings of one
requirement, both refusing with `FW-GRAPH-PRODUCES-UNSATISFIED` and leaving state
unchanged:

1. each `--produces KEY` must already exist as a carrier on the leaving edge×visit
   (the promise the **caller** made); and
2. since **LET-1676**, a carrier the **graph-def** declares on the edge being crossed
   (`"carrier": "K"` on that edge) must be bound to that edge×visit — whether or not
   `--produces` names it (the promise the **edge** made).

So produce the carrier first. `--produces` is how you SATISFY an edge's declaration,
not how you opt into being checked.

**The `carrier` field is the ONE authored edge attribute the runtime enforces — and
`produces` on an edge is still not it.** An edge does **not** carry a `produces`
declaration: `GraphDefEdge` has no such field, so writing `"produces": ["artifact"]`
on an edge in a spec declares nothing (it survives verbatim in the stored raw `spec`
and is covered by `effective-hash`, which makes it *read back* as declared). Since
LET-734, `graph-def create`/`revise` **warn** on that key rather than accepting it in
silence, so the mistake is caught where it is made. Write `"carrier": "artifact"` when
you mean the edge to require evidence.

**An undeclared name is refused at the door (LET-1944, v0.20.4).** An advance whose
`--to`, `--edge`, `--loop-edge`, `--route-edge` edge or `--route-else` — or a `carrier
produce` whose `--node`/`--edge` — names a node or edge the def version this run PINNED
does not declare (compiled, for a composite: `node@sub`) is refused
`FW-GRAPH-WALK-UNDECLARED` before anything is written or the gate trio runs. The refusal
lists the declared moves out of the run's position (`next_edges`, LET-1902) in
`expected`, and each as a copy-pasteable command in `suggested_actions` — a mangled
`claim-` from an unquoted `claim->fork` is caught where it is typed. Not re-judged:
`--from` (the from-guard pins it to the stored state), a `--fire-join` arrival edge
(judged from the branch ledger, below), a carrier `--key` (a def declares the carriers an
edge demands, not a closed key set), a closed case (`FW-GRAPH-CASE-CLOSED` speaks), and a
def that cannot be read at all — that one still warns at the advance and bites at the
**close** (`FW-GRAPH-CLOSE-NON-CONFORMING`, see below). Enforcement is at WRITE time
only: a walk a store already holds (recorded before v0.20.4) is never re-validated and
stays readable; `conform` keeps reporting it.

**Everything else about `advance` IS still graph-def-free, and that is the design —
the `carrier` check and the declared-NAME check above are the documented exceptions.**
For every other authored attribute `advance` reads NO stored def: the join `--quorum`, the loop
`--cap`/`--min-times`/`--until-stable`/`--circuit-breaker`/`--until-drain` bounds, and
the router's `--route-edge`/`--route-else` guards are ALL resolved by the CALLER from the
def and passed as flags (see `AdvanceGraphRunCaseOptions`). Verified live: an edge
declaring `cap: 1` is walked twice with exit 0 when `--cap` is omitted, and refused
when it is supplied. So those authored edge attributes remain elective, and a spec's
`cap`/`when`/`quorum` is still a claim about the process rather than a runtime
guarantee. **Do not generalise from `carrier` to them, and do not generalise from them
to `carrier`** — that generalisation is exactly what this paragraph used to license,
and it was what let 1410 uncarried crossings of carrier-declaring edges reach 240
closed run-cases before LET-1676.

LET-914's join-fire refusal does **not** break this: it checks `--edge` against the
run-case's OWN branch ledger (which edges branches actually arrived on), never against
the stored def. Every def `advance` does read is the version the run PINNED, never the
latest, so a def revised mid-run can never strand an in-flight case.

The READ-time counterpart is `graph-run-case conform` (LET-720), which replays the
ledger against the pinned def — but know **exactly** what it covers before leaning on
it. It reports undeclared edges, unknown nodes, a join fired **below** the declared
quorum, a declared carrier absent from a crossed edge, two carriers on one coordinate,
a finished walk where no declared join ever fired, a declared-ephemeral station worked
twice by one actor, a missing def, an unresolvable pin, and a finished-but-empty walk.
**Since LET-1676 it is not only a report:** `graph-run-case close --disposition
completed` calls this same reader and REFUSES with `FW-GRAPH-CLOSE-NON-CONFORMING`
when the walk CONTRADICTS the def it pinned — so run `conform` before you close. It is
still NARROWER than this report on purpose, at both edges: since LET-1680 every
CONTRADICTION finding blocks (`CARRIER-MISSING`, `CARRIER-AMBIGUOUS`,
`UNDECLARED-EDGE`, `UNKNOWN-NODE`, `QUORUM-BELOW-DECLARED`, `JOIN-NEVER-FIRED`,
`NO-TRANSITIONS`, `DECLARED-EPHEMERALITY-VIOLATED`) while conform's UNKNOWN arm — a
missing def, an unresolvable pin — is reported HERE and does NOT block, because
refusing on "I could not check" is a different act from refusing on "I checked and it
is false"; and `--disposition abandoned` / `--disposition failed` stay allowed on a
non-conforming case (LET-728: an honest claim must not cost more than silence). A false refusal
strands honest work while a false report is only noise, which is why the two surfaces
share the reader and not the threshold. It does **not** check the loop bounds or the router guards:
verified live, the run that walked a `cap: 1` back-edge twice reports
`conforms: true`. So do not read a spec's attributes
as runtime guarantees. Read them as the process a run is *claimed* to have followed,
check the claim with `conform`, and know that the caps and guards are outside what it
checks.

**"Against the pinned def" became true only in LET-919 — it was documented here
before it was implemented.** A run-case pins `graph-effective-hash` at open, but
nothing could resolve that hash back to a stored spec version, so `conform` read the
def's **latest** spec. `hash_drifted` honestly reported that the def had moved, yet
the **verdict** was computed against the wrong text: `graph-def revise` silently
changed the basis of every prior run-case's verdict — the walk did not move, the
yardstick did (the LET-727 moved-goalpost shape, one object over). `conform` now
resolves the pinned hash by recomputing each stored version's effective-hash
newest-first and judging against the version that mints it, reporting
`pinned_version` and `latest_version` so the basis is never inferred:

- `pinned_version: 1, latest_version: 3` — judged against v1; the def has since moved on.
- `pinned_version: 0` **with** a `pinned_hash` — `FW-GRAPH-CONFORM-PIN-UNRESOLVABLE`.
  No stored version mints that hash, so the topology walked cannot be recovered and
  **no verdict is computed**. Falling back to the latest here is exactly the defect.
- `pinned_version: 0` with **no** `pinned_hash` — a run-case older than the pin.
  Judged against the latest and labelled as such; absence is history, not guilt.

**One basis for every judge of a walk (LET-1922, LET-1923).** `conform` (and so the
`close --disposition completed` door), the `advance` carrier door and the task-gate arms
all judge a run against the SAME text: the pinned version, and for a `uses:` composite its
COMPILED form — the inlined, namespaced nodes (`c1@sub`) that `next_edges` advertises and
walkers address. So a `graph-def revise` never adds or waives an advance-time carrier demand
for a run opened before it, a child's `carrier` declaration is enforced at advance on the
inlined edge, and a faithful composite walk conforms and closes `completed`. On a compiled
form an edge is named either by its declared name or by its endpoints (`c1@sub->c2@sub`, the
form `next_edges` gives); a composite whose child no longer compiles is
`FW-GRAPH-CONFORM-COMPOSITE-UNRESOLVABLE` (unknown, like PIN-UNRESOLVABLE).

The per-version hash is **recomputed, never stored**. A stored per-version hash would
be an unverifiable claim: tamper with a stored version's bytes, leave the stored
hash alone, and a
pin would still "resolve" to v2 while `conform` judged the walk against text that
version no longer holds. Recomputing makes the version's own bytes the only evidence.

**`open` is gated (LET-733) — the def must EXIST and `--start` must be its
declared start.** `--graph` is a **reference to a stored def's slug**, not a
free-text label: a slug the project does not declare is refused
`FW-GRAPH-DEF-UNKNOWN` (naming the defs it *does* declare), and a `--start` that
is not the def's declared start is refused `FW-GRAPH-START-UNDECLARED` (stating
the start that works). This is what makes the reproducibility pin a **binding**
— an unresolvable `--graph` pins nothing while still minting a well-formed
`grc_…` id that passes every downstream grammar check. Note the second refusal
also blocks opening at a *real but later* node: from there every edge walked is
a declared edge, so `conform` would report `conforms:true` over a run that
skipped the whole process. The gate is on **new opens only** — already-recorded
run-cases keep reading, advancing, closing and validating even if their def is
later revised, renamed or deleted, and `advance` judges names only against the
version the run pinned (LET-1944).
(A composite whose declared start is a `uses` node accepts either the authored
start or the inlined effective start `compile` enters at.)

```bash
# Open — mints a grc id (grc_…) and records the start node (revision 1).
# The id is in the JSON envelope at .data.data.id — note the DOUBLE data.
# MUTATION envelopes nest the command payload one level under an operation wrapper;
# READ envelopes (list/show) are flat. Reaching for the flat path on a mutation yields
# null, and every later command in this section then runs against an empty id.
lettuce graph-run-case open --graph review-linear --start producer \
  --root "$ROOT" --project "$P" --author "$A" --format json
# -> {"data":{"operation_id":"op-…","data":{"id":"grc_…","state":"producer","revision":1}}}
#    GRC=$(… --format json | jq -r .data.data.id)
GRC=grc_…                            # the minted id from the line above

# Produce the evidence the producer->gate edge emits (write-once, content-addressed).
lettuce carrier produce --graph-run-case "$GRC" --node producer \
  --edge 'producer->gate' --key artifact --value 'art:GATE-1/1' \
  --root "$ROOT" --project "$P" --author "$A" --format json

# Advance across the guarded edge — the produces post-check reads the carrier back.
lettuce graph-run-case advance "$GRC" --from producer --to gate --edge 'producer->gate' \
  --produces artifact --root "$ROOT" --project "$P" --author "$A" --format json

# Advance to the terminal (optionally record what this node changed with --effect,
# and what the step actually COST with --duration-ms / --cost-usd).
lettuce graph-run-case advance "$GRC" --from gate --to terminal --edge 'gate->terminal' \
  --duration-ms 1250 --cost-usd 0.42 \
  --root "$ROOT" --project "$P" --author "$A" --format json

# Inspect derived state plus the forward timeline/effects/joins audit trail, then close.
lettuce graph-run-case show  "$GRC" --root "$ROOT" --project "$P" --format json
lettuce graph-run-case close "$GRC" --from terminal --outcome completed \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

**Where can the walk go next? (LET-1902)** Every `open` and `advance` result lists
the legal moves out of the position it just reached, at `.data.data.next_edges`,
read from the graph-def version this run **PINNED** — never the latest, so a
`graph-def revise` after open never changes a run's guidance. `next_edges_basis`
names that source (`pinned`, or `latest-unpinned` for a pre-pin run) or why there
is none (`def-missing`, `pin-unresolvable`, `composite-unresolvable`,
`position-undeclared`, `no-def`, or `unreadable` — the mutation stands). Each move gives the exact `from`/`to`/`edge` to
pass and an `intent`:

| intent | take it with |
|---|---|
| `advance` | a plain `advance`; add `--branch COORD` when the move names a `branch` |
| `open-branch` | `advance --open-branch` (the move leaves a fork; open one branch per edge) |
| `fire-join` | `advance --fire-join --quorum K` from the fork, on this branch's arrival edge (`quorum` is the def's K) |
| `loop-exit` | `advance --loop-exit --loop-edge LOOP_EDGE` (the move leaves a loop head) |

`carrier` names a carrier the def declares on the edge — produce it before crossing
(the advance refuses otherwise); `when` is a router edge's guard. The list is omitted
at a terminal: close the run. A composite is guided on its compiled form (`node@sub`).
The guidance is advisory: every move is still judged by the same guards.

**Routing out of a `router` (ENACT-5).** When the node being left is a `router`,
pass its **whole** out-edge guard set with repeatable `--route-edge 'EDGE=WHEN'`
(plus `--route-else EDGE` for the single unguarded default). The engine resolves
the guards' facts from **this run-case's own ledger** and decides — the caller does
not get to pick:

```bash lettuce-example proven-by=TestEnact5GuardFalseRefusesAdvance
# The producer records the decision as a carrier on the edge into the router.
lettuce carrier produce --graph-run-case "$GRC" --node producer \
  --edge 'producer->route' --key decision --value approved \
  --root "$ROOT" --project "$P" --author "$A" --format json

# REFUSED: the guards select route->approve, so the reject branch is barred.
lettuce graph-run-case advance "$GRC" --from route --to reject --edge 'route->reject' \
  --route-edge 'route->approve=carrier.decision == "approved"' \
  --route-edge 'route->reject=carrier.decision == "rejected"' \
  --root "$ROOT" --project "$P" --author "$A" --format json   # FW-GRAPH-ROUTE-UNSATISFIED

# PERMITTED: the same command along the edge the evidence selects.
lettuce graph-run-case advance "$GRC" --from route --to approve --edge 'route->approve' \
  --route-edge 'route->approve=carrier.decision == "approved"' \
  --route-edge 'route->reject=carrier.decision == "rejected"' \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

Three refusals, each with a different repair, and **all** leave state unchanged:
`FW-GRAPH-ROUTE-UNSATISFIED` (this edge is not the selected one — the diagnostic
names the edge that *is*, plus the facts it read), `FW-GRAPH-ROUTE-AMBIGUOUS` (two
guards true at once, or a carrier fact recorded with two divergent values — never
a silent pick), and `FW-GRAPH-ROUTE-NO-MATCH` (nothing matched and no
`--route-else` — a loud dead-end, not an arbitrary fall-through). The decision and
the evidence it consumed are written onto the `case-advanced` event
(`route-taken`, `route-guard`, and the `route/` + `route-facts/` groups) and folded
into its content-addressed id — so routing is auditable, replay-stable, de-dupes
across clones, and a **fabricated** route is refused at merge-heal (Class-B).

`advance` also takes `--visit N` (loop iteration; default 0),
`--effect 'KIND|REF[|CARRIER]'` (record a mutation this node performed — e.g.
`cell-hardened|<cell-ref>|<carrier-ref>`), and a gate trio (`--gate-task
REF --gate-action ACTION --gate-reason TEXT`) that fires a real task-workflow
transition as a PRE-gate (a missing requirement bites
`FW-WF-REQUIREMENT-UNSATISFIED`). `carrier produce` takes `--ref
'ROLE|REF[|NOTE]'` to attach typed enrichment (evidence/subject/tool/…). A
refused advance or a divergent double-produce leaves the ledger unchanged — the
post-checks are the contract's teeth.

**An effect that names a nonexistent object is refused at write time — and one
already on disk is repaired by ACKNOWLEDGEMENT, never by forging.** `advance
--effect` checks the ref's grammar AND that the referenced object **exists** (and
so for the effect's optional carrier back-ref): a well-formed ref that resolves to
nothing is refused with `FW-REF-DANGLING` before the event is written, so no NEW
dangling effect can be minted (LET-1557). `carrier produce --ref` enrichment refs
are checked for grammar (and an `author|` ref for a registered author) at write
time; their existence is judged at rest. The at-rest check still runs over every
effect ref as the defence for LEGACY refs written before the write-time refusal and
for anything placed out of band: such a ref is `FW-REF-DANGLING` and holds the
whole store at `valid=false`. Two "fixes" are
wrong: **creating the missing object forges a record of work that never happened**,
and **editing the event is impossible** (events are write-once, and the effect set
is folded into the event's content-addressed id). The honest repair is to record
that the finding is known and accepted:

```bash
# copy the EXACT path from `lettuce validate --format json` (the FW-REF-DANGLING `path`)
lettuce graph-run-case acknowledge GRC \
  --path 'projects/P/graph-run-cases/GRC/events/EVT/effects/0/ref' \
  --reason 'why this dangling reference is accepted' \
  --project P --author you
```

This records a canonical acknowledgement against the run-case; `validate` then
reports the finding as the **warning**
`FW-REF-DANGLING-ACKNOWLEDGED` (carrying your reason), so the store is *valid*
while the breakage stays visible and attributed. An unacknowledged ref stays the
**error** `FW-REF-DANGLING`. The command refuses a path that is not currently a
dangling finding, so an acknowledgement can never pre-empt future breakage; a
record whose finding is gone is reported `FW-REF-DANGLING-ACK-STALE`.

The same mechanism covers a **carrier enrichment ref**:
`lettuce carrier acknowledge CAR --path 'projects/<p>/carriers/<car>/refs/<j>/ref' --reason '…'`
records the identical acknowledgement against the carrier, and `validate`
reclassifies that finding exactly the same way. One record type, one set of guards, two owning
objects.

And it covers the one **merge conflict** with no in-place repair (LET-1677): a divergent
double-produce on a write-once carrier coordinate — `doctor` reports
`FW-STORE-MERGE-CONFLICT` on BOTH carriers' `coordinate` field, each carrier coherent with
its own event. Do **not** delete either carrier (history) or pick one (a forged verdict):
`lettuce carrier acknowledge CAR --path 'projects/<p>/carriers/<car>/coordinate' --reason '…'`
once per carrier. `doctor` then reports the warning `FW-STORE-MERGE-CONFLICT-ACKNOWLEDGED`,
honoured only while the recorded sibling set (`ref`) is the live one; a changed collision is
an error again and the old record is `FW-STORE-MERGE-CONFLICT-ACK-STALE`.

**Pre-enforcement history (LET-1677).** A closed run-case whose own close event predates the
close-time conformance door (`2026-09-16T21:54:48Z`, LET-1676/1680) reports its door-refused
contradictions as the doctor WARNING `FW-GRAPH-RUN-CASE-NON-CONFORMING-PRE-ENFORCEMENT`; one
closed at or after it keeps the ERROR `FW-GRAPH-RUN-CASE-NON-CONFORMING`. `doctor --format json`
states both counts in `summary.landmarks[]`. Never backfill carriers onto a closed case; re-walk
the work if it needs certifying.

**Run telemetry — what a step actually cost (`--duration-ms` / `--cost-usd`).**
An advance MAY record the **observed** cost of the step it just performed:
`--duration-ms N` (wall-clock milliseconds) and `--cost-usd 0.42` (a
non-negative decimal, at most 6 decimal places — converted **exactly** to
integer micro-USD, so sums never drift and `0.42` / `0.420000` are the same
evidence). Both are stored **verbatim** on the `case-advanced` /
`branch-advanced` event. The engine reads **no clock** for this — *you* supply
the numbers, it records what it is told.

The one rule: **telemetry is EVIDENCE, never CONTROL INPUT.** No derived-state
reader folds it, so routing, joins, loops, the terminal and replay are all
untouched by what a step cost — driving the same transitions with wildly
different telemetry folds to an *identical* state, revision and transition
sequence. It *is* folded into the content-addressed event id (exactly like
`--effect` and the routing decision), so an identical re-record de-dupes while
two clones recording **different** readings for the same transition fork the
chain (Class-B refuse) rather than silently unioning. Because it describes *a
step an actor performed*, it is **refused** (never silently dropped) on the
control-bookkeeping intents `--open-branch` / `--fire-join` / `--wave-close` /
`--loop-exit` — a wave's cost is already recorded by the advances that made up
the wave. A **routed** advance (above) *is* a step an actor performed, so
`--route-edge`/`--route-else` and the telemetry flags compose freely: a router
step records both *why* it went that way and *what it cost*.

`graph-run-case show` reports the per-run totals — refolded from the event log
(main chain **and** every branch), never a stored cache:

```jsonc
"telemetry": {"steps": 2, "duration_ms": 2000,
              "cost_micro_usd": 500000, "cost_usd": "0.5"}
```

Absent entirely for a run that recorded nothing. Pair it with the node
`exec_policy` above and a run answers both *how it was meant to be staffed* and
*what that actually cost*.

The same show response also carries `timeline` (ordered main-chain steps),
`effects` (forward produced-effect records), and `joins` (fired joins with quorum,
arrival edge, and arrived branch coordinates). They are event-derived on both the
local and HTTP backends; do not inspect server filesystem paths to reconstruct them.

### Visualize — `viz` (eyeball the graph)

Render a graph-def — or a running run-case — as a **mermaid** diagram (or DOT) so
you can *see* the topology. mermaid renders natively on GitHub, mobile, and
artifacts and converts cleanly to PNG. Node **shape encodes the concern**
(producer = stadium, gate = diamond, human = parallelogram, router = hexagon,
terminal = subroutine box, fork/join = trapezoids, reviewer/verifier = rectangle;
a `uses` subgraph node = a single cylinder labelled with its ref). A join's label
shows its **quorum** (`join (2 of 3)`), and a **loop back-edge** (cap>0) renders
dashed with its cap + iteration primitives (`cap=5 min_times:1 until_stable:2`).
A node carrying a behavior or an exec-policy gets extra labelled lines
(`behavior: …`, `exec: model=… effort=… residency=…`), so SHAPE, BEHAVIOR and
EXECUTION never blur together. Output is deterministic (sorted) so it diffs
cleanly.

```bash
# A graph-def as mermaid (default). --format mermaid|dot print the raw diagram to
# stdout (pipe it to a .mmd/.dot file or paste into a GitHub/artifact block);
# --format json|yaml wrap it in the envelope's `diagram` field.
lettuce graph-def viz review-linear --root "$ROOT" --project "$P" --format mermaid > graph.mmd
lettuce graph-def viz review-linear --root "$ROOT" --project "$P" --format dot   > graph.dot

# A run-case with its live state OVERLAID: the CURRENT node is highlighted and
# every VISITED node is styled distinctly (classDef current / visited).
lettuce graph-run-case viz "$GRC" --root "$ROOT" --project "$P" --format mermaid
```

`viz` is a pure read. An unknown slug / run-case is `FW-PATH-NOT-FOUND`. The
default view shows a composite (`uses`) node as ONE node — a `--expand` flag that
inlines the referenced subgraph is deferred; DOT is graph-def-only (the run-case
overlay is mermaid-specific).

**An unknown graph ref tells you how to repair it (LET-946).** Every
`FW-PATH-NOT-FOUND` on a graph-def slug or a `grc_…` id — from `graph-def
show`/`compile`/`lint`/`viz`/`revise` and `graph-run-case
show`/`viz`/`conform`/`advance`/`close` — now carries `expected`, `actual` and a
non-empty `suggested_actions[]`, and it distinguishes the two ways a reference can
be unusable. A **malformed** ref answers with the GRAMMAR (`lowercase-letter start
then a-z/0-9/-…` for a slug, `grc_ followed by 32 lowercase hex characters` for a
run-case id), because no stored object could carry that name. A **well-formed but
absent** ref answers with the discovery command (`graph-def list --project P` /
`graph-run-case list --project P`) plus, for a graph-def only, how to author it —
a grc id is minted by the store, so opening a new run-case would produce a
different id and repair nothing. Two refs get no discovery command on purpose: a
missing `uses: <slug>@N` spec **version** is answered by ENUMERATING the versions
that exist (`graph-def show` reports the object revision, not the version count),
and a `--branch` coordinate is answered by `graph-run-case show`'s
`branches[].coordinate`. Read `suggested_actions[]`, not `expected` — the actions
are the machine-readable half.

**Which spec version a run-case viz draws (LET-921).** The topology is the version
the run-case **PINNED** at open — resolved from its `graph_effective_hash` by the
same resolver `graph-run-case conform` uses — *not* the graph-def's latest. So
`graph-def revise` never redraws a run that walked the earlier text, and the picture
agrees with the conform report about the same run. A `uses:` composite is drawn in its
**COMPILED** form (subgraphs inlined, child nodes named `node@use`), the basis every
judge of the walk reads (LET-1923/1935), so a run standing inside a subgraph is drawn
there; the envelope then carries `compiled: true` and the `%%` note says so. Every
diagram **states its basis** on both surfaces: the envelope carries `basis` (`pinned` |
`latest-unpinned` | `trail-pin-unresolvable` | `trail-composite-unresolvable` |
`trail-def-missing`) with `pinned_version` / `latest_version`, and the diagram itself
carries a mermaid `%%` comment on the line under `flowchart TD`, so redirecting the raw
output to a `.mmd` file keeps the provenance:

```text
flowchart TD
%% basis: PINNED spec version 1 of 2 of graph-def tcv — the topology this run-case actually walked
```

A run-case with **no** pin (the pre-pin shape) is drawn against the latest spec and
says so — absence is history, not an error. If the def is missing, or the pin matches
none of its stored spec versions (so the latest is a topology the run provably did
*not* walk), viz falls back to the run's own visited trail and names that basis.

### Replay

Because a grc is event-sourced, its whole history is the audit trail: `case-
opened` → `case-advanced`… → `case-closed`, plus the carrier events. `graph-run-
case show` returns the derived scalars (a faithful projection of the fold, heal-
forward-completable after a crash mid-write). The event chain replays the walk
exactly.

### Navigate back-references — `refs-to` (from a cell/object → the run-cases that touched it)

`advance --effect 'KIND|OBJECT-REF[|CARRIER]'` records a FORWARD edge: an effect
whose ObjectRef points at the object the node mutated (a hardened cell, an opened
task). `graph-run-case refs-to OBJECT-REF` **inverts** it (INT-15) — from any
object, find the run-cases that referenced it. So a cell (or an evidence artifact)
answers *which decisions touched me*.

```bash
# A cell is referenced by its coordinate HASH (INT-13): lettuce/cells/<hash>.
# Get the hash from `cell show <coord>` (or it is the effect ref you recorded).
lettuce graph-run-case refs-to lettuce/cells/9f3a1c2b4d5e6f70 --root "$ROOT" --project "$P" --format json
# -> {"data":{"references":[{"graph_run_case":"grc_…","from":"gate","to":"terminal",
#      "edge":"gate->terminal","kind":"cell-hardened","ref":"lettuce/cells/9f3a…",
#      "carrier":"lettuce/carriers/car_…"}]}}
```

Each hit carries the run-case, the transition the effect was recorded on
(`from`/`to`/`edge`), the effect kind, the exact stored ref, and the optional
carrier back-ref to the justifying evidence — every hop a navigable ObjectRef. The
reverse index is **derived = f(events)** (recomputed from the durable event log,
never a cache) with deterministic ordering (grc id, then event order). Matching is
on object identity (`project/kind/id`): querying `lettuce/cells/<hash>` matches an
effect that pinned `lettuce/cells/<hash>@4` (the revision pin narrows a citation,
not the object). It works for **any** object kind, not just cells. An object
nothing references returns an **empty `references[]` with `ok=true`** — "no
back-references" is a valid answer, like an empty query, not a not-found. A
malformed OBJECT-REF is `FW-CMD-USAGE` with the ref grammar. Read-only.

The **cell-facing** view of this reverse index is surfaced inline by `cell show
--with-references` (INT-15b) — so a cell viewer sees *its own* provenance (which
run-case decisions hardened/touched it) without a separate `refs-to` call:

```bash
lettuce cell show area=auth;layer=api --with-references --root "$ROOT" --project "$P" --format json
# -> {"data":{"coordinate":"area=auth;layer=api","state":"hardened",…,
#      "referenced_by":[{"graph_run_case":"grc_…","kind":"cell-hardened",
#      "from":"gate","to":"terminal","edge":"gate->terminal","ref":"lettuce/cells/cf141a…"}]}}
```

`referenced_by` reuses the same `refs-to` reverse index and record shape (same
deterministic order). The flag is **opt-in**: default `cell show` stays cheap and
byte-unchanged (no `referenced_by` key, no grc scan); with the flag the field is
always present in the machine formats (json/yaml) and the human table — an empty
`[]` when nothing references the cell. Under `--format plain` the flag is refused
(`FW-CMD-USAGE`: plain carries references only) — read provenance via json/yaml/table.

### Deferred, honestly

**Shipped end-to-end** (authored *and* enacted at runtime): the linear walk,
composition (`uses`/`bind`), structured parallelism (`fork`/`join`/`quorum` →
ENACT-1/2: real concurrent branches and K-of-M join firing), loop iteration
(`min_times`/`until_stable`/`until_drain`/`circuit_breaker` → ENACT-3: real waves,
convergence, drain and breaker trips), and **conditional routing**
(`when` guards on a router's out-edges → ENACT-5: the run-case picks its own
branch from recorded evidence). Each is validated + hashed at author/compile time,
guarded by a precise refusal at runtime, and materialized as catalog patterns
(`audit-wave@v1`, `harden-loop@v1`).

**Not yet shipped:** routing INSIDE an open concurrent branch (`--route-edge` is
mutually exclusive with `--branch`/`--open-branch`); the remaining schema surface
(carrier-*types*, io-contracts); a `graph-def validate` subcommand (soundness is
folded into `create` + `compile`, there is no separate verb);
the named-pattern catalog's `--bind` re-wiring at `use` time. Hosted mode supports
graph-def create/revise (the server grant needs the **admin** role, LET-1814) and
show/list/viz, the graph-run-case lifecycle/read/conformance surface and carrier
produce/list/show. Graph-def compile/lint/catalog/use and graph/carrier
acknowledgements remain store-local. In particular, `graph-def lint` and
`graph-def catalog list/show` explicitly refuse client mode with `FW-CMD-USAGE`,
whether selected by `--server-url` or a `.lettuce` pointer; they do not inspect a
local fallback store. Prefer `lettuce usage graph-def` (and `usage
graph-run-case`, `usage carrier`) for the always-current flag contract.

## 9.1. Every work item is a ticket; every ticket runs a graph-run-case

This is the chain the DoD's **ticket gate** (§8, "Definition of Done") actually
measures: `dod show` is met only at **zero** non-terminal tickets, counting
every task in the store — not the ones an agent remembers to mention. Work
that is never filed is invisible to that count; a run-case opened and then
abandoned proves nothing either. Both halves are required, in order:

**1. File a ticket first.** Before touching anything:

```bash
lettuce task create TASK-N --title "Short title" --body-file ./body.md \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

**2. Open a run-case BEFORE the work, not after.** Open at the def's start
node — the def must exist and `--start` must be its declared start, else
`FW-GRAPH-DEF-UNKNOWN` / `FW-GRAPH-START-UNDECLARED` (§9, "Enact") — then
record the ticket as an effect on the FIRST advance. The ref is
`project/kind-plural/id` — `myproject/tasks/TASK-N`, **never**
`myproject/TASK-N` (that shorter `$P/TASK-N` shorthand is only for
`task`/`lease`/`comment`-style commands, not for object refs like `--effect`
or `refs-to`; the wrong shape is refused with a suggestion naming the fix):

```bash lettuce-example proven-by=TestGraphEffectRefusalsAreActionable
# review-linear must already be a graph-def in $P, and `producer` must be ITS declared
# start — author it once (§9, "Author") or `graph-def use` a shipped pattern. Check with
# `graph-def list --project "$P"`; the wrong slug or start is refused, not recorded.
lettuce graph-run-case open --graph review-linear --start producer \
  --root "$ROOT" --project "$P" --author "$A" --format json   # -> grc_…
GRC=grc_…
lettuce carrier produce --graph-run-case "$GRC" --node producer \
  --edge 'producer->gate' --key artifact --value 'art:TASK-N/1' \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce graph-run-case advance "$GRC" --from producer --to gate --edge 'producer->gate' \
  --produces artifact --effect "task-opened|$P/tasks/TASK-N" \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

**3. Drive it — do the real work between advances, all the way to the
terminal.** An opened-and-abandoned run-case is exactly as unproving as no
run-case at all. Fork nodes: `--open-branch` to open a branch, `--branch
COORD` to advance within one, `--fire-join --quorum K` to reconverge (see
"Enact" above); a plain linear walk just keeps calling `advance` then `close`:

```bash
lettuce graph-run-case advance "$GRC" --from gate --to terminal --edge 'gate->terminal' \
  --root "$ROOT" --project "$P" --author "$A" --format json
lettuce graph-run-case close "$GRC" --from terminal --outcome completed \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

**3b. If the run will NOT be finished, ABANDON it — do not leave it open, and
never delete it (LET-915).** An unfinished run must still get a terminal
disposition, because the ledger has to be able to answer "what happened to this
run" with a name, a time and a reason rather than with silence. `abandon`
resolves the node the case is parked on itself (there is no `--from` — an
abandonment claims nothing about where the run got to, so it bypasses no gate)
and requires `--reason`:

```bash
lettuce graph-run-case abandon "$GRC" --reason 'superseded by a rerun; never walked' \
  --root "$ROOT" --project "$P" --author "$A" --format json
```

It writes the same `case-closed` event as `close --disposition abandoned` —
no second terminal, no second event kind — and REFUSES a run-case that is
already closed (`FW-GRAPH-CASE-CLOSED`): terminal is terminal, and two
dispositions is a fork in the record. **Deleting the directory is not an
alternative**; it destroys the one record that says the process was not
followed, which is the tamper shape this whole family exists to refuse.

Two consequences worth knowing:

- An abandoned run is **EXCLUDED from the conformance denominator and VISIBLE
  in the census**. `graph-run-case conform` reports `excluded: true`,
  `disposition: abandoned`, `conforms: false` and exits `2` (output contract: the
  exit code IS the answer, and an abandoned run has no "yes"; exit 1 before v0.20.2) — it
  never walked, so it carries no finding and stays out of the conformance
  denominator (doctor's census does not count it as non-conforming). `failed` is NOT excluded: it asserts the process DID
  run, so its walk stays evidence and stays checked.
- Every READ surface now names it (LET-1100). `graph-run-case list` projects a
  per-case `disposition` and counts `abandoned` beside `open`; `show` adds
  `disposition` plus the `outcome` carrying the abandonment's required
  `--reason`; `viz` puts it in the DIAGRAM BYTES as a `%%` comment beside the
  basis, not only in the envelope. Before that, an abandoned case was
  byte-identical to a completed one on all three, so this doctrine was stated
  and unmeasurable — and the required reason, whose whole purpose is to answer
  "what happened to this run", was not exposed on any read surface.
  **Read `open` as "never closed", not as
  the unproving population: that is `open + abandoned`.**
- Abandoning a run with concurrent branches still open is allowed and WARNS
  (`FW-GRAPH-RUN-CASE-BRANCHES-UNRESOLVED`), naming each leftover tip. A branch
  has no disposition of its own: the abandonment collapses the MAIN state and
  leaves every tip where it stood, because stamping a terminal onto a branch
  nobody walked would invent an event that was never enacted. `show` keeps
  listing those branches — after a close they are the record of where each one
  stopped, not work in flight.

**4. The ticket already carries the grc — query it back from either side.**
The `--effect` at step 2 recorded the reference, so the evidence is queryable
from the work item with no extra write:

```bash
lettuce graph-run-case refs-to "$P/tasks/TASK-N" --root "$ROOT" --project "$P" --format json
```

**4b. Make the chain ENFORCED, not merely conventional.** Declare a custom field
of `value-kind graph-run-case` and gate the transition on it. The gate then
requires **both** that the cited run-case EXISTS and that its ledger records a
produced-effect on *this* task — the reverse of the `refs-to` above, read from
the same index. Existence alone is not enough: any real grc id can be copied
from another ticket:

```bash
lettuce registry create custom-field grc --value-kind graph-run-case \
  --root "$ROOT" --project "$P" --author "$A" --format json
# …then a workflow transition carrying requires_field: ["custom/grc"], and:
lettuce custom set TASK-N grc "$GRC" --root "$ROOT" --project "$P" --author "$A" --format json
```

Refusals name which half failed, and the code — never the prose — is the
contract: `FW-WF-REQUIREMENT-REFERENT-MISSING` (no such run-case),
`FW-WF-REQUIREMENT-REFERENT-UNLINKED` (a real run-case that never touched this
task), `FW-WF-REQUIREMENT-REFERENT-INCOMPLETE` (a real, linked run-case whose
own graph-def declares carriers it never produced), and
`FW-WF-REQUIREMENT-REFERENT-UNFIRED` (LET-1278 — it produced every declared
carrier and its join never fired, so the quorum was never compared against
anything; scoped to defs that declare a `fork` or `join` station). All exit `1`,
like every other refused transition.

**What this proves is LINKAGE plus DECLARED EVIDENCE.** Linkage alone was
forgeable (LET-1446): one `advance` carrying a `transition` effect that names the
ticket satisfied the gate exactly as well as a completed walk, leaving the
run-case parked at its start node with zero branches. The gate now also reads the
run-case's *own* graph-def and requires a carrier for every edge that def declares
one on — def-driven, so a graph declaring no carriers is never asked for any, and
a run-case whose def or pin cannot be resolved is reported unjudged rather than
refused.

It is still a narrower claim than conformance: that the walk *conformed* to the
def is `graph-run-case conform`; that the workflow policy itself was not edited
under you is the workflow policy pin (`workflow show`). And carrier production is
not constrained by the def, so a run-case may hold carriers on edges its def never
declared — this gate does not examine those.

**5. DoD is a strict AND of three gates, never a blend** —
`lettuce dod show --project "$P"`: every scope's coverage cells clear the
grade/depth/recency floor, every **committed** milestone is reached
(`hypothesis`-stage and canceled milestones are excluded as bets, not
commitments), and the ticket gate above reads zero-open. Each gate is a
**count**, never a percentage; one weak gate blocks the whole verdict even if
the other two read 100%. A stored floor the verdict cannot parse (a hand-edited
`dod/depth` of `" 2"`, a `dod/recency` of `" fresh"`) never reads as "no floor":
the verdict fails **closed** on it and names it under `invalid_floors`, and
`validate --strict` flags the RAW scalar (`FW-FILE-INVALID-INTEGER` /
`FW-NAME-SLUG`) exactly as the reader parses it — no trimming on either side
(LET-579). The board agrees: each affected scope carries `depth_invalid: true`,
and `board next` / `board render` name the broken depth floor and draw no cell
at that bar (LET-1779).

## 10. Output formats and exit codes

`--format table` is the human default (headers, alignment, the `<command> ok`
acknowledgement); `json`/`yaml` are the complete machine envelopes; `markdown` is only for
`doctor`, `usage` and `skill`. **`plain` belongs to the shell pipeline and has one meaning:
primary references** (ADR 0026, the output contract). Every command declares its kind
(`lettuce usage --format json` → `output_kind`):

- an **object** (most `show`s and mutations — each command declares its kind) prints its
  reference — `REF=$(lettuce task
  create LET-9 --title x --format plain)` captures `p/LET-9`; a delete prints the deleted
  reference;
- a **collection** prints one reference per row (nothing when empty) — pipe it:
  `lettuce task list --status ready --format plain | xargs -n1 lettuce task show --format json`;
  rows that could not be judged or failed (`lease list`, `task set-where`) are named on stderr;
- a **verdict** (`task exists`, `cell gate check`, `graph-run-case conform`, `graph-def lint`)
  prints the subject's reference only when the answer is yes, nothing otherwise (the reason on
  stderr; the exit code is the answer);
- a **report** (`status`, `doctor`, `validate`, `dod show`, `defaults show`, `grid show`,
  `board next`, and configuration/maintenance mutations such as `dod set`, `defaults …`,
  `import`, `repair …`) prints the table bytes; `lease show` is a 0..1 collection (the task
  reference only when a lease is present); **content** (`usage`, `skill`, `docs`, viz) prints the content.

Fields are never on plain: read them with `--format json` (or `table`). An inclusion flag
(`--with-body`, `--with-comments`, `--full`, `--body-versions`, …) is **refused** under plain (`FW-CMD-USAGE`); an
explicit projection (`query run … select`, `--fields`) prints the requested columns,
tab-separated. `lettuce docs show concept-plain-format` has every command's kind. More formats are
command-specific: `okf` (the usage-bundle export — `lettuce usage --format okf
--out DIR`), `html` (`lettuce board render --format html`, and the bare `lettuce
docs` explorer — not `docs show|list|export`) and `mermaid` (`graph-def viz`,
`graph-run-case viz`) / `dot` (`graph-def viz` only). Every other command REFUSES a scoped format
(`FW-CMD-USAGE`) rather than printing something else with exit 0 — and so does
help: `X --help --format F` answers exactly like `lettuce usage X --format F`, and
the bare `--help` banner is printed for `table`/`plain` only. Errors exit non-zero with
a distinct code so automation can branch on the process exit code: `1`
validation or invalid input, `2` object not found, `3` expected-revision
mismatch, `4` lock acquisition failure, `5` Git mode failure, `6` interrupted
operation requires recovery, `7` internal error, `8` hosted result not ready
yet — a health report (`FW-API-HEALTH-PENDING`: retry later or pass `--wait`;
not a finding) or a board/search build (`FW-API-READ-PENDING`: the client
already waited up to `--wait`, default 3m; retry later) (success is `0`). Human output
prints `lettuce: <message>` plus `-> <suggested action>` lines. QUERY commands
encode the answer in the exit code while still succeeding: `task exists REF`
returns `ok:true` with `data.exists` and exits `0` (exists) or `2` (absent; table
prints one `<project>/<id> does not exist` line, plain prints nothing and the line goes to
stderr), `cell gate check` returns
`ok:true` with `data.passed` and exits `0` (PASS) or `2` (FAIL; LET-1819), and
`graph-def lint` exits `2` when the def has smells, and
`graph-run-case conform` exits `2` with an `ok:true` report when the walk does not
conform (LET-1831; it was `1` before v0.20.2) — every verdict answers "no" with `2`.
`reconcile` and `cell reconcile` are reports and exit `1` on an unresolved finding. A
non-zero exit with `ok:true` is the negative answer; only `ok:false` is an error.
Every command publishes its declared exit codes as `exit_codes` in `lettuce usage <command>
--format json` (LET-1318): branch on that list, not on prose.
A refusal you can repair yourself says how: its `suggested_actions` name the command (with
your reference filled in), and its `repairability` is `manual` or `automatic`, never `none`.
`none` means there is no repair (an invariant); an operator-only condition (a token, a grant,
the host) says who to ask. docs/quality/REPAIRABILITY-CENSUS.md classifies every code.
`lettuce --help`/`-h` prints a short hint; `lettuce usage` prints everything.
Supported commands also accept a structured input envelope via
`--from-json PATH` / `--from-yaml PATH` (see each command's
`external input keys` in `lettuce usage`).

## 11. Modes

- **Local** (default): operate on a filesystem store via `--root`.
- **Dedicated Git**: `--mode dedicated-git` makes each mutation a Git commit
  and **fetches the upstream before every mutation** — a strongly-consistent
  shared store that requires connectivity by design (not an offline mode);
  requires a clean, synchronized worktree. `sync status|push|pull` manage it.
  A store-writing command (a mutation, maintenance or `serve`) run against a
  dedicated-git store with NO mode given (no `--mode`, `LETTUCE_MODE` or config
  `mode`) is refused `FW-CMD-USAGE` before it writes. The refusal names `--mode
  dedicated-git` (LET-1818), because filesystem mode would leave uncommitted
  files that wedge the next commit on `FW-GIT-DIRTY`. A dedicated-git store is
  an initialized store whose root is also a git worktree root, and a fresh clone
  counts too. An explicit `--mode filesystem` is honoured. Reads are unaffected.
  `sync status` fetches first (the same freshness the mutation gate uses), so
  `behind` is current; if the fetch fails it still answers, with
  `fetched:false` and an `FW-GIT-SYNC-REQUIRED` warning. `sync pull` and
  `sync push` both refuse a store with no remote as `FW-CMD-USAGE`.
  A mutation whose durable commit FAILED still returns `ok:true` (the write
  landed) but carries an `FW-GIT-COMMIT-FAILED` warning: do NOT retry it — run
  `lettuce recover` to re-commit it (`serve --recover-on-start` does the same
  before discarding any unattributed torn state). Unrelated stray files do not
  block the re-commit. `serve --recover-on-start` never deletes a file outside
  the store's canonical entries (a `stray.txt`, a `serve.log` redirected inside
  the store): it refuses to start with `FW-RUNTIME-RECOVERY-FAILED`, names the
  files and discards nothing. It also refuses when an acknowledged operation
  still cannot be re-committed. Keep serve logs outside the store root.
- **HTTP client**: pass `--server-url` (or set `LETTUCE_SERVER_URL` +
  `LETTUCE_BEARER`/`_FILE`, `LETTUCE_ACTOR`) and the identical CLI dispatches
  over HTTP. A discovered repo-local `.lettuce` overrides the env
  `LETTUCE_SERVER_URL`, so the env reaches the server only when no `.lettuce`
  is in scope; the `--server-url` flag always wins. The binary name changes
  only the NO-STORE default (§0.1: `flt-issue` falls back to the fleet service
  or setup guidance, `lettuce` to local discovery); any `.lettuce`,
  `--store` or `--server-url` decides the same way under either name.

**Domains (one server, several independent stores — LET-1764).** A server started
with `--domains-root BASE` serves every initialized store folder `BASE/<name>` as
the domain `<name>`; `BASE/default` is the `default` domain. Pick one per command
with the global `--domain NAME` (or `LETTUCE_DOMAIN`); it rides the
`X-Lettuce-Domain` header. Omit it and you get your **token's default domain**
(which may not be `default` — check with `lettuce domain list`, which shows only
the domains your token can reach and marks its default). A token reaches
`default` too unless the operator issued it with `default_access: false`. `403 FW-DOMAIN-FORBIDDEN` means "not yours OR does not exist"
(the server will not say which); `400 FW-DOMAIN-INVALID` is a malformed name.
Each domain is a separate store: tasks, ids, leases, idempotency keys and
migrations never cross domains. Move a local store into a domain with
`LETTUCE_BEARER_FILE=<token-file> lettuce migrate --to https://host/ --domain NAME --project P …` (§0.6). Domains
are created only on the server host: `lettuce domain create NAME --domains-root
BASE` (no restart needed). `--domain` is refused with a local store.

**A project that MOVED to another domain (LET-1874).** An operator can move a project
between domains of one server (`lettuce project move P --to-domain DEST`, admin, token
reaching both). A move changes no token grant. If a command against your bound project
answers `FW-REF-MISSING-PROJECT` with a first suggested action starting "this project
was MOVED to domain DEST", do exactly that: change the `domain:` line of the repository's
`.lettuce` pointer to `DEST` (or `LETTUCE_DOMAIN=DEST` for one shell), check that
`lettuce domain list` shows DEST (ask the operator to add it to your token otherwise),
and confirm with `lettuce status`. Never `project create` it again in the old domain.
`lettuce query audit P` in the old domain shows the move record.

**What works over the HTTP client.** Almost everything — the exceptions are narrow. The
authoritative per-command answer is the `kind` field of `lettuce usage <cmd> --format
json`.

`project id-block list PROJECT` and `project id-block grant PROJECT AUTHOR
--prefix PREFIX --start N --size N` work in local and hosted modes. `AUTHOR` is
the range recipient; `--author`/`LETTUCE_ACTOR` identifies the acting writer.
Read the existing ranges before granting: overlapping grants are refused. Hosted
calls require reader (list) or writer (grant) access to that project. HTTP retries
replay their automatic request key; the explicit CLI `--idempotency-key` flag is
not supported for this command.

**Allocate IDs in the live store (LET-1850).** Use `lettuce task create --next LET
--project lettuce --author NAME --title TITLE` so allocation and creation occur
together. Inspect current grants with `lettuce project id-block list lettuce`;
ask an authorized writer for a grant if needed. `scripts/next-id.sh` is retired
and always refuses: neither Git history nor the historical `ID-RESERVATIONS.yml`
knows which IDs the hosted store has already used. Do not revive a max-ID scan
as an allocator; observing a free ID does not reserve it.

| Command / group | HTTP client | Notes |
|---|---|---|
| Work plane: `project`, `task` (incl. `transition`), `comment`, `lease`, `run`, `artifact`, `author`, `version`, `custom set` | ✓ | full CRUD |
| Registry + milestones: `registry create/update`, `milestone create/close/set-stage` | ✓ | |
| Coverage cells: `cell set` · `clear` · `transition` · `evidence add/remove` · `gate check` · `list` · `show` · `rollup` | ✓ | |
| Dimensions: `dimension declare` · `rename` · `member add/update` · `list`/`show` | ✓ | |
| Definition of Done: `dod set` · `dod show` | ✓ | |
| Board: `board export` · `render` · `next` | ✓ | frontier/HTML computed client-side from the fetched export |
| Queries: `query run`/`tasks` (incl. `--group-by`) · `search` · `audit` · `timeline` · `graph` · saved queries | ✓ | |
| Workflows: `workflow show` · `list` · `revise` (incl. `--declare-default-outcomes`) | ✓ | `revise` is admin-only over HTTP (LET-1865): the server needs an `--authz-policy` granting the token `admin`; a writer gets `FW-API-AUTHZ-DENIED` |
| Graph runtime: graph-def `create/revise/show/list/viz` · graph-run-case `open/advance/close/abandon/show/list/viz/refs-to/conform` · carrier `produce/list/show` | ✓ | graph-def `create`/`revise` need an **admin** grant (LET-1814); `compile`/`lint`/`catalog`/`use` and acknowledgements remain store-local |
| Maintenance: `init` · `status` · `validate` · `doctor` · `import` · `export --bundle` · `recover` (incl. `--report-only` and `--abandon`) · `reconcile` · `cleanup` · `repair` | ✓ | |
| Cell `verify` · `affirm` (single coordinate) · `reconcile` | ✓ | reconcile `--dry-run` is an actorless hosted GET; mutation is an attributed hosted POST; atomic confirmation `--coords-file` remains store-local |
| `cell set-where` · `clear-where` · `note` · `import --file` · confirmation `--coords-file` | — | **local / dedicated-git only** bulk/process commands (a single cell note is still reachable over HTTP via `cell set --note`) |
| `domain list` | ✓ | GET /v1/domains — your token's reachable domains only |
| `self-update` · `client list` | ✓ (only) | GET /v1/version + /v1/client/{os}-{arch}[.sha256] (any role) · GET /v1/clients (admin); both need a server and are refused locally |
| `domain create` | — | **server-host only** — domains are created on the server, never over HTTP |
| `serve` · `sync status/push/pull` · `github init-repo` | — | **local / server-only** — refused in client mode (`serve` starts a local process; `sync`/`github` act on the local working copy + Git remote) |

## 12. Invariants and gotchas

- **Path jail.** All references are validated slugs/paths inside the store
  root; traversal and malformed components are refused (`FW-PATH-*`). Input
  *files* (e.g. `--body-file`) follow symlinks; store-internal guards do not.
- **Faithful round-trip.** Adversarial Unicode (bidi, homoglyphs, zero-width)
  is stored and returned byte-faithfully; input rejects only format-breaking
  control characters. "Accepts suspicious Unicode" is by design.
- **Single writer.** One mutation at a time per store. A killed writer can
  leave the lock held (`FW-RUNTIME-WRITER-ACTIVE`); `lettuce recover` heals it
  when the owner is provably gone (`--abandon` when liveness is unknowable —
  another host, no usable pid, or a pid owned by another user, which lettuce
  never treats as a live writer).
  Exit code 6 = run recovery.
  `recover` never removes or lowers published content it can see committed, and
  it never deletes and then refuses: a pass that ends with the store still
  invalid undoes its own removals (v0.20.4). When it reports
  `FW-RUNTIME-RECOVERY-OBJECT-KEPT`, do NOT delete the named object or event:
  follow its suggested actions (restore the named marker, e.g. "revision should
  be 3", or repair the other findings and rerun `recover`). A record a dead
  writer left mid-write (`FW-RUNTIME-WRITE-PHASE-STRANDED`) is kept as evidence;
  once `recover` has settled its interrupted create in a store that validates, it
  marks the record `settled_by` and the warning stops (v0.20.5). A dead create's
  record proves only the object it wrote: when its operation id is not the one the
  object's create recorded (the object was created again at that path), `recover`
  keeps the object and says so (v0.20.5, LET-2001). A leftover
  `FW-RUNTIME-RECOVERY-UNDO-PENDING` is a dead recover pass's undo journal: run
  `recover`, never delete it by hand.
- **Soft vs hard removal.** `archive` (task/project/comment/artifact) is
  reversible and history-preserving (hidden from lists, still searchable);
  `delete` is permanent and needs `--yes` (+ `--cascade` for
  children/non-empty).
- **Forced overrides need a reason.** `lease release --force`, `lease steal`,
  `run finish --force` all require `--reason` — they override another holder.
- **Bodies and artifacts are immutable.** New body/comment/summary versions
  are appended; artifact payloads are never edited (`artifact replace` makes a
  new revision).
- **Additive dimension layer.** A project's runtime dimensions can never
  shadow or edit pack vocabulary; refusals happen before any write.
- **Diagnostics are the API.** Every error carries `code` (e.g.
  `FW-WF-REQUIREMENT-UNSATISFIED`), `expected`/`actual`, `repairability`, and
  `suggested_actions`. Branch on codes, not message text — they tell you the
  fix. After any repair, import, or conflict resolution, re-run
  `validate --strict` and `doctor`.
