Canvas
Canvas
Canvas is a small structured document that holds the current shared understanding of a task between a person and the agents working on it.
This repository is the canonical home for both Canvas specifications:
- Product spec
- Engineering spec
- Node identity — how an
idis minted, what preserves it under each of the four verbs, and what bumpsv - Node state — whether a canvas may say anything about a node’s state, and how much:
answeredon<question>, and nothing else anywhere - Node naming — whether what
createmints is distinguishable inside the document itself, and where the distinction lives instead - Rendering — what the renderer does with a
<figure>, and how the index of open questions and the per-node marker treat a question that has been answered - Coherence checking — why model checking is a separate synchronous post-write process, and how each contradiction becomes one open question
History/source
The original Claude artifact is retained as a non-canonical historical source. The Markdown specifications in this repository are authoritative.
The canvas file
A canvas is stored as XML with a closed vocabulary. The vocabulary is defined
once, in schema/canvas.rng, a RELAX NG grammar — so a
schema violation is an exit code rather than a code review.
Eleven element names, and no twelfth: canvas, section, text, list,
item, table, row, cell, figure, link, question. <canvas> is the
root and carries ledger and a schema version. New canvases are schema 2;
schema 1 remains a fully supported read/edit/render compatibility branch. <section> is the only container,
carries title, and nests one level — two levels of section in total, a third
is invalid. Every node except the root carries id and v.
<question> is the one node that carries state: it may carry
answered="true", and nothing else. Absence means open, answered="false" is
invalid, and no other element may carry it. That is the whole of what a canvas
says about a node’s state — node-state.md decides it and
prices what it leaves in the reason.
The list is closed. <decision>, <risk> and <acceptance-criterion> are
rejected, and there is no way to register a twelfth element: no wildcard in the
grammar, no plugin point, no configuration file of allowed elements. A closed
list that can be opened by configuration is not closed.
Every container holds zero or more children, never one or more. One edit is one
node, so an empty <list>, an empty <row>, an empty <section> and a
childless <canvas/> are states a real document passes through and all of them
are valid.
Validating a file by hand
bin/canvas-validate <file> [<file> ...]
| exit | meaning |
|---|---|
0 |
every file given is a valid canvas; nothing on stderr |
1 |
the document is wrong — not well-formed, or it violates the schema. One diagnostic per problem on stderr, each naming the offending node |
2 |
the tool or its invocation is wrong — no arguments, file missing, unreadable, not a regular file, or in a directory this process may not look in, xmllint not on PATH, schema missing or uncompilable, or any other condition the operating system refuses the command with. The refusal carries the errno, so a caller can tell a permission problem from a missing file |
The 1 / 2 split is the point: an agent has to be able to tell “your canvas
is invalid, fix the node I named” from “the validator is broken, do not touch
the canvas”. The refusal says which of the two it is in band — see
what a refusal prints — so a caller reading stderr
does not have to have read this table.
A diagnostic names the offending node — its element name, and its id where it
has one, or its path where it does not — and the refusal that carries it says
what to do about it:
$ bin/canvas-validate bad.xml
canvas-validate: not a valid canvas: bad.xml
bad.xml:4: <decision> (id="jc5v", v="1"): Did not expect element decision there
bad.xml:3: <text> (no id attribute, v="1", at /canvas[1]/text[1]): Element text failed to validate attributes
bad.xml fails to validate
Canvas-About: file bad.xml
Canvas-Next: repair the file at the line each diagnostic above names, then re-run `bin/canvas-validate bad.xml`; what a canvas node may be is written in /…/schema/canvas.rng and nowhere else, and `xmllint --noout --relaxng <that schema> <file>` asks it directly
Canvas-Exit: 1 — the document is wrong, not the validator; repair the node each diagnostic names
The schema also stands on its own, with no Python involved at all:
xmllint --noout --relaxng schema/canvas.rng <file>
The validation path
Every read and every write of a canvas goes through one entry point:
from canvas.validate import validate_file
problems = validate_file(path) # list of diagnostics; empty means valid
bin/canvas-validate is a thin shim over the same function. A verb validates
the document it is about to write before committing it, so an invalid canvas
is never reachable on disk. Callers must not shell out to xmllint themselves
and must not restate any part of the vocabulary: every rule about what is legal
lives in schema/canvas.rng and nowhere else.
validate_file raises canvas.validate.EnvironmentProblem when the validator
itself cannot run. That is not an invalid document and must not be reported as
one.
Running the tests
From a clean checkout, with no install step, no virtualenv and no network:
python3 -m unittest discover -s tests -t .
Standard library only. It needs xmllint, which ships with macOS and with
GitHub’s ubuntu-latest image, and git, because the store tests exercise the
real repository the tool builds. Every test points $OPENCLAW_WORKSPACE at its
own temporary directory; none of them touches a live workspace.
Checking that citations still land
These documents cite each other by line number, and a line number is a fact about a file at one moment: inserting a paragraph into a spec moves every passage below it and falsifies every citation of them without breaking anything a reader can see. The citation still resolves — the lines exist, they are simply the wrong lines.
bin/canvas-citations [<checkout> ...]
For each citation with a quotation beside it, the quoted words are either inside the cited lines; somewhere else in the same file, which is the only failure and is reported with the range that now holds it; or not in that file at all, which is counted and not failed, because most quoted text beside a citation is the citing author’s own sentence rather than a quotation of the target. Those undecidable ones are named on the report as well as counted: they are precisely the citations nobody can check mechanically, so a reader who wants them read has to be told where they are. Pass a sibling checkout as well to cover citations that cross repositories:
bin/canvas-citations . ../ledger-orchestrator
Two things decide which words count as a quotation, and both were learned from
citations this missed. A " inside code is a character being named rather than
a delimiter — straight quotes are paired in order, so one unpaired mark inverts
every pair below it and takes the prose between quotations as the quotations,
which is how one document’s citations all went unchecked. And where a sentence
cites two files before quoting, each is tried: the words belong to one of them,
and testing only the nearer leaves the other stale and silent.
The dated readings under docs/why-verdict/, docs/drive-by-hand/ and
docs/merge-and-split/ are not checked. Each was written against a named sha
and its citations record what was there then, so correcting one to today’s line
numbers would edit a measurement to agree with a file it never saw. The report
prints how many citations that exempted, because an exemption nobody can see is
indistinguishable from a gap.
Checking a successful write for coherence
bin/canvas remains local, deterministic and network-free. The orchestrator
may synchronously check a successful content write in a separate process:
bin/canvas-coherence <canvas-id> --trigger <full-head-sha> \
--model-command <adapter> [<adapter-argument> ...]
--model-command is the final option. The adapter receives one UTF-8 JSON
object on stdin and emits one UTF-8 JSON object on stdout. It does not inherit
OPENCLAW_WORKSPACE, choose an author or receive Canvas flags. Provider SDKs,
credentials, network access and retries belong to that adapter, outside both
Canvas commands. The complete version-1 request and response contract is in
coherence.md.
The trigger must be the full forty-character current head produced by the
successful primary write to that ledger’s XML file. A repository head belonging
to another canvas or a stale trigger is refused before the adapter starts at
exit 1; a frozen canvas is not — it is checked like any other. The adapter failing to start, timing out, exiting
non-zero or returning invalid UTF-8/JSON/schema is exit 2. In every case the
primary write is already committed and is never rolled back or turned into a
failed write.
The common response is {"schema": 1, "findings": []}. It exits 0 and
writes nothing: no node, reason, temporary or commit. Each actual contradiction
in a non-empty response is inserted through the existing store as one open
<question> at the root, with a minted id, its own evidence-bearing reason,
the independent Canvas-Author: canvas-coherence | trigger:<sha>, and the
current head as its declared base. Two findings therefore mean two inserts and
two commits. The production orchestrator invokes the checker after create or
a successful insert, replace, remove or move, never after a read,
render, history, freeze, or a question inserted by the checker itself.
What the schema deliberately does not check
- That an
idis globally unique. Uniqueness is a property of the whole git history — the commits whoseCanvas-Node:trailer names the id — which no document schema can see. The schema checks the shape of an id;insertchecks that it is free. - That
vagrees with the commit count. Same reason — checkable against the log, not against the file. - The meaning of arbitrary figure languages. Schema v2 admits Canvas
Diagram 1 character data by default, explicit
payload="mermaid"source andpayload="svg"character data only through the closed inline-SVG validator. It does not admit dot, PlantUML, raw child elements or another payload value; schema v1 remains an explicit compatibility branch.rendering.md§1 owns the deliberately revised network and notation contract.
The store
A Canvas lives in one file per Canvas identifier:
$OPENCLAW_WORKSPACE/state/canvas/<canvas-id>.xml
$OPENCLAW_WORKSPACE has no default: a tool that falls back to a guess writes
real state whenever a caller forgets the variable, and the failure is silent and
lands on production data.
state/canvas is one git repository holding every Canvas file,
initialised on first use and never re-initialised over one that already exists.
It is one repository and not one per canvas because ids are unique across the
whole of it: the uniqueness check insert runs is the history itself, with no
path filter, so a repository per canvas would path-scope it by accident and
hand out an id another canvas already used.
bin/canvas create [<canvas-id>] --problem TEXT --expected-value TEXT [--author TEXT]
bin/canvas read <canvas-id> [--id NODE-ID]... [--type NAME]... [--provenance] [--since SHA] [--frozen]
bin/canvas render <canvas-id>
bin/canvas history <canvas-id> <node-id>
bin/canvas replace <canvas-id> <node-id> --why TEXT [--base SHA] [--type NAME] [--text TEXT | --mermaid-file FILE | --svg-file FILE] [--title TEXT] [--href URL] [--answered] [--author TEXT]
bin/canvas insert <canvas-id> (--after <node-id> | --into <container-id>) --why TEXT [--base SHA] [--type NAME] [--text TEXT | --mermaid-file FILE | --svg-file FILE] [--title TEXT] [--href URL] [--answered] [--author TEXT]
bin/canvas remove <canvas-id> <node-id> --why TEXT [--base SHA] [--author TEXT]
bin/canvas move <canvas-id> <node-id> (--after <node-id> | --into <container-id>) --why TEXT [--base SHA] [--author TEXT]
bin/canvas freeze <canvas-id> --why TEXT [--author TEXT]
The read hands out the current sha and all four verbs take it back as
--base: the sha the edit was decided against. The staleness
rule is what it buys.
Nine subcommands, and still four editing verbs. The other five are three
reads — read, render and history — and the two
ends of a canvas’s life: create, which is its birth, and
freeze, which records that its work ended. A freeze edits
no node, so it is not a fifth verb in the sense the four are, and a projection
is not one either: render writes nothing at all.
What one commit contains
One repository for every Canvas is also one index for every Canvas, so what a commit contains is a thing this store has to decide rather than inherit. It decides it this way, and a caller may rely on all three:
- One commit changes one canvas. Never two, whatever else is staged in the shared index at the moment it is made.
- One commit changes one node in it, named in its
Canvas-Node:trailer — except at the two ends of a canvas’s life.create’s birth commit names no node and writes a root with nothing in it;freezenames no node, carriesCanvas-Freeze: <canvas-id>, and changes no file at all. - The reason in its subject is about that node, because the write path composes the subject and the trailer together and will not write one without the other.
It holds because the commit names its path, exactly as the add before it
does: git commit … -- <path> builds the commit from the head plus that one
file, so a second writer’s staged entry stays staged and is committed by that
writer’s own commit under that writer’s own reason. Two things follow for a
reader. git log -- <canvas-id>.xml is that canvas’s whole history and nothing
else’s; and history — the commits whose Canvas-Node:
trailer names the node — is that node’s whole history, rather than most of it.
One commit in the live store predates this and it has not been rewritten.
59f6946 carries an insert into one canvas and an unnamed full rewrite of node
ihfu in another, made before the commit named its path. history cannot
report that rewrite and never will: a node’s history is the commits that name
it, and loosening that match to recover one edit would make every node’s
history a guess. What the store can still say about it, what is recoverable
from outside it, and what is permanently gone are written down in
docs/unattributed-edits.md.
What a caller may not read into it. A write does not leave the index empty
— another writer’s staged canvas may be sitting in it, untouched and
deliberately so — so git status in state/canvas is not a report on the
write that just finished. And the sha a read hands out is still the
repository’s head and not one file’s version number: that is what makes it an
identity key every --base can be compared against, and
the staleness rule is what scopes it back down
to the caller’s own canvas.
Creating a canvas
Omit the identifier and create mints a stable, collision-safe one, then prints
it as Canvas-ID for every later command. It also creates the first two nodes
and prints their ids, the sha and the path:
$ bin/canvas create --problem "The store does not exist." \
--expected-value "A writer can learn what to write against."
Canvas-ID: canvas-8ce441e168bf4eeea13a1f584f327323
Canvas-Problem: y8dk
Canvas-Expected-Value: itpe
Canvas-Base: e4a864130afb88ad2abc17f1b4889df707b15ded
Canvas-File: /…/state/canvas/canvas-8ce441e168bf4eeea13a1f584f327323.xml
Minted identifiers come first, because they are what the caller did not
know. Canvas-ID appears only when the CLI minted the Canvas identifier. The
node ids use Canvas-Problem: and Canvas-Expected-Value:, one name for each
flag rather than Canvas-Node: twice: two identical labels would leave the
caller counting, which node-naming.md rules the document
does not carry. The price, the same one freeze pays for Canvas-Freeze:: a
caller grepping Canvas-Node: across the verbs to collect minted ids does not
see create’s.
The two lines below them are unchanged and in their existing order, so anything
parsing Canvas-Base: or Canvas-File: by name is unaffected.
Supplying the positional identifier remains supported for integrations that already have one. A task-ledger or Basecamp-derived id is optional integration, not a prerequisite:
bin/canvas create bc-10340467739-existing-task \
--problem "The task's problem." \
--expected-value "The task's expected value."
That form does not print Canvas-ID, because the caller supplied it. Existing
commands and stored files keep using the identifier verbatim. The root’s
ledger= attribute also remains unchanged for schema and file compatibility;
standalone Canvases store their minted Canvas identifier there.
Runnable standalone lifecycle
This starts with a clean temporary workspace, creates a standalone Canvas, reuses the printed identifier to read and edit it, renders it, then freezes it:
export OPENCLAW_WORKSPACE="$(mktemp -d)"
created="$(bin/canvas create \
--problem "The deployment steps are scattered." \
--expected-value "One reviewed sequence is ready to follow.")"
printf '%s\n' "$created"
canvas_id="$(printf '%s\n' "$created" | sed -n 's/^Canvas-ID: //p')"
problem_id="$(printf '%s\n' "$created" | sed -n 's/^Canvas-Problem: //p')"
base="$(printf '%s\n' "$created" | sed -n 's/^Canvas-Base: //p')"
bin/canvas read "$canvas_id"
bin/canvas replace "$canvas_id" "$problem_id" \
--text "The deployment steps need one reviewed order." \
--why "The first pass found duplicate ordering in deploy.md and runbook.md; this node now states the conflict the Canvas must resolve." \
--base "$base"
bin/canvas render "$canvas_id" >| "$canvas_id.html"
bin/canvas freeze "$canvas_id" \
--why "done: the reviewed sequence is recorded in this Canvas and rendered in $canvas_id.html"
It is three commits, not one:
create canvas-8ce441e168bf4eeea13a1f584f327323: Canvas created, root only
insert y8dk: the problem this Canvas starts with
insert itpe: the expected value this Canvas starts with
node-identity.md §4 requires it. The creation commit creates the root only,
and the two first nodes arrive as two ordinary insert commits, each naming its
own node in a Canvas-Node: trailer, each born at v="1". A single commit
holding the root and both nodes would be one commit touching two nodes, which is
the rule the tool exists to make inexpressible; the birth of a canvas gets no
exemption from it.
The root commit carries no Canvas-Node: — <canvas> is not a node — and no
Canvas-Base:, because there was no prior state it could have been decided
against. Each insert bases on the commit before it.
The two first nodes are <text> nodes, problem first. They carry no marker
saying which is which: the vocabulary has no semantic node and inventing one is
the <decision> / <risk> tripwire. The distinction lives in the commit
subject, in the order, and in the two lines above — and
node-naming.md is the ruling that settles it, says what would
reopen it, and names the one thing it costs: the rendered page shows two
unlabelled paragraphs, which the renderer is free to fix and the grammar is not.
create refuses rather than overwrites. A canvas that already exists is exit
1, with the path and its current sha, and nothing is written.
Neither first node may be blank. A --problem or an --expected-value
that is absent, empty or whitespace-only is exit 2 — the same code and the
same answer an empty --why gets — and it is decided before the store is
opened, so nothing is written, nothing is committed, nothing is minted and the
repository is not even initialised. A supplied identifier is left free to try
again; an omitted one has not yet been minted.
The reason is that the store cannot hold deliberately blank and say so. A
node with no character data is written <text id="…" v="1"/> whether it was
given None or "", and render prints nothing for
it, so a canvas whose problem is blank reads exactly like one whose problem was
lost. There is no third thing for a reader to conclude, so there is no third
thing for the tool to record. Content that is not blank is stored verbatim:
this refusal is about whether there is content, not about what it looks like.
This does not make a blank node inexpressible, and it is not meant to.
insert … --text "" still makes one, because an insert carries a --why
that says what the node is for, and a reader who finds it blank can ask
history what it was for. create’s two nodes get the two
fixed reasons the tool writes — the problem this Canvas starts with, the
expected value this Canvas starts with — so a blank one arrives with a reason
that describes content it does not have. That is the difference, and it is the
whole of it.
It used to be two different answers to the same mistake, and neither was one a
reader of this page could have predicted. An empty --expected-value exited
0 and made all three commits, leaving a blank second node that rendered as
nothing. An empty --problem landed the first two commits and had the third
refused by the tool’s own one-edit-is-one-node guard — create holds one
document in memory across its three writes, and the empty string it put in the
problem node came back from disk as None, so the third write looked like it
also changed the problem node. That left a canvas that existed, had no
expected-value node, and could not be created again. _shape no longer draws
that distinction — None and "" are one state, because the store writes one
spelling for both — so the guard can no longer name a node the caller did not
edit; refusing blank content is the separate decision about what an empty
argument means, and the answer is that it is not one.
--author becomes the Canvas-Author: trailer and is used verbatim, which is
how a run passes leo | step:implement | run:ship-the-flag-3. Driven by hand it
defaults to <user> | by-hand — not a synthesised step:/run:, because a run
id no run store can resolve makes git log --grep='run:' return rows for runs
that never existed.
Reading a canvas
Reading is how a writer learns what to write against, so the sha is part of the output rather than a separate lookup:
$ bin/canvas read my-task
Canvas-Base: e4a864130afb88ad2abc17f1b4889df707b15ded
<?xml version="1.0" encoding="UTF-8"?>
<canvas ledger="my-task" schema="2">
<text id="y8dk" v="1">The store does not exist.</text>
<text id="itpe" v="1">A writer can learn what to write against.</text>
</canvas>
The sha comes first, on one line, under the same name the next write declares it
under: one name for one thing. It is the full forty characters — handing out an
abbreviation as an identity key is a hazard as the log grows — and it is
git rev-parse HEAD of the canvas repository, not the file’s last-touching
commit, because that is what a later --base is compared against.
The cost, stated because it is real: stdout is not itself a valid XML document. The document alone, byte for byte what is on disk, is
bin/canvas read my-task | tail -n +2
The boundary rule, once, so nothing added to the header ever breaks a second
idiom: the document begins at the <?xml declaration line, and everything above
it is the header. tail -n +2 is the document alone for a read with no flags;
sed -n '/^<?xml/,$p' is the document alone under any combination of them. A
unified diff in the header cannot be mistaken for the declaration — a diff
prefixes every line with a space, a + or a -.
A read is a read. It writes nothing, commits nothing, and does not initialise a
repository. That stays true of every flag below: all any of them adds is
git log, git diff, git rev-parse and git merge-base --is-ancestor. None
of them acquires anything either — none of this is a lock, and the answer can be
stale the moment it is printed. --base on the next write is still the only
thing that refuses.
Part of a canvas: --id and --type
The canvas goes into every step’s prompt, so a read that is all-or-nothing is a prompt that is all-or-nothing. Two flags select part of it, by node id and by node type:
$ bin/canvas read my-task --type question
Canvas-Base: e4a864130afb88ad2abc17f1b4889df707b15ded
<?xml version="1.0" encoding="UTF-8"?>
<canvas ledger="my-task" schema="2">
<question id="mqxd" v="2">Does the store re-read before it writes?</question>
</canvas>
- Both repeat, and they union. Every occurrence of either adds to one
selection: a node is printed if any selector names it. Not intersection — an
--idintersected with a--typeis either that one node or nothing. - A selected node brings its subtree, once. A selected node inside another selected node is printed in place and not again on its own.
- The sha is unchanged.
Canvas-Base:is still the first line, still the repository head, still forty characters, whatever was selected: the selection narrows what you see, not what you would be writing against. - What comes back is a projection of the document and not the document, and is
not claimed to validate. Selecting a
<cell>without its<row>produces something the grammar refuses, and that is correct — the stored file is the document and is the thing that validates. Nothing runs the validator over a selection. A read with no selector still prints the file byte for byte. - A
--typethat matches nothing is exit0and an empty root:<canvas ledger="my-task" schema="2"/>. A type is a predicate and “none” is its answer, not its failure — are there anyquestionnodes left is the question this exists to answer, and a tool that refuses to say “none” has not answered it. An unknown type name is not refused either:--type decisionmatches nothing and says so, because refusing it would need a list of legal element names in Python and the vocabulary is written down once, inschema/canvas.rng, and nowhere else. - An
--idthat names no node in this canvas is exit1, naming the id. An id is an assertion that a node exists — ids are minted and unique — so a name that matches nothing is a wrong request, which ishistory’s rule for the same mistake: the canvas is there; the node is not.
This is not addressing, and does not reopen the argument against it. See
Addressing is by explicit node id below: that argument is
about a selector on a write, which can silently touch two nodes when the
writer meant one, and it is why IWE’s --expect match-count guard would have to
exist beside one. A read selector applies nothing, mints nothing and commits
nothing, and the matches are the output: there is no unseen second match for a
guard to catch. No editing verb takes a selector and none is being given one.
Who wrote what: --provenance
$ bin/canvas read my-task --provenance
Canvas-Base: e4a864130afb88ad2abc17f1b4889df707b15ded
Canvas-Wrote: y8dk 8b03f2ebb78897960b14ba0f159d7f0964969b26 leo | by-hand
Canvas-Wrote: itpe e4a864130afb88ad2abc17f1b4889df707b15ded claude-opus-5 | second-writer
<?xml version="1.0" encoding="UTF-8"?>
…
One Canvas-Wrote: <node-id> <sha> <author> line per node printed, in the order
the nodes are printed, composing with the selectors. It parses by splitting
twice: the id is four characters, the sha is forty, and the author is last and
free text, which it has to be — a Canvas-Author: value contains spaces and
pipes.
- “Last wrote” is the last commit whose
Canvas-Node:trailer names the node, whatever verb it was. Amovecounts: it names the node and it bumpsv, andvis exactly the count of those commits, so any other rule would make this disagree with the number in the file. - It is derived from the log and is in the document nowhere. No node gains an
authoror acommitattribute: the vocabulary is closed, and an attribute the log cannot check is the thingengineering-spec.mdalready rejected. Provenance is a property of the read surface only. - It is behind a flag and not on by default, because the canvas is bounded by what is affordable to send in every prompt, and a line per node on every read is a permanent tax on that. A read with no flags is byte for byte what it was.
- A node no commit names — reachable only by hand-editing the file, which the
store treats as out of band — gets the word
unrecordedin both fields rather than no line, so a caller can count lines against nodes. Canvas-Wrote:is a new name and not a second spelling of an existing one.historyprintsCanvas-Commit:andCanvas-Author:on lines of their own; reusing them here would be three lines per node, run together with nothing separating them. This name carries the join of a node, a commit and an author, which nothing else in the tool prints.
What changed since a sha: --since
Before writing against a base, ask what moved since it — and apply nothing:
$ bin/canvas read my-task --since 4f1a2c9
Canvas-Base: 31499cf9c2a16a4b7b5c2e7c1e8b9a0d3f6e5c4b
Canvas-News: 1 commit(s) between 4f1a2c9… and 31499cf…
replace itpe: they revised the expected value
diff --git a/my-task.xml b/my-task.xml
…
<?xml version="1.0" encoding="UTF-8"?>
…
It is the Canvas-News: block the soft branch
already prints after a write, verbatim — the same lines, in the same order, under
the same name — asked for beforehand instead of told afterwards. A writer sees
identical text in both places and does not have to learn a second shape.
- When nothing moved it prints exactly one line and no diff:
Canvas-News: 0 commit(s) between <sha> and <sha>. The soft branch prints nothing in that case, which is right there because nobody asked; here the question was asked, and silence is indistinguishable from the flag having done nothing. - A malformed or unusable sha answers exactly as
--basedoes, because it is one rule: not a sha at all is exit2; a well-formed sha this repository never handed out is exit1; known but not an ancestor of the head is exit1. An abbreviation git can resolve is accepted. The one difference is the next action —--base’s “or drop it to ask for no staleness check at all” is wrong advice for a question somebody asked on purpose. - It is not a lock, and must not be read as one. The answer can be stale the
instant it is printed.
--baseon the next write is what refuses. - It is not a whole-canvas history verb, and the refusal below stands — see What the store deliberately does not do.
Whether this canvas has ended: --frozen
A canvas is ended with freeze, and this is how a reader
holding only bin/canvas asks whether one has:
$ bin/canvas read my-task --frozen
Canvas-Base: e5adec19caf14d602d780b13530f24cbb13cca8d
Canvas-Frozen: e5adec19caf14d602d780b13530f24cbb13cca8d done: the done-gate artifact is PR #21, merged 2026-09-23
<?xml version="1.0" encoding="UTF-8"?>
…
And against a canvas that is still open:
$ bin/canvas read my-task --frozen
Canvas-Base: c6429ddfbb6b3a1360e469dda2823227c33186a7
Canvas-Frozen: none
<?xml version="1.0" encoding="UTF-8"?>
…
One line, Canvas-Frozen: <40-char sha> <the freeze's reason> or
Canvas-Frozen: none, directly under Canvas-Base: and above Canvas-Wrote:
and the Canvas-News: block, composing with all of them and with the
selectors. It parses by splitting once: the first field is forty hex characters
or the literal word none, and the reason is last and free text, which it has
to be — a --why contains spaces, colons, quotes and pipes.
- Both answers are exit
0, andnoneis one of them. A canvas that has not ended is not an error, exactly as a--typethat matches nothing is exit0and an empty root. It is printed rather than omitted for the reason--sinceprintsCanvas-News: 0 commit(s)…when nothing moved: the question was asked on purpose, so silence would be indistinguishable from the flag having done nothing. - The line does not say who froze the canvas, and that is a real cost
stated rather than argued away. The author is free text too, and two
free-text fields on one line cannot be split apart again. The line hands out
the freeze’s sha, so
git show <sha>has the author. A reader asking did this end, and why is answered; a reader asking by whom has the sha to ask with. Canvas-Frozen:is a new name and not a second spelling ofCanvas-Freeze:. The commit trailerCanvas-Freeze:’s value is a ledger id — it is how the freeze commit is found. This line’s value is the freeze itself: its commit and its reason. One name for one thing, so they are two names.- It is derived, and stores nothing. The line comes from a
git log --grepover the commit history, run at read time. There is no cache, no index, no attribute and no second home for the fact to go stale in — the commit history stays the one place a freeze lives, and nothing is added toschema/canvas.rng. It is also the only place the tool asks the question: nothing on the write path asks, because a canvas that has ended takes writes like any other. - The unflagged shape is untouched. A read with no flags prints one header
line and then the document, frozen canvas or not, so
bin/canvas read my-task | tail -n +2is not falsified by anybody freezing anything. That is why this is a flag rather than an unconditional line: the boundary rule licenses a header line for a caller who asked for one, and a caller who did not ask pays nothing. - It is not a whole-canvas history verb any more than
--sinceis. It answers one closed question — has this ended, and why — and the answer is a report and not a warning: no verb acts on it.
Rendering a canvas
bin/canvas render <canvas-id> > canvas.html
bin/canvas render <canvas-id> --format comment
engineering-spec.md section Projections gives the canvas three of them and
the first two are here: “HTML — one standalone document: shareable as a file,
viewable in a browser, openable from a file:// path”, and “the ledger row’s
Basecamp comment”, which is what --format comment prints. All of them are
one-way. A projection is never edited and never read back, so there is no
form, no button, no route and no flag here that writes to a canvas — --format
picks which projection and can be nothing else.
$ bin/canvas render my-task | head -3
<!DOCTYPE html>
<html lang="en">
<head>
stdout is the projection, and nothing but the projection. No Canvas-Base:
line above the doctype and no trailer under the closing tag: unlike read,
whose stdout is deliberately not a valid XML document,
this output is a file somebody opens or a block somebody puts in a comment, and
a line of plain text above it would be a defect in the artifact. Redirecting
stdout is how the page becomes a file, and there is no --output — a
projection this command could write anywhere is a projection somebody
eventually writes into state/canvas.
One standalone document. The projection is one generated HTML file with inline CSS and no external stylesheet, font or image. Canvas Diagram 1 and SVG figures draw in process and fetch nothing. An explicit Mermaid figure adds one exact, renderer-owned CDN ES-module dependency; its escaped source is readable before enhancement and remains so if JavaScript, the CDN or Mermaid parsing fails. Rendering needs no install, virtualenv or subprocess.
What the page carries:
- Every element in the vocabulary.
<section>with its title and its one level of nesting as two heading levels,<text>as a paragraph,<list>/<item>,<table>/<row>/<cell>,<figure>,<link>,<question>. Every node keeps its canvasidas the HTMLid, so an id found in one is found in the other and the index can link to it. - A marker on every
<question>. In words — Open question, Answered question — because a colour is not a marker a reader of the HTML can point at. - An index of open questions at the top, naming every
<question>id in the document and nothing else. An answered question keeps its entry and is quiet; it is not dropped.rendering.md§2 argues that, andtests/test_render.pyholds it on a canvas with open questions and on one with none. - The sha it was rendered from, in full, in the page’s own header. A projection outlives the canvas it came from — that is what carrying one to a comment does — and this line is the only thing that tells it apart from the canvas as it stands now.
- One reason on every node that crossed between the canvas’s sections, and
none anywhere else. A node has crossed when a
movenaming it changed which<section>it sits in — no section counts as one of the two values — and what the page shows beside it is the--whyof the latest such move, verbatim, with the full sha of that commit. A node born where it stands carries nothing; a node reordered inside one section carries nothing; a node carried along by its container’s move carries nothing, and the container carries the one reason that was written. So a canvas in which nothing has moved between sections renders with no reason in it at all. Nothing in the document says any of this: the structure says where a node is, the--whysays why, and the move is derived from the log.problem-and-solution-space.mdrules it — including why nothing reads the characters of a sectiontitle, so which space is which is your reading of your own heading — andtests/test_crossing.pyholds the count on both projections.
A <figure> is drawn. Use --mermaid-file FILE for architecture, flow,
sequence and relationship diagrams; it stores explicit payload="mermaid"
character data and the page progressively enhances its escaped source through
the pinned Mermaid module. URL-bearing source is left as readable source rather
than passed to Mermaid. Returned SVG is parsed off-document and accepted only
through a renderer-owned static SVG allowlist; images, reuse, links, scripts,
foreign objects, event attributes and external URL values reject the drawing
and preserve that fallback. A restrictive page policy also prevents Mermaid’s
temporary render tree from making an author-controlled request. With no payload
attribute the source remains Canvas Diagram 1: box ID ROW COLUMN "label",
edge FROM -> TO "label" (also -- and -x), and text ROW COLUMN "label";
coordinates run from 1 to 1000 and source mistakes become visible diagnostic
rows. Reserve payload="svg" and --svg-file FILE for small, simple reviewed
visuals; a closed validator rejects script, CSS, links, URLs, external entities
and foreign content. Existing schema-v1 and unmarked figures keep the same
textual drawer.
rendering.md §1 records both policy reversals and the safety
and fallback boundary.
--format comment: the projection a ledger row carries
$ bin/canvas render my-task --format comment | head -3
**Canvas** — rendered from `13ccbc7f403a9c52a05dba72194a730b730b4a97`
A projection of the canvas at that commit, not the canvas. Never edited and never read back; re-render to see it as it stands now.
The ledger row rewrites one Basecamp comment in place on every transition and is deliberately the only author on that anchor, so this form is not a comment — it is a block the row appends to the one it already writes. It is the same read, the same document and the same sha as the page; what differs is the output, and the output is dictated by the transport:
- No raw HTML tag of any kind, and no
<at all — a<in a canvas is written<. ThebasecampCLI converts a comment body from Markdown to HTML only when the body contains no HTML, so one tag turns the conversion off for the whole comment, including the ledger row’s own status blocks around this one. That is also why the HTML page is not pasteable into a comment, and whyengineering-spec.mdno longer says it is. - Paragraphs and bullet lists, separated by blank lines, because that is all a comment body renders. Hard wraps are off there, so a single newline is a soft break and adjacent lines fold into one paragraph.
- A
<table>is a header line and one bullet per row, cells joined by ` — `, because the Markdown table extension is off and a real table leaks its own pipes as literal text. - A
<figure>keeps an honest labelled fallback in a fenced block — Canvas Diagram 1 or Mermaid source, or entity-escaped inline SVG markup. Basecamp cannot display the picture; the standalone page is the drawn projection. No literal<reaches the comment body. - The index and the markers are the page’s, unchanged in substance: every
<question>named with its state, nothing that is not a<question>named, and a marker on every<question>node.tests/test_render.pyasserts all three on this form as well as on the page. - A crossed node’s reason is the page’s too, and the bound on it is
identical: one reason per crossed node, none on any other, the full sha
beside it. It is drawn as its own block —
**Carried across** \\` — — \` \`` — because a comment has no anchors and the id is all a reader has to match the line to its node. `tests/test_crossing.py` asserts the two projections name the same crossed nodes with the same reasons and the same shas.
What it cannot carry is the depth of a section — two heading levels on the page, one weight of bold here. The blocks are in the document’s order, so nothing is lost but the nesting, and the page is where a reader goes for that.
A render is a read, on read’s terms, in either form: it writes no file,
makes no commit, mints no id and does not initialise a repository, and it goes
on working on a canvas that has been frozen.
| exit | meaning |
|---|---|
0 |
the projection is on stdout |
1 |
the request is wrong against the store as it stands — there is no canvas for that Canvas identifier, or the stored document is invalid. An invalid canvas is refused rather than rendered, with the validator’s diagnostics: read prints an invalid document because refusing to show it would make it unrepairable, and a projection is the other case — a page asserting a canvas that does not exist, carrying a sha, is exactly the artifact somebody pastes into a comment. Repair it with bin/canvas read and bin/canvas replace, then re-render |
2 |
the tool or its environment is wrong — $OPENCLAW_WORKSPACE unset or unusable, a Canvas identifier that is not a filename, a missing or unrecognised argument, git or xmllint missing, the validator unable to run, or any other condition the operating system refuses the command with |
Those are the tool’s two codes and its one refusal shape; a new subcommand gets no third one.
A node’s history
The reason an edit was made is in the commit subject and nowhere else, so the
way to ask what a node’s current text is for is to read the log. history is
the verb that does it:
$ bin/canvas history my-task b7pk
Canvas-Node: b7pk
Canvas-Commit: 31499cf2d88f070dcd9f3fc7914c1e801e17d209
Canvas-Author: leo | by-hand
insert: the options this decision is between
Canvas-Commit: d8cabf10a6537bab095bcc0c22d607c1af1ff6c4
Canvas-Author: leo | step:implement | run:ship-the-flag-3
move: file it under the decisions section
Canvas-Commit: e29e76568379c9720c8ef2f7af59774122612c10
Canvas-Author: leo | step:implement | run:ship-the-flag-3
replace: chose A over B: B needs a migration we are not paying for
Oldest first, because the answer is a story: the reason the node was born, then every reason it changed, ending at the reason it reads the way it does now. One block per edit — the commit, the author, and then the verb and the reason, which are the commit’s subject taken apart. The reason is last in its block because it is free text and everything before it is not.
Edits made before a move are in the list, and not as a special case.
node-identity.md §3 keeps a node’s id across a move, so the commit the move
wrote names the same node the earlier commits named, and one query spans it.
That is what the id buys: a node that was drafted at the top of the canvas,
sharpened, and then filed under a section still answers for all three.
It names the canvas as well as the node, like every other verb, because
state/canvas holds one file per ledger row. And it is a read: it writes no
file, makes no commit, and does not initialise a repository.
The list is a node’s whole history and not most of it, which is a property
of the write path rather than of this command: one commit changes one canvas
and names the one node it changed there, so there is no edit for this query to
miss. What one commit contains is that guarantee,
and the one edit in the live store made before it held is recorded in
docs/unattributed-edits.md.
The match is on the trailer’s value, for equality — not the substring match
git log --grep='Canvas-Node: b7' performs. Ids are four characters, so asking
that for b7 also returns b7pk’s whole life, and a --why that merely quotes
the string Canvas-Node: b7pk is counted as an edit to a node it never touched.
Both are reachable by following the documentation. history reads the trailer
block git itself parses and compares the value, so a prefix of an id, a longer
id that starts with it, and a reason quoting the trailer text are all excluded.
v is counted through the same matcher, which is what keeps the number in the
file equal to the number of commits the history shows.
A commit that names no node matches no node id. Two commits in this store
carry no Canvas-Node: trailer — the create and the
freeze — and neither is an edit to anything, so neither
is ever an answer here. This is the matcher’s rule and not a case beside it:
a commit with no trailer decodes to no ids, rather than to one empty id.
An empty node id is exit 2, a malformed argument — not exit 1, “no
such node”. An id is four characters, so '' is not an id this canvas happens
not to hold; it is not an id at all, and no canvas that could exist would hold
one. That is the line the two codes already draw: 1 says the request is wrong
against the store as it stands and its advice is to re-read and re-decide, and
a caller whose variable came out empty can re-read this canvas forever without
finding a node named ''. 2 says the command was not well formed, and its
advice — fix the invocation — is the one that works. It is the same answer an
empty --why and a blank --problem already get, and it is decided before the
store is opened, so a blank id costs no read:
$ bin/canvas history my-task ''
canvas: a node id is required and must not be empty: a node id is four characters, so an empty one names no node and never could — this is the invocation being wrong, not the node being absent
Canvas-About: argument node-id
Canvas-About: Canvas identifier my-task
Canvas-Next: re-run `bin/canvas history my-task <node-id>` with the id you meant; if it came from a variable, that variable was empty. `bin/canvas read my-task` prints the canvas and every id in it
Canvas-Exit: 2 — the tool or its environment is wrong; do not touch the canvas
Whitespace-only is the same answer, as it is for --why. What this costs,
stated because it is real: a caller that builds the id from a variable and gets
an empty one is told its invocation is wrong rather than told the node is not
there. That is the trade — the invocation is wrong, and the refusal names the
empty variable instead of sending the caller looking for a node.
It used to exit 0 and print the create commit, which is neither of the two
answers above and was nobody’s decision: the trailer-less create commit
decoded to one empty id, so '' matched it. The freeze commit did not show
up beside it only because history path-scopes its query and a freeze changes
no byte of any file — so the old answer was half of a canvas-level history
form, arrived at by accident. This is not that form, and does not reopen
the argument against one: What the store deliberately does not
do still refuses a verb that
reports a whole canvas’s history, and history still takes a node id.
The four verbs
Four editing verbs, and no more:
$ bin/canvas replace my-task b7pk --type text \
--text "Chose A." \
--why "chose A over B: B needs a migration we are not paying for"
Canvas-Node: b7pk
Canvas-Base: 9c1e0a7…
Each is one commit, whose subject is <verb> <node-id>: <why> and whose
trailers are Canvas-Node:, Canvas-Author: and Canvas-Base: — the same
three create’s own insert commits already write. The reason is recorded in
the commit and nowhere else: there is no Canvas-Why: trailer and no attribute
on the node, because a node’s reasons are its history.
There is no resolve, no collapse and no supersede. An options
<table> becoming a settled decision is replace on the table node with
--type text. The semantics live in the reason, where they can be anything,
and not in a verb name, where they can only be what somebody thought of in
advance. freeze is not a fifth one: it edits no node and
changes no byte of the document, and all four of these go on working against a
canvas it has ended.
Addressing is by explicit node id. No selector, no path, no “the first
heading”. That is the property that makes IWE’s --expect match-count guard
unnecessary here — a selector can match two nodes, an id cannot — and it is the
argument against ever adding one to a write.
read --id and read --type are not that
and do not reopen it. The guard exists to catch a second match a caller never
saw, and it is needed because a write applies to what it matched. A read
selector applies nothing, mints nothing and commits nothing, and the matches are
the output — there is no unseen second match, and the count is legible by reading
what was printed. So there is no --expect, and no selector reaches any of the
four verbs.
| verb | what it does | id | v |
|---|---|---|---|
insert |
adds one node at a named position | mints a fresh one, never reminting a retired id | born at 1 |
replace |
new content, possibly of a different node type | unchanged, including across the type change | bumps |
move |
position only: content, type and children untouched | unchanged | bumps |
remove |
takes the node out | retired, never reminted | none left to bump |
A node’s v is written from the log rather than incremented in the file:
node-identity.md §4 defines v as the number of commits whose Canvas-Node:
trailer names the node, so the store counts them and adds the commit it is
about to make. The number in the file cannot drift away from its own
definition.
Each verb names the canvas as well as the node. Ids are unique across the
repository, so an id does identify a node on its own — but state/canvas holds
one file per ledger row, --into root names a root that every one of them has,
and finding the file by scanning them all would need a match-count guard for
the case where two answered. Naming the canvas is the cheaper half of that
trade, and it is what create and read already do.
--why, and what it costs to omit
--why is required by all four, with no default and no fallback. An absent
one is refused by the argument parser; an empty or whitespace-only one is
refused by the store. Both exit 2 — an unexplained edit is a malformed
invocation, not a request that is wrong against the store — and both write
nothing, commit nothing and mint nothing. Both name the node the edit was for,
too, and say what to type:
$ bin/canvas replace a-row bn3x --text "We chose A."
usage: canvas replace [-h] [--type NAME] [--text TEXT] [--title TITLE]
[--href HREF] [--answered] --why TEXT [--base SHA]
[--author AUTHOR]
canvas-id node-id
canvas: replace: the following arguments are required: --why
Canvas-Node: bn3x
Canvas-About: command canvas replace
Canvas-About: Canvas identifier a-row
Canvas-About: option --why
Canvas-Next: re-run the same command with --why TEXT (why this edit is being made; required, with no default). There is no default and no fallback: a reason a tool invented is a sentence in the history that reads like somebody decided something. Nothing was written
Canvas-Exit: 2 — the tool or its environment is wrong; do not touch the canvas
The rule a --why writer is given
docs/why-verdict/VERDICT.md §5.1 reads the first fifty reasons written to a
canvas and names one rule as the whole of the fix. It belongs wherever the
--why writing instruction lives, so it is here verbatim, and it is also in
the launch brief that puts it in front of a step that drives a canvas:
/Users/lfigea/Projects/ledger-orchestrator/guidelines/canvas-why.md.
Never point at another reason. If the justification for this node is one you have already given for a sibling or a parent, name that node’s four-character id and say what is different about this one — what it holds that the other does not, and what would retire this one and not the other. The words “as above”, “as before”, “same as”, “same shape” and “matching X” are not reasons:
canvas historyprints one node’s edits and never the node you meant.When the node you are placing holds nothing — an empty
<table>, an empty<row>, a one-word header<cell>— the reason is not about the element. Say what the structure it belongs to is for, name the container’s id, and say what would make this element wrong or unnecessary. Do not describe text that a later commit will put inside it.
The first clause’s opening phrases are the one shape the store also refuses on
its own: a reason that contains as above, as before, see above, same as
above, same shape, ditto or as previously and names no other node’s
four-character id is refused at exit 2 with nothing written, like an empty
one. require_reason in canvas/store.py is where that lives, and its
docstring records what it catches, what it does not, and the one reason in
forty-one the verdict priced as its accepted false positive.
A reason that asserts a fact about the world names its evidence
Convention, not enforcement — engineering-spec.md says the same thing about
the one drift this specification does not solve, and this repeats it where a
reader with only the README will meet it. An edit whose reason asserts a fact
about the world — that a PR merged, that a verdict ruled, that a file says
something — should name its evidence in --why, so a reader can follow it.
Nothing enforces that, and nothing is meant to: the check would have to leave
the canvas to run.
Name something a reader can resolve without already knowing the answer. A path, a commit sha, a pull request, a titled section, a report file, or an identifier they can grep for. “The ENOENT template”, “the staleness check”, “the launch brief” and “the ledger row” name a role rather than an artifact: a reader who does not already know which artifact you meant cannot get there from the reason, and a claim nobody can reach is a claim nobody can check.
Prefer a name to a line number. Fifty-four real reasons were read for
whether the evidence they name still resolves — docs/why-verdict/DRIFT-CHECK.md
in the canvas repository — and every citation that was a name resolved
unchanged, while both that were a bare line number now point at something
other than what they meant. Give the line if it helps, and give something
beside it that survives an edit.
Pin a claim about how the code is today. If the reason turns on the current state of something — that the tool cannot do X, that no template names Y — name the sha you looked at, or say what would retire the claim. Two reasons in that corpus rested on “the tool has no way to mark a question answered while leaving it standing”; a merge on another branch made it false seven hours later, and neither reason says what a reader should re-check.
Quote verbatim, or do not use quotation marks. They are an invitation to go and find the sentence. A paraphrase inside them costs a reader their trust in everything else the reason names.
None of this is a required form. There is no Evidence: line, no required
word, no shape a reason has to match and nothing that checks any of it: a
check that could tell a real citation from a plausible-looking one would have
to leave the canvas to run, and the freedom of the field is what
docs/why-verdict/VERDICT.md §5.3 found load-bearing.
A merge or a split names the other node’s id
The third convention in this place, and the same shape as the two above: about
what a reason must name, unenforced, and load-bearing because nothing else
carries it. node-identity.md §7 rules that a node’s lineage is the set of
commits whose Canvas-Node: trailer names its id, and that it ends where that
set ends — the tool records no ancestor and no successor. So when an edit moves
one node’s material into another id, the --why of that edit is the only
place the pointer exists, and it has to be in it:
- A
removethat retires the losing half of a merge names the id that took the material over, and says that it now stands there. The retired node’s history is not lost —bin/canvas historyon a removed id still exits0and prints its whole life, with the removing commit last — but nothing in the document leads a reader to that id, so this sentence is the only route to it. - A
replacethat takes material over names the id it is taking it from, while that node is still in the document to be named. - An
insertthat is the second half of a split names the id it was cut out of. The split’sreplacecannot name the born node in return, because the id does not exist until theinsertruns; that is the one direction the convention cannot carry, and no follow-up edit is made to repair it.
This is the same rule as VERDICT.md §5.1’s first clause reaching its hardest
case rather than a new one. It says never to point at another reason, and to
name the other node’s id and say what differs when the justification is a
sibling’s;
a merge’s remove is exactly that situation, and the reason that clears the
bar — why this node may go, when “we deleted this section” is not a reason
for deleting any particular thing that was in it — has to say that what it
held now stands inside the other node, which names it. The pointer is the
evidence the reason needs, not an extra clause. require_reason refuses the
version without it that a writer reaches for first: --why "Merged upward;
reason as above." exits 2 with nothing written.
docs/merge-and-split/ holds the merge and the split this was decided against,
with every invocation, both refusals and the bin/canvas history output for
the surviving id and for the stranded one.
How new content is supplied
Neither spec said, so this is settled here: a node type by name, and the three attributes the closed vocabulary has that are not identity.
| flag | what it sets |
|---|---|
--type NAME |
the element name. Defaults to text on insert, and on replace to the type the node already has |
--text TEXT |
the node’s character data |
--title TEXT |
the title a <section> requires |
--href URL |
the href a <link> requires |
--answered |
marks a <question> answered; absence means open. The one state a canvas carries — node-state.md. Restated and not sticky: a replace that omits it reopens the question |
Named flags rather than a general --attr name=value, because a general one
could set id and v — and insert mints ids, so a caller cannot supply one.
Which element names exist and which attributes each requires stays
schema/canvas.rng’s business: --type decision builds a <decision> node
and the validator refuses to let it reach the canvas’s path.
One edit is still one node
node-identity.md §5 settles what “one node” means when the node has children,
and it settles it for every container the vocabulary has — <section>,
<list>, <table>, <row> and the root — and not for <section> alone. What
the store does, verb by verb:
replaceon a container that has children renames it, and that is permitted and ordinary. The children keep their ids, theirv, their content and their order; the container’s own attributes are replaced and itsvbumps. A<section>of eight nodes is retitled by one command and the eight nodes are not mentioned in the commit.replacethat would change the type of a node that has children is refused, because the new type has nowhere to put them. Move them out first; they keep their ids throughout, which is the entire benefit.replacethat would give a node with children character data is refused, because a node holds children or text and never both, so the text would be dropped silently.removeon a node that still has children is refused, naming the node and every child id. A cascading delete either names N nodes in one trailer or lets N−1 vanish in a commit no grep on them will ever return. Empty it first.moveof a container carries its whole subtree, and is still one node’s edit. Position is a property of the child: every child is a child of the same container before and after, in the same order, with the same content and the samev, so not one child’s record changed and not one child’s history is missing anything. Only the moved node is named and only itsvbumps.moveof a node into its own subtree is refused. The subtree would leave the document and the commit would name one node while N disappeared.insertbrings exactly one node, and it arrives childless. There is no payload that fills a container, so a subtree is built the way it is emptied — one node at a time, each with its own reason.
Every refusal exits 1, writes nothing — not the file, not a commit, not a
temporary — and names the container and every child it would have touched, each
on its own Canvas-Node: line, with what to do instead on Canvas-Next:. That
is the shape every refusal takes, and these are where
it came from.
A replace payload cannot express a child at all: --type, --text,
--title, --href and --answered are five scalars, and there is no
--children, no --file, no document body and no stdin. “One commit rewriting N children” is
inexpressible here rather than merely refused.
There is no code path that writes more than one node
The twin of the --why rule above, in the same place and for the same reason.
_write_and_commit compares the document it is about to write against the
document already on disk, and refuses unless exactly the node named in the
commit’s Canvas-Node: trailer is the one that differs — same type, same
attributes, same character data, same parent, and the same sibling order for
every other node.
The supported write surface is five functions: create, insert,
replace, remove and move. Each takes a Canvas identifier, a reason and at most one
node id, and none of them takes a document. That absence is the design:
a function that accepts a whole tree is a whole-document rewrite whatever it is
called, so the parameter is not offered and the one private function that has it
refuses the write anyway. from canvas import store reaches no further than
bin/canvas does, and canvas.document writes to no file at all.
There is no code path that writes more than one node — not a command, not a
flag, not a combination of flags, not a file-level route and not an import path.
The single write that names no node is the birth of a canvas, and the only
document it may produce is the root alone, which is why create is three
commits and not one.
--base, and the two branches
--base <sha> is the sha a write was decided against — the one a read handed
out. All four verbs take it and the store enforces it, and what it buys is the
split engineering-spec.md § Staleness names. Nothing reconciles and nothing
merges: the refusal is the feature, which is also why this is not CRDTs.
$ bin/canvas read my-task
Canvas-Base: 4f1a2c9…
…
$ bin/canvas replace my-task b7pk --text "Chose A." \
--why "chose A over B" --base 4f1a2c9…
Nothing moved — --base is the head, or nothing has touched this canvas
since it. The write applies and says nothing extra. A read-then-write round
trip against an unchanged canvas is silent:
Canvas-Node: b7pk
Canvas-Base: 9c1e0a7…
Silent means no diff, not no output. Those two lines are the verb’s ordinary success output and the sha on the second is what the next write bases on.
Something else moved — the soft branch. The write applies, and the same
output that reports success carries what the writer did not know: the range,
the reason for each commit in it, and git’s own unified diff of this canvas
between --base and the head the write was applied to. The write’s own commit
is not in it — it is not news to its author.
Canvas-Node: b7pk
Canvas-Base: 9c1e0a7…
Canvas-News: 1 commit(s) between 4f1a2c9… and 31499cf…
replace itpe: they revised the expected value
diff --git a/my-task.xml b/my-task.xml
…
- <text id="itpe" v="1">A writer can learn what to write against.</text>
+ <text id="itpe" v="2">A writer learns what it did not know.</text>
The node being written moved — the hard branch. Exit 1. Nothing is
applied, nothing is merged, nothing is committed, no file is written — not even
a temporary — and no id is minted. The refusal names the node and carries that
node’s diff since --base on stderr, one block per commit that moved it: the
commit, its subject with the reason in it, and its patch. One commit is one
node, so a commit’s own patch is that node’s diff for that edit.
$ bin/canvas replace my-task b7pk --text "Chose A." --why "…" --base 4f1a2c9…
canvas: refusing to write b7pk: it moved in 1 commit(s) between --base
4f1a2c9… and 31499cf…, so this edit was decided against text that is no
longer there. …
Canvas-Commit: 31499cf…
replace b7pk: they got there first
diff --git a/my-task.xml b/my-task.xml
…
A node removed since --base is the hard branch too, with the removal as
its diff — rather than the bare “no node with id b7pk”, which a writer working
from a stale read cannot learn anything from.
insert never takes the hard branch. Its node is minted after the check,
and an id no commit has ever named cannot have moved. The position anchor it
names is a different node: an anchor that is gone is already a refusal, and
an anchor that merely changed is reported in the news rather than refused — the
writer is told, and the node it asked to file beside it is filed beside it.
Both branches are scoped to this canvas’s own file. state/canvas is one
repository holding every ledger row and the sha is repository-wide on purpose,
so without the path filter an edit to an unrelated row would make every writer
stale and the soft branch would fire carrying an empty diff. With it, the sha
stays a repository-wide identity key and the news is about the document the
writer actually read. history path-scopes for the same reason.
What an omitted --base means, and three ways one can be unusable
--base is optional, and an omitted one is not a base of “now”. It is the
absence of the question: nothing is compared, and the verb behaves exactly as
it did before this rule existed. That is deliberately not --why’s shape —
--why has no value the tool could correctly compute, so an absent one is a
malformed invocation, while an absent --base asks for no check and there is
no silently wrong answer hiding in it. create takes none under either
reading: the birth of a canvas has no prior state it could have been decided
against, which is also why its root commit writes no Canvas-Base: trailer.
the --base given |
exit | why |
|---|---|---|
| not a sha at all | 2 |
the invocation is wrong, as a malformed Canvas identifier is |
| a well-formed sha this repository never handed out | 1 |
a true statement about the store, like “no canvas for this Canvas identifier”. Re-read and re-decide |
| known, but not an ancestor of the head | 1 |
the canvas repository has one line of history and nothing here branches, so this came from a rewritten history or somewhere else — and <base>..HEAD would answer “nothing moved” for it, a vacuous pass wearing the safe case’s face |
An abbreviation git can still resolve is accepted. read hands out the full
forty characters, but there is no reason to refuse a shorter one supplied later.
The same three rows are
read --since’s, because a sha is usable or
it is not and which command asked cannot change that. The one thing that differs
is the next action a refusal names: “drop --base to ask for no staleness check
at all” is right for a write and is wrong advice for a read that asked the
question on purpose.
The Canvas-Base: trailer the commit carries is a different fact and is
written either way: it is the truthful record of the head the edit was applied
to, not of the base the writer declared. On the soft branch those are two
different shas, and the commit records the one a reader of the history needs.
Naming a position
node-identity.md §6 settles how a position is named, including the first
position of an empty container: --after <node-id> for the sibling case, and
--into <container-id> — appending as the last child — for the container case,
with the root named by the reserved word root. The flags ship with the four
verbs; the rule and the placement code are in canvas/document.py already, so
that the verbs inherit an answer instead of improvising one.
Ending a canvas
engineering-spec.md §Lifecycle gives a canvas a life with an end in it: born
at open, grown through executing, ended at done — “that artifact is a
projection of the canvas, and the ledger records the ending on the canvas as a
freeze” — and never deleted, because “abandoned ends it the same way, with
the reason as the last edit”. freeze is that edit:
$ bin/canvas freeze my-task \
--why "done: the done-gate artifact is the What/Why/Evidence block on PR #21, merged 2026-09-23; nodes gjxb and qrpa carry the outcome"
Canvas-Freeze: my-task
Canvas-Base: 9c1e0a7…
It takes a --why like every other write, on exactly the same terms — required,
no default, no fallback, no fields, no structure and no required vocabulary, and
refused by the same back-reference guard.
An absent one is exit 2 and an empty or whitespace-only one is exit 2, and
neither freezes anything. --author becomes the Canvas-Author: trailer, as it
does everywhere else. There is no --base: a freeze names no node and carries
no payload a moved document could invalidate, so it declares nothing, exactly as
create does — an omitted --base is the absence of the
question and
not an exception to the staleness rule. The commit still records
Canvas-Base: <head> truthfully.
One verb for both endings. done and abandoned are two things a --why
says, not two commands. The spec’s own sentence is that abandoned ends a
canvas the same way — the mechanism is identical and only the reason differs —
and this repository has already ruled twice that there is no resolve, no
collapse and no supersede, because “the semantics live in the reason, where
they can be anything, and not in a verb name, where they can only be what
somebody thought of in advance”. So there is no abandon, and the third outcome
nobody has thought of yet — superseded, merged into another row, cancelled
before it started — needs no fourth verb either. The convention is to begin the
reason with what ended it: --why "done: …", --why "abandoned: …". Nothing
enforces that, deliberately: a value set for the outcome is a taxonomy, and
--why gains no required vocabulary here any more than anywhere else.
There is no verb that removes a canvas, and that question has now been
asked. A canvas made by a probe, or by a mistake, or by a row that turned out
not to be a row is the third outcome above and is frozen like any other — one
of them, probe-empty-problem-20260924, is in the live store and was.
docs/a-canvas-is-never-removed.md is the
ruling, what was done with that one, and the one thing that would reopen it.
Where the freeze is recorded: in the log, as one commit. Its subject is
freeze <canvas-id>: <why> and it carries Canvas-Freeze: <canvas-id>:
freeze my-task: done: the done-gate artifact is PR #21, merged 2026-09-23
Canvas-Freeze: my-task
Canvas-Author: leo | by-hand
Canvas-Base: e4a8641…
It carries no Canvas-Node:, because a freeze edits no node — it is the second
and last write in this store whose commit names none, the other being the birth
of the canvas — and it changes not a byte of the document. Nothing is added
to schema/canvas.rng: the vocabulary is closed, it is written down once, and
what ends a canvas is not a node state. The freeze is where every reason in this
store already is, which is what makes it literally “the reason as the last
edit”; an attribute would need a second home for the reason, and
Canvas-Frozen: names the freeze’s commit, which a document can never carry
for the commit that wrote it. To see it, ask the tool:
bin/canvas read my-task --frozen
or read the commit itself:
git -C $OPENCLAW_WORKSPACE/state/canvas log --grep='^Canvas-Freeze: my-task$'
The query is not path-scoped, and cannot be. A commit that changes no file
is invisible to git log -- <path>, so the tool matches the trailer’s value for
equality across the whole repository — the same rule, in the same place, that
history already uses for Canvas-Node:.
The cost, stated because it is real: a frozen canvas’s XML file, read on its own
with no repository around it, does not say it is frozen. That is the price of
not putting a twelfth attribute into a closed vocabulary, and it is the same
price v, authorship and every reason in this store already pay — the document
was never the whole of a canvas here.
Every verb goes on working against a frozen canvas. replace, insert,
remove and move are refused by nothing on the strength of the freeze, and
neither is a second freeze; read, render and history are unchanged, as
they always were. An ended canvas is a canvas with something recorded about it,
not a canvas with something taken away from it.
This is a reversal, and the sentence it reverses used to be here. Until
2026-09-28 every write verb was refused against a frozen canvas at exit 1,
on the ground that a frozen canvas is read-only history, and a create for the
same Canvas identifier was refused with the same reasoning. The refusal has
been removed rather than softened — there is no flag, no override and no
second class of writer that may still write — and it was removed retroactively:
frozen-ness was never stored in the document, only derived from the log, so
every canvas already frozen accepted writes again on the next invocation with
no migration and no file edited by hand.
Why it was reversed. The block landed exactly when a canvas is most
improvable. The work is over, the person finally knows what the document should
have said, and that is the moment the tool chose to stop taking the correction.
The cost of a late edit to an ended canvas is one more commit in a log that
already records who wrote what and why; the cost of refusing it is that the
correction is not made at all, or is made in some other document nobody will
find. The three rules that make a canvas safe are untouched and are the ones
doing the work: one edit is one node, every edit carries a --why, and
a writer declares the base it read. An absent or empty --why is still
exit 2 on a frozen canvas, exactly as anywhere else.
What a create against an existing frozen canvas gets is the ordinary
“a canvas for <id> already exists” refusal at exit 1, with the ordinary
advice — read it and change it one node at a time — because that advice now
works. It used to get a second, sterner refusal saying a freeze was final and
the work needed a new Canvas identifier; that refusal is gone with the rule it
enforced.
What the marker still does. read
--frozen prints `Canvas-Frozen: