Node state
Node state
Settled 2026-09-23. This document decides whether a canvas may say anything
about a node’s state, and how much. It is scoped to the four wants the friction
record collected at docs/drive-by-hand/FRICTION.md:145-164 and to the
vocabulary in schema/canvas.rng; it decides nothing about the renderer beyond
the one fact the renderer will need from the document, and it decides nothing
about the canvas lifecycle. What is still open is named at the end.
The reason it is settled now and not later: the renderer’s contract depends on
the answer. A renderer has to know whether “this question is open” is a fact it
reads off the document or a fact it cannot see, and it has to know that before
anybody writes the first line of it. engineering-spec.md:108-110 already
justifies <question> existing on exactly that ground — “the renderer has to
make it loud” — so the renderer’s contract and this vocabulary question are the
same question asked twice, and the vocabulary is the half that has to be settled
first. A rule arrived at implicitly, by a renderer that happens to render one
thing loudly, is a rule nobody can argue with afterwards.
It holds the spec’s existing commitments fixed rather than relitigating them:
- The vocabulary is closed. Eleven element names and no twelfth
(
schema/canvas.rng:13-16, pinned bytests/test_validate.py:130-143). - Four verbs and no more (
engineering-spec.md:122-127). There is noresolve, nocollapse, nosupersede(engineering-spec.md:131). - The semantics live in the reason, not in a verb name
(
engineering-spec.md:131-137,product-spec.md:48). - One edit is one node, one edit is one commit, and a node’s history is
git log --grep='Canvas-Node: <id>'(engineering-spec.md:139-141and:192,node-identity.md). (Added 2026-09-25, after this ruling — the bullet above is as written on 2026-09-23. That query is not the command.engineering-spec.md:218-223calls the bare--grepform “the same query written as a substring match” and “wrong in two reachable ways”: ids are four characters, so it also answers for a longer id that starts with the one asked about, and a reason that quotes the trailer text is counted as an edit to a node it never touched. The same paragraph, atengineering-spec.md:222-223, says instead that “The command matches the trailer’s value for equality instead” — which is whatcanvas/store.py:1037does, comparing the values of the trailer block git itself parsed; the anchored--grepatcanvas/store.py:1026is a pre-filter on top of that and never the authority (canvas/store.py:885-895). What this bullet holds fixed is untouched: a node has a history of its own, and it is exactly the set of commits whoseCanvas-Node:trailer names it. Nothing this ruling decides turns on how that set is queried, andvcounts that set rather than what any one query returns, so the ruling stands as written.) vis what the log counts — a node’s state must not become a claim the log cannot check (node-identity.md§4, and §7’s rejection of an ancestor id atnode-identity.md:555-598).
Every rule below was checked against those five, and where a candidate rule contradicted one, the candidate was the thing that gave way. That is why this ruling is narrower than the option it takes was originally phrased: see §4.
The ruling
Option B, and only for openness: <question> gains one optional attribute
recording that it has been answered, so that an answered question can stand in
the document instead of being destroyed by the replace that answers it — and
no other node gains any state, and the question does not gain a pointer to the
node that answered it.
Concretely: a <question> may carry answered="true" and nothing else. The
attribute’s absence means open. answered is legal on <question> and on no
other element, true is its only legal value, and answered-by — the pointer
half of the option as the todo phrased it — is refused, with the reason carrying
the pointer exactly as node-identity.md §7 already rules for merge lineage.
Option A is not taken and option C is not taken. §5 says why C could not be argued past the sentence that rejects it, and §1 and §3 say why A does not reach wants 1 and 3.
1. The four wants: which two this answers, and which two stay in the reason
The finding, FRICTION.md:150-151:
The single largest gap: a node has no status. Four separate wants in this canvas all reduce to it, and every one ended as a word typed into somebody’s prose:
They do not all reduce to it in the same way, and that is the whole of this
ruling. Two of the four are about a question being open, which is the one fact
engineering-spec.md:108-112 already says the document must carry and shape
cannot derive. Two are not.
Want 1 — “this question is answered” — ANSWERED
FRICTION.md:153-154:
- “this question is answered” — became
replacedestroying the question (f7be9ea,fad921f).
This is the want the ruling exists for, because it is the only one of the four
where the document loses the node altogether. FRICTION.md:109-110 states the
outcome: “The finished document contains zero question elements — the canvas
ends with no visible trace that anything was ever asked.” The writer said so
inside the document, in f7be9ea’s own reason: “The question node becomes the
answer because the tool has no way to mark a question answered while leaving it
standing.”
Under this ruling the answering edit is still one replace on the question
node, still one commit, still one reason. What changes is what it produces: a
<question> that stays a <question>, carries answered="true", and holds the
answer as its text. The question is still in the document, still findable, and
an agent reading the canvas can see both that something was asked and that it is
no longer open.
Want 3 — “the code has taken option B, and the question is still open” — ANSWERED, as a consequence rather than by a second mechanism
FRICTION.md:157-163:
- “the code has taken option B, and the question is still open” — the mirror image, hit by the first writer at commit
35e25a1, nodenf98. The node ends with the sentence “This node records what the code does; it does not close the question above it” — a sentence about the document, inside the document, because there is no shape for a provisional state. It then went stale: commit3aebd79had to replacenf98because that sentence became false once the question was settled, and its reason says why that matters — “A canvas whose nodes disagree about whether a question is open is worse than one that never said.”
This want reads as a request for a provisional state on the <text> node, and
it is not one. Read what nf98’s sentence actually asserts: it is a claim about
the question above it, not about itself. The writer had to put it in nf98
because the question node could not be relied on to still be there, and because
nothing about a standing <question> said whether it was still open.
Give the question node the fact and the sentence stops needing to be written. A
<question> without answered is open; a <text> beside it recording what the
code does asserts nothing about it either way; and the reader derives “the code
has taken option B and the question is still open” from two nodes each of which
is only about itself. The sentence that went stale is a sentence this ruling
makes redundant, and a sentence nobody writes cannot go false.
FRICTION.md:162-163 is the argument for putting the fact in one place rather than
two: “A canvas whose nodes disagree about whether a question is open is worse
than one that never said.” Two nodes can only disagree about whether a question
is open if two nodes are both allowed to say so. Under this ruling exactly one
node says it — the question itself — so the disagreement is not merely unlikely,
it is inexpressible. That is a stronger answer than a provisional marker on
<text> would have been, and it costs one attribute rather than two.
What this does not give want 3 is a way to mark the <text> itself as
provisional in some sense unrelated to an open question — “this is my best
guess”, “this is unverified”. That is asserted-versus-verified, it is
canvas-versus-reality, and it is the thing engineering-spec.md:291-295
rejected. It stays in the reason. See §5.
Want 2 — “this option is the one taken” — DELIBERATELY LEFT IN THE REASON
FRICTION.md:155-156:
- “this option is the one taken” — became the literal word
CHOSEN.inside a cell (90907b5,6be144a).
Left in the reason, for two reasons that are different in kind.
The spec already prescribes a different move, and it is the right one.
engineering-spec.md:131-134: “An options table becoming a settled decision is
replace on the table node, with the new node being a <text>, and --why
"chose A over B: B needs a migration we are not paying for this cycle".” The
canvas is “Not a wiki. A wiki accumulates. A canvas resolves”
(product-spec.md:52). An options table with a winner marked in it is a canvas
that did not resolve: it keeps four rows of superseded comparison in the
document that every prompt carries, and marks one. A state="chosen" on a
<cell> would make the un-resolved shape the comfortable one, which is the
opposite of what the product is for.
And the friction that was actually recorded is not the friction a marker
fixes. FRICTION.md:121-122 says what hurt: both commits “are full retypes of
cells several hundred characters long, because replace takes the new text and
not a patch of it.” The cost was retyping, not the absence of a word for
chosen-ness — the writer had the word, and typed it. A --chosen flag would
have removed one word from a several-hundred-character retype and left the
retype. Whether replace should be able to change an attribute without
restating the text is a real question and it is named in Still open; it is not
this one, and answering this one would not have answered it.
So want 2 is left where engineering-spec.md:131-137 puts it: the reason says
which option was chosen and why, and the table it was chosen from goes.
Want 4 — “this document is finished” — DELIBERATELY LEFT, and handed to its own todo
FRICTION.md:164:
- “this document is finished” — became node
gjxb(02de26b).
This is not a node state at all, and it is the one of the four that is
misfiled by being on the list. It is a property of the canvas, and the canvas
root is the one element that carries no id and no v and sits outside the
identity rules entirely (schema/canvas.rng:25-26, node-identity.md §4). It
is also already specified: engineering-spec.md:372-392 gives the lifecycle —
born at open, grows through executing, ended at done, never deleted —
so what want 4 records is not a missing vocabulary item but a missing
mechanism, the freeze the spec promises and bin/canvas does not implement.
FRICTION.md:127-129 says exactly that: --help lists seven subcommands and
“None is a freeze, a close or a done.”
Giving every node a state would not have reached it either: node-attributes is
referenced by the ten non-root elements and not by <canvas>, so even option C
would have had to decide the root separately.
It is therefore handed, deliberately and by name, to
Give a canvas an end (#10330567174),
which is already open in the same list. Until that lands, “this document is
finished” stays where 02de26b put it: in a node’s prose and in its reason.
This ruling does not pre-empt that todo’s answer, and it does not constrain it
beyond one thing — whatever ends a canvas is a property of the root or of the
ledger, not an eleventh element and not a value of answered.
The fifth finding this does not touch
FRICTION.md:166-172 records authorship as “The same shape as the missing
status: what a canvas needs to say *about a node has to be smuggled into the
node.”* It is not one of the four wants and the todo does not ask about it. This
ruling refuses the general form of that shape — a canvas may not carry
arbitrary metadata about a node — and answers exactly one instance of it, so it
leaves authorship precisely where it is: on the commit, via --author, not in
the document. If that is wrong it is wrong for its own reasons and needs its own
ruling.
2. The tripwire, and why one attribute on one element does not trip it
product-spec.md:123, the row in Risks and open decisions, in full:
The closed node vocabulary grows A fixed list of node types is a topology, and topologies fall through — the same failure we already have in how step failures are categorised Tripwire: the day somebody proposes a “decision” or “risk” node type, the taxonomy has started growing
and the clause itself:
Tripwire: the day somebody proposes a “decision” or “risk” node type, the taxonomy has started growing
This ruling reads the tripwire in spirit and not only literally, and it
survives the broader reading. The literal reading is easy and would be
cheating: the tripwire names a node type, engineering-spec.md:99-106 names
<decision>, <risk> and <acceptance-criterion> — element names — and this
ruling adds no element, so on the letter it is not tripped and neither would
option C have been. That is not good enough. The failure the tripwire is
actually about is stated at engineering-spec.md:99-104: “A closed list of node
types is itself a topology, and topologies fall through the moment a case
appears that nobody anticipated — the same failure this repo already has in its
handling of step failures, where each new kind of failure needed a new category
and the categorisation kept missing.” An enumerated attribute value set is a
closed list of names that has to anticipate the cases. It is a topology in a
different syntactic position, and it falls through the same way. Read in spirit,
state="decision" | "risk" | "chosen" | "provisional" is the tripwire, and
option C trips it whatever the RELAX NG spelling.
What survives the broader reading is a value set with one member. answered
is not a classification of a node; it is the negation of the one property
engineering-spec.md:108-110 already says the document must carry. There is no
second value to reach for, so there is no case the categorisation can miss —
the shape it adds is not “which of these is this node” but “is this question
still open”, and that question has an answer for every question node that will
ever exist.
The defence at engineering-spec.md:103-105 is that the vocabulary is
structural and not semantic: “table, text, list describe shape, and
shape does not run out.” This ruling does not weaken that. <question> was
already the declared exception — engineering-spec.md:108-109, ”<question> is
the one semantic node, and it is the one exception worth arguing for” — and
putting openness on it puts the semantics where the spec already put them, in
the one place it already argued for, instead of spreading them over ten
structural elements that have been semantics-free by design.
The tripwire is therefore moved, not disarmed. Under this ruling the day
somebody proposes a second legal value for answered, or answered on a second
element, is the day the taxonomy has started growing — and §6 makes the suite
say so out loud rather than leaving it to somebody’s memory.
3. “The semantics live in the reason” — which reading this takes, and how FRICTION and VERDICT are reconciled
engineering-spec.md:131-137:
There is no
resolve, nocollapse, nosupersede. An options table becoming a settled decision isreplaceon the table node, with the new node being a<text>, and--why "chose A over B: B needs a migration we are not paying for this cycle". 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. A verb per kind of intent is how you get eleven verbs and a twelfth case that fits none of them.
The clause, at engineering-spec.md:134-136:
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.
restated at product-spec.md:48:
The meaning lives in the reason, where it can be anything, rather than in a verb name, where it can only be what somebody thought of in advance.
This ruling holds the rule in full, and reads it as a rule about the space of
intents, not a rule about the location of facts. Both sentences name the same
contrast — anything against what somebody thought of in advance — and both
name the same failure: “A verb per kind of intent is how you get eleven verbs
and a twelfth case that fits none of them.” The rule forbids enumerating
intents. It does not forbid the document carrying a fact, and it cannot: the
document already carries title and href, and engineering-spec.md:111-112
proposes <text open="true"> — a state-ish boolean on an ordinary node, written
into the spec by the spec, as the shape <question> would collapse into if it
earned nothing.
The test that separates the two is: could this field ever have a twelfth
value that fits none of the ones somebody thought of? For state on every
node, yes, obviously, and that is why option C fails this rule as well as the
tripwire. For answered, no. It is one fact with no residue. There is nothing
for a reason to add that the attribute crowds out, because the reason still
carries all of why the question is answered and what the answer is — the
attribute carries only that it is, which is the one part a reader of the
rendered document needs and a reason cannot give them.
The two readings of the same event, reconciled
This is the part no file in the repository settles, and it has to be settled here because the two strongest pieces of evidence in the corpus are about the same commits and point in opposite directions.
FRICTION.md:109-110, on f7be9ea:
The finished document contains zero
questionelements — the canvas ends with no visible trace that anything was ever asked.
VERDICT.md:432-434, on the same edit:
Entry 37 is that design working — a question node becoming its own answer, legible only because a free-text reason said so.
Both are true, and they are not in conflict, because they are about two
different artifacts. --why lives in the git log. It is legible to somebody
running bin/canvas history <id>, and VERDICT.md’s whole method was reading
reasons out of the log, which is why from where it stood the design was working.
It is not legible to somebody reading the rendered canvas, and the rendered
canvas is the artifact product-spec.md:52 says must be “small enough to read
in a minute and cheap enough to put in every prompt”. FRICTION.md was reading
the document. Nothing was smuggled past either of them; they looked at different
things.
So the rule this ruling extracts, and the rule the rest of it applies:
A fact a reader of the rendered canvas must act on belongs in the document. A fact a reader reconstructs when they ask why belongs in the reason.
That an agent must not quietly answer an open question is a fact it must act on
before it has any occasion to run history, and engineering-spec.md:108-110
says so in as many words. Which option was chosen over which, and why, is a fact
you go looking for. The line falls between want 1 and want 2 and it falls there
for a stated reason, which is the whole of §1.
VERDICT.md:425-434 — “Do not give --why required fields or a structured
form” — is untouched by this. --why gains no fields, no structure and no
required vocabulary. The freedom of that field stays exactly as load-bearing as
the verdict found it; §5.2 of the same document (VERDICT.md:389-413) is the
precedent that this repository does add mechanism where mechanism can decide
something without judgment, and prices what it costs. This ruling is that shape,
and §7 below prices it.
4. Why the answering node’s id stays in the reason
The option as the todo phrased it is ”<question> gains a way to say it is
answered and by which node“. The ruling takes the first half and refuses the
second, and it refuses it on an argument this repository has already made and
already landed.
node-identity.md §7 rejected an ancestor attribute for merge and split
lineage, and its four costs transfer to answered-by one for one:
- It is
supersedein an attribute. Ananswered-bypointing from a question to the node that settled it is a lineage claim between two nodes, which is the verbengineering-spec.md:131deliberately does not have, wearing a different hat. - It would be the first piece of node state not derivable from the log
(
node-identity.md:568). RELAX NG cannot check a cross-reference, so either the id is unchecked — a pointer that can name a node that does not exist — or it is checked in Python, which puts a second copy of the vocabulary outsideschema/canvas.rngand breaks the one rulecanvas/validate.py:7-10andcanvas/document.py:3-10are both built to keep.answered="true"is checkable by the grammar alone;answered-by="wxyz"is not. - It is single-valued against an operation that is not. Two nodes can
jointly answer one question; an answer can be split later. One attribute
becomes a list, and
node-identity.md:582-584already prices a list attribute: “a subtree wearing an attribute’s clothes — addressed by no id, edited by no verb”. - And it can be false at exactly the moment a run dies. Answering a question
with a new node is an
insertand areplace: two commits, because one edit is one node. Stamp the pointer on the question first and the document asserts it was answered by a node that does not exist yet.
And node-identity.md:527-536 gives the positive half of the argument, which
applies here unchanged: the reason has to carry the pointer anyway.
require_reason already refuses a bare back-reference — “if the reason is
another node’s, name that node’s id and say what differs here” — so the --why
on the edit that answers a question is already the place a writer names what
answered it. An attribute beside that sentence would record less of the same
fact and be believed in preference to it, because it is structured and the
sentence is not.
answered="true" is not a pointer. It is a property of the question node,
about the question node, set by the one commit that is already naming the
question node. That is the whole of why the first half of option B survives §3
and the second half does not.
5. Why not option C, and why the argument past engineering-spec.md:291-295 could not be made
engineering-spec.md:285-295:
Between the canvas and reality — the canvas says we chose A; the branch implements B. No amount of reading the canvas catches this, and it is the more dangerous of the two precisely because the canvas is what a person will use to spy on progress and to steer. This specification does not solve it. The mitigation is convention only: an edit whose reason is a fact about the world should name its evidence in
--why— a PR, a verdict, a file and line — and a reader can follow it. Nothing enforces that. A state field distinguishing what was asserted from what was verified was considered and rejected as premature; if canvas-versus-reality drift turns out to bite, this is where it will be fixed, and it should be fixed with evidence in the reason before it is fixed with a new attribute.
The two binding clauses, at 272-276:
A state field distinguishing what was asserted from what was verified was considered and rejected as premature
it should be fixed with evidence in the reason before it is fixed with a new attribute
The todo’s instruction is that option C is available only with an argument that gets past that sentence. The argument could not be made, and option C is therefore not taken. Saying why is worth more than saying so.
The narrow reading is available and is genuinely tempting: what 272-273 rejects is one specific field on one specific axis — asserted versus verified, canvas versus reality — and none of the four wants is on that axis. All four are canvas-internal. Is this question answered, is this row chosen, is this claim provisional about an open question, is this document finished: not one of them is a claim about whether the world matches the canvas. On the narrow reading, 272-273 simply does not reach them.
It does not rescue option C, for three reasons.
First, 274-276 is a general ordering and it does reach them. “It should be
fixed with evidence in the reason before it is fixed with a new attribute” is
not scoped to the asserted/verified axis; it states which of two remedies is
tried first. The corpus shows the reason being tried and the corpus shows what
it bought: VERDICT.md:40-43 found 41 of 50 reasons meeting the bar, and
VERDICT.md:432-434 scored the want-1 edit itself as the design working. The
reason was tried on all four wants and it held on two of them. The ordering has
been honoured for wants 2 and 3-as-provisional-state, and that is exactly why
they stay in the reason in §1. For want 1 the reason was tried and found to
leave the document — not the log — with no trace, which is a defect the reason
is structurally unable to fix because the reason is not in the document. That is
an argument for the one attribute this ruling adds. It is not an argument for
ten.
Second, the evidence that has appeared since does not overturn “premature”; on
inspection it cuts the other way. The one candidate is want 3’s staleness:
35e25a1 wrote a state claim in prose and 3aebd79 had to replace the node
because the claim went false. That is an instance of something biting, and the
escape clause at 274-275 invites exactly that — “if canvas-versus-reality drift
turns out to bite, this is where it will be fixed.” But read what bit. A state
claim went stale. A state attribute on <text> would have gone stale in
precisely the same way, on the same commit, needing the same repair, because
nothing verifies an attribute either — and node-identity.md:572-573 names that
property as disqualifying: “an ancestor attribute is verifiable against
nothing — a claim about history stored outside history.” A state attribute on
every node is a claim about the world stored outside the world. Want 3’s
evidence is evidence that unverifiable state claims go stale, which is evidence
for the 272-276 rejection, not against it. The fix §1 takes instead removes
the claim rather than relocating it.
Third, option C fails two other tests independently, so even a successful
argument past 272-276 would not carry it: the tripwire read in spirit (§2), and
the enumerate-the-intents rule at engineering-spec.md:131-137 (§3). A state
attribute needs either an enumerated value set — a topology that will fall
through the first time somebody needs a value nobody listed — or a free token,
which puts the semantics back in free text and is option A with extra syntax and
a second place for the document to contradict itself.
Option C is refused. If canvas-versus-reality drift does turn out to bite,
engineering-spec.md:291-295 is still where it gets fixed, and nothing here
forecloses that.
Why option A is not taken either
Option A had to be argued rather than defaulted into, and §1 is that argument,
want by want: it is taken for wants 2 and 4, and it is refused for wants 1 and 3
for a single stated reason — the reason lives in the log, the question lives in
the document, and “an agent has to be told not to quietly answer it”
(engineering-spec.md:109-110) is a thing the document has to say to a reader
who has not run history and has no reason to. Option A’s strongest support,
VERDICT.md:432-434, is not evidence against this: §3 shows it is a judgment
about the log, made by a review that was reading the log, and it stands.
6. What schema/canvas.rng must enforce, and what must fail
This is the whole of the implementable content. A later step should need no judgment beyond it.
The grammar
One change, in <define name="question"> at schema/canvas.rng:189-194, between
the <ref name="node-attributes"/> and the <text/>:
<define name="question">
<element name="question">
<ref name="node-attributes"/>
<optional>
<attribute name="answered">
<value>true</value>
</attribute>
</optional>
<text/>
</element>
</define>
- It is
<optional>, so every canvas on disk and all seventeen fixtures stay valid. This is the first optional attribute in the grammar; there is no in-repo precedent for the construct and that is expected. - Its value set has exactly one member,
true.answered="false"is invalid, deliberately: absence means open, and two ways to spell the same state is how a document learns to contradict itself. - It goes on
<question>and on nothing else.node-attributes(schema/canvas.rng:59-70) is not touched — touching it is option C. - No change to
<canvas>atschema/canvas.rng:27-46. The root has no state; want 4 belongs to todo #10330567174. - Add a comment above the define in the style of the existing ones
(
schema/canvas.rng:175-176is the nearest example), naming this document and the reason the value set has one member.
What must now fail, and how
Nothing that was valid becomes invalid — the change is purely additive — so the
old shape that must fail is the wrong new shape. Three fixtures, in the
tests/fixtures/unknown-node.xml idiom: one legal node beside the illegal one,
so the diagnostic is shown to pick out the offender rather than condemn the file
wholesale. Assert with assert_rejected_naming
(tests/test_validate.py:98-108), which already requires exit 1 and not 2.
answeredon an element that is not<question>— e.g.<text id="q4rt" v="1" answered="true">beside a valid<question>. Must be rejected, exit 1, diagnostic naming<text>andid="q4rt".answeredwith a value other thantrue— e.g.answered="false", and a second case withanswered="yes"oranswered="TRUE"if one fixture is made to carry both. Must be rejected, exit 1, diagnostic naming<question>and itsid.answered-by="…"on a<question>— the pointer §4 refuses. The closed grammar already rejects an undeclared attribute, so this needs no grammar work; the fixture exists to pin the refusal so that adding the pointer later means deleting a test and reopening this document. Must be rejected, exit 1, diagnostic naming<question>and itsid.
Attribute-level violations already name the node with no change to
canvas/validate.py — _describe at validate.py:139-158 prints element name,
id and v — and tests/test_validate.py:228-233 and :173-188 are the
existing proof of it. No validator work is needed.
All three were run before this was written, against a scratch copy of the
repository with the grammar above applied, so the shape is not being proposed
untested. validate_file returned one diagnostic each and
bin/canvas-validate exited 1 each, verbatim:
answered-on-wrong-element.xml:4: <text> (id="q4rt", v="1"): Invalid attribute answered for element text
answered-bad-value.xml:4: <question> (id="mqxd", v="1"): Invalid attribute answered for element question
answered-by-pointer.xml:4: <question> (id="mqxd", v="2"): Invalid attribute answered-by for element question
Both legal shapes — a <question> with no attribute and one with
answered="true" — validated at exit 0. Note what the diagnostic does and does
not carry: it names the element, the id and the v, and it does not echo
the rejected value, so a test should assert the element and the id and not the
string false.
The premise, pinned in the suite
Following 3bcd0ba and cd8d90e — rulings in this repository pin their premise
as an invariant the suite re-runs — add to tests/test_validate.py the mirror
of test_the_grammar_declares_exactly_the_vocabulary (:130-143): parse the
RNG and assert the set of declared attribute names is exactly
{"ledger", "schema", "id", "v", "title", "href", "answered"}
There is no such invariant today, which is why adding an attribute breaks no
existing test. That is the gap this ruling closes: §2 moves the tripwire to the
attribute set, and this is the tripwire. A twelfth attribute, or a second legal
value for answered, should break a test and force somebody to come back here.
The one positive case
tests/fixtures/valid.xml gains an answered <question> beside the open one it
already has, so the legal shape is exercised and not only the illegal ones.
The command line
canvas/cli.py:305-333, _add_payload, gains one flag beside --title and
--href:
parser.add_argument(
"--answered",
action="store_true",
help="mark a <question> answered; absence means open",
)
and the docstring at cli.py:309 — “the two attributes the closed vocabulary
has that are not identity” — becomes three, naming this one. A named flag
and not a general --attr, for the reason already given there. The flag reaches
store.replace and store.insert and is passed into the attributes dict at
store.py:2349-2355 beside title and href; document.new_node
(document.py:92-115) needs no change, because it already drops a None.
Two consequences to write down rather than discover:
- The attribute is restated, not sticky.
replacebuilds a new node from the payload, so areplacethat omits--answeredclears it — exactly as it already does fortitleandhref. That is how a question gets reopened, and it needs no new verb. - Marking a question answered bumps
v. It is a commit naming the node, andvis what the log counts (node-identity.md§4). No new rule; noting it so nobody treats it as a bug.
7. What this costs, stated honestly
A question is retyped to answer it. store.replace builds the replacement
from the payload, so --text omitted means the node’s character data is
dropped, not preserved (store.py:2349-2355). Marking a question answered
therefore means passing its text again. That is the same defect that made want 2
expensive — “replace takes the new text and not a patch of it”
(FRICTION.md:121-122) — and this ruling does not fix it. It is smaller here
than there: a question is a sentence, a comparison cell was several hundred
characters. But it is the same defect and it is named in Still open rather
than quietly absorbed.
The first optional attribute in the grammar is a precedent. Every attribute today is required. Optionality is a door, and the next person who wants one will point at this. The mitigation is the attribute-set invariant in §6, which makes walking through that door break a test.
<question> now earns its keep, which forecloses the cheaper future.
engineering-spec.md:111-112: “If it turns out to earn nothing, it folds back
into <text open="true">.” This ruling answers that clause in the negative and
should say so plainly: <question> earns something, and this document is the
argument that it does. The retirement is not available while answered stands,
because folding <question> into <text open="true"> would put openness on an
ordinary structural element — which is §2’s objection to option C, arrived at
sideways. Anyone who later wants the retirement has to reopen this.
Two of the four wants get nothing. Want 2 is left with a replace and a
reason, and want 4 with a node that says the document is finished while anybody
with the ledger id can still write to it tomorrow. §1 argues both are right. If
either is wrong, it is wrong on the record and pointable-at, which is the reason
this is a document rather than a habit.
What this makes deliberate, and what stays free to change
These two lists are what goes into the orchestrator’s
guidelines/domain-decisions.md.
Deliberate — a later change may not rewrite these without reopening this document
- A canvas says exactly one thing about a node’s state: whether a
<question>has been answered. No other node carries state, in any spelling. Why: the four wants are not one want (§1), and the general form — a canvas carrying metadata about a node — is whatengineering-spec.md:291-295rejected as premature and §5 could not argue past. answeredis legal on<question>and on no other element. Why:<question>is the declared semantic exception (engineering-spec.md:108-109); putting state on structural elements is what makes the vocabulary a topology (§2).answeredhas exactly one legal value,true; absence means open. Why: a one-member value set cannot be a taxonomy that falls through, and two spellings for one state is how a document contradicts itself (§2, §6).- A
<question>does not point at the node that answered it.answered-byor any equivalent id-valued attribute is refused. Why: the four costsnode-identity.md§7 prices for an ancestor id apply one for one, and the reason already has to name what answered it (§4). - The reason carries semantics;
--whygains no fields, no structure and no required vocabulary. Why:engineering-spec.md:131-137,product-spec.md:48, andVERDICT.md:425-434on evidence. - Openness is stated in exactly one place — the question node itself. No
other node may assert whether a question is open. Why:
FRICTION.md:162-163— “A canvas whose nodes disagree about whether a question is open is worse than one that never said” — and want 3 is answered by making the disagreement inexpressible (§1). - The vocabulary is written down once, in
schema/canvas.rng. No vocabulary rule moves into Python, which is the reason the pointer is refused rather than merely deprecated. Why:validate.py:7-10,document.py:3-10. - “This document is finished” is not a node state. Whatever ends a canvas is
a property of the root or of the ledger. Why: §1, want 4;
engineering-spec.md:372-392; the root carries noidorv. - The decision rule behind all of the above: a fact a reader of the rendered
canvas must act on belongs in the document; a fact a reader reconstructs when
they ask why belongs in the reason. Why: it is what reconciles
FRICTION.md:109-110withVERDICT.md:432-434(§3), and it is the test any future proposal of this kind should be put to.
Free to change — implementation choices, not decisions
- The attribute’s spelling.
answeredversusresolvedversusclosed;answered="true"versus a valueless spelling, if the grammar gains a cleaner one. The fact carried is deliberate; the word is not. - The CLI surface.
--answeredas a store-true flag, the flag name, its help text, and the wording of thecli.py:309docstring. A different flag shape that sets the same attribute is free. - Whether
replacecan change an attribute without restating--text. Today it cannot (§7). Fixing that is a change tostore.replace, not to this vocabulary, and it would make marking a question answered cheap without touching anything above. - The fixture filenames and the exact diagnostic wording asserted. The tests
assert behaviour and not wording by standing policy
(
tests/test_validate.py:3-6). - The renderer’s treatment of an answered question — quiet, struck through, collapsed, or moved. This ruling settles only that the renderer can tell, and that an open question must be loud.
- Whether a
<question>may be born answered viainsert --type question --answered. The grammar permits it and nothing forbids it. If that turns out to be a nuisance it can be refused in the CLI without reopening this. - The attribute-set invariant’s exact form — parsed set equality is the obvious shape because the element invariant already uses it, but any check that breaks when the attribute set changes serves.
Known tensions — unresolved, recorded
One of the five below has since been ruled on. It is kept here and marked rather than deleted, so that a reader who was told it was unresolved is told by the same place that it is not, and so that what settled it stays one link away from where it was recorded. The other four stand.
<question>’s retirement clause now points the other way.engineering-spec.md:111-112says<question>folds back into<text open="true">if it earns nothing. This ruling makes it earn something, so the spec’s own named exit is closed whileansweredstands — and the spec has not been edited to say so. The two documents now disagree in tone if not in letter, and whoever next editsengineering-spec.mdshould reconcile them.FRICTION.mdandVERDICT.mdstill read the same event in opposite directions in their own texts. — Settled 2026-09-24: both were amended, and each now cites the other and this section. §3 reconciles them here, by distinguishing the document from the log; when this was written neither file cited the other and neither had been amended, so a reader who found only one of them got one of the two readings whole. That is no longer true of either.docs/drive-by-hand/FRICTION.md:114-118citesVERDICT.md:432-434and §3;docs/why-verdict/VERDICT.md:449-455citesFRICTION.md:109-110and §3. Neither amendment widens this ruling — the verdict’s own addition re-asserts that ”--whystill gains no fields, no structure and no required vocabulary” — and each is marked as a later addition, so both records still read as what their authors wrote on the day. What is settled is the finding, not the disagreement: the two readings stand as two readings, and §3 remains the only thing that reconciles them. Nothing is left open in this bullet.- A state claim in an attribute can go stale exactly as a state claim in prose
can. §5 uses this against option C, and it is equally true of
answered: nothing verifies that ananswered="true"question really was answered. The defence is that the marker is set by the same commit that writes the answer, so the window is one commit wide rather than open-ended — but it is a defence and not a proof, and if it turns out to bite, this is the sentence to point at. - Want 2’s real cost is untouched and lives elsewhere.
replacetakes whole text and not a patch, so marking anything means retyping the node. This ruling narrows how often that hurts; it does not fix it, and nothing in this repository owns it yet. - Want 4 is handed to another todo and could come back differently. #10330567174 may conclude that ending a canvas needs something this ruling did not anticipate. The only constraint left on it is that whatever it is, it is not a node state.
Still open
- Whether
replaceshould be able to change an attribute without restating the node’s text. Today it cannot:store.replacebuilds the replacement from the payload and a missing--textdrops the character data (store.py:2349-2355). That makes marking a question answered a retype of the question, and it is the same defect that madeCHOSEN.cost several hundred characters twice. It is a question about the verbs and not about the vocabulary, which is why it is not decided here, and it is the single change that would do most for the wants this ruling leaves in the reason. - What ends a canvas. Want 4, handed by name to
#10330567174.
engineering-spec.md:372-392promises a freeze atdoneandbin/canvasdid not implement one when this was written. This document constrains that answer in exactly one way: it is not a node state. - Whether authorship is the same shape.
FRICTION.md:166-172says what a canvas needs to say about a node has to be smuggled into the node, and names authorship as the other instance. This ruling refuses the general form and answers one instance; it does not answer that one. If authorship belongs in the document it needs its own argument, and “we already did it foranswered” is not one. - Whether an answered
<question>should ever bereplaced away. Nothing here forbids it, and a canvas that resolves (product-spec.md:52) may legitimately want the question gone once nobody will re-ask it. The marker is the preferred move and the destruction stays available. Whether that is right is a renderer-era question: it depends on whether an answered question still earns its space in an artifact that goes in every prompt.