Problem and solution space
Problem and solution space
Settled 2026-09-28. This document decides two things and no third. First, what
it means — mechanically, in the vocabulary that already exists — for a node to
have moved from a canvas’s problem space to its solution space. Second, how much
of a node’s --why history a rendered projection is allowed to show. It decides
nothing about how either is drawn, nothing about what an agent is obliged to
write, and nothing about the canvas lifecycle. What is still open is named at the
end.
The reason it is settled now and not later: both halves are things a renderer
would otherwise decide by accident. A renderer that showed a reason under some
nodes and not others would have decided which nodes are in the solution space;
a renderer that showed every reason on every node would have decided that the
page is a transcript, which product-spec.md’s What it is not refuses in one
sentence. node-state.md opens by naming exactly that hazard — “A rule arrived
at implicitly, by a renderer that happens to render one thing loudly, is a rule
nobody can argue with afterwards” (node-state.md:16-18) — and the hazard is
the same one here, so the same answer applies: argue it before the page is
looked at.
It holds the existing commitments fixed rather than relitigating them:
- The vocabulary is closed. Eleven element names and no twelfth
(
schema/canvas.rng, pinned by the element invariant intests/test_validate.py). Nothing below adds an element, an attribute or a configuration file. There is no<assumption>, nospace=, nostatus=. <section>is structure, not semantics.engineering-spec.md’s The node vocabulary gives it one line, atengineering-spec.md:88: ”<section>| the only container; carriestitle; nests one level”. The paragraph under it, atengineering-spec.md:103-105, states the defence the whole vocabulary rests on: “this vocabulary is structural and not semantic:table,text,listdescribe shape, and shape does not run out”.- The semantics live in the reason (
engineering-spec.md:134-136,product-spec.md:48), and a--whygains no fields and no required vocabulary (docs/why-verdict/VERDICT.md§5.3,guidelines/canvas-why.mdinmalkovro/ledger-orchestrator). - One edit is one node, one edit is one commit, and a node’s history is the
set of commits whose
Canvas-Node:trailer names it (engineering-spec.md’s The timeline,engineering-spec.md:192and:218-223;canvas/store.py’shistory). movechanges a node’s position and nothing else — not its id, not its content, not its type — and its subtree travels with it untouched (node-identity.md§3 and §5).- No node carries state in any spelling except
answeredon<question>(node-state.md’s ruling, andengineering-spec.md:289-295, which rejected a state field as premature and said it “should be fixed with evidence in the reason before it is fixed with a new attribute”).
Every rule below was checked against those six, and where a candidate rule contradicted one, the candidate was the thing that gave way.
The worked example this is argued from
Exactly one canvas in the live store uses <section> at all: the canvas for
duplicate-invoice-criterion-output-schema, one of 62 present on 2026-09-28.
Its shape is what this document generalises, so it is worth stating what that
shape actually is rather than what it is remembered as.
Two <section> nodes at the top level: czfj, titled Problem space, and
jub8, titled Solution space. Two <text> nodes — q39f and x6fk, the
problem and the expected value the row was born with — sit at the top level
outside both sections. czfj holds two <figure> nodes that show the break
and the measurement of it. jub8 holds three <question> nodes and one
<figure> laying out three options against cost.
Not one node in that canvas was ever moved. bin/canvas history
duplicate-invoice-criterion-output-schema <id>, run against each of its eleven
nodes on 2026-09-28, returns a single insert for nine of them and an insert
followed by one replace for q39f and x6fk. There is no move commit in
that canvas’s history at all. Both sections and every node in them were inserted
into the position they still occupy.
That fact is load-bearing twice over and both readings are taken below: the shape does not require a crossing to exist (§3, first edge case), and a ruling that made a canvas without crossings defective would condemn the only canvas that has ever had the shape.
The other thing that history shows is a warning, and it is already on record.
Seventeen of the 36 commit subjects in that canvas are the identical string
“describe the problem and solution space for this row” —
docs/why-verdict/maintenance-check.md §1.2 counts them and rules on them:
“That is one writer issuing batches, not a person composing a reason per node —
and it is, incidentally, the failure VERDICT.md §5.1 exists to stop, arriving
here through a door that review never looked at.” So the worked example is a
model of structure and an anti-model of reasons. §5 below is written knowing
that the reasons a page will be asked to show are, on today’s evidence, often
that bad.
The ruling
1. A node has crossed when a move naming it changed which <section> it
sits in. Precisely: an edit crosses a node when its verb is move and the
node’s nearest <section> ancestor in the document at that commit differs from
its nearest <section> ancestor in the document at that commit’s parent, where
having no <section> ancestor counts as one of the two values and is not
equal to any section. A node that has at least one crossing in its history has
crossed; the last such edit is its crossing, and the --why of that edit
is the assumption that carried it. Nothing else is a crossing: not an insert,
not a remove, not a replace, not a move that left the nearest <section>
ancestor the same, and not a move of some other node that changed where this
one sits.
Which section is “problem space” and which is “solution space” is read off the
title by the person, and by nobody else. No rule here, no test, no renderer
and no tool reads the characters of a title. The document never claims which
of its sections is which, because it has no way to claim it that is not a word a
renderer is expected to interpret, and node-state.md refuses that class of
thing by name.
2. A projection shows exactly one reason per crossed node — the --why of
that node’s crossing, verbatim, beside the sha of the commit it came from — and
no reason anywhere else. A node that has not crossed carries no reason. A node
that has crossed three times carries one. No node carries two. A canvas in which
nothing has crossed renders with no reason in it at all, and that includes every
canvas in the live store today.
1. Why the ruling is about a crossing and not about a space
The obvious ruling is the one this does not take: the solution space is the section titled “Solution space”, and a node is in it when it is under that section. Three things kill it, and the third is the one that would have been found later and more expensively.
It puts a keyword in the document. title is free character data. A rule
that says “Solution space” means something to a tool makes that string a
reserved word — one that nothing validates, nothing documents in
schema/canvas.rng, and nothing catches when a person writes Solutions,
Solution-space, What we think now or Where we landed. The domain ruling
that governs this is node-state.md’s, restated in
guidelines/domain-decisions.md in malkovro/ledger-orchestrator: no node
carries state “in any spelling — not a status, not a state, not a
resolved, not a word inside the text that a renderer is expected to read.” A
title a renderer is expected to read is that last clause exactly. The attribute
would be spelled title= instead of status=, and that is the only difference.
It is the tripwire in another spelling. The Risks and open decisions table
in product-spec.md gives the growing vocabulary a row of its own, and the
landing place in that row, at product-spec.md:125, is a tripwire that fires the
day somebody proposes a decision or a risk node type — at which point, in its own
words, “the taxonomy has started growing”. A privileged section
title is a node type declared in character data rather than in the grammar. It
is <solution-space> with the angle brackets taken off, and it inherits every
property of the growth engineering-spec.md:99-106 warns about except the one
that would make it visible — it cannot be caught by the attribute-set invariant
or by the element invariant, because it is not an element and not an attribute.
Of the two, the undetectable one is worse.
It does not describe the thing the work is about. What a reader wants to see
is not where a node is; it is that a node is somewhere it did not start, and
what assumption put it there. Placement alone cannot say that: bwbs and v6k5
are in jub8 because they were written there, and a rule keyed on placement
would claim they were carried across by an assumption that does not exist. The
event is the crossing, and the crossing is a fact about the history, which is
where this repository already keeps the facts you reconstruct rather than act on
(node-state.md:343-344, the decision rule).
So the ruling names the event and leaves the naming of the spaces to the person
who wrote the titles. A reader who sees a node under a heading that says
Solution space, carrying one line that says what was assumed to put it there,
has the whole thing — and got it without the document ever asserting which of
its own sections is which.
2. What a crossing is, mechanically
Two documents and one comparison. For an edit E in a node’s history, with the
node id N:
Eis a crossing only if its verb ismove.store.Editcarries the verb, andbin/canvas history <canvas-id> <node-id>already prints it.- Let
beforebe the canvas document atE.sha^andafterbe the document atE.sha. Both are the whole file at that commit; the canvas directory is a git repository and one edit is one commit (engineering-spec.md:192), so both exist for every edit that is not the first. - Let
S(d)be theidof the nearest<section>ancestor of the node with idNin documentd, or the sentinel none whenNhas no<section>ancestor ind.<section>nests one level (engineering-spec.md:88), so there are at most two ancestors to walk and “nearest” is unambiguous. EcrossesNwhenS(after) ≠ S(before), comparing none as a value unequal to every section id.
Why the comparison is of section ids and not of positions. A move records
the node it named, the reason, the author and the base it was written against,
and no position: canvas/store.py’s _write_and_commit builds the subject
"%s %s: %s" % (verb, subject_name, why) and the trailers Canvas-Node:,
Canvas-Author: and Canvas-Base:, and --after / --into appear in none of
them. The position is in the document, not in the commit. So where a node went
is not a thing the log can be asked; it is a thing two documents are compared
for. This ruling is written as that comparison rather than as a query against
the log because the log genuinely does not carry it, and a rule phrased as if it
did would be unimplementable.
Why none is a value and not an absence. The worked example’s q39f and
x6fk sit outside both sections. A later edit that moved q39f into czfj
would be the canvas saying the problem the row was born with is now part of the
problem space we have drawn — a real event with a real assumption behind it, and
one a reader should see. Treating none as equal to nothing would make that
event invisible while making a move out of a section into the ungrouped body
invisible too, and those are the two edits most likely to be worth reading.
Why v is not the test. A node’s v is the number of commits naming it
(node-identity.md §4), and it bumps on a replace and an in-section reorder
just as it does on a crossing. v > 1 says the node was edited; it says nothing
about whether it went anywhere. Nothing here reads v, and nothing here changes
what v counts.
3. The edge cases, ruled
Each of these is a case two readers could otherwise rule differently, and each falls out of §2 without a special rule. They are stated anyway, because a rule whose consequences nobody wrote down is a rule two readers will apply twice.
A node inserted directly into the solution section, never moved. Not a
crossing. It has no crossing, it carries no reason in any projection, and that is
correct and not a gap. It was born where it is; there was no assumption that
carried it anywhere, because it never was anywhere else. The --why of its
insert says what it is for, and bin/canvas history is the one command that
prints it — which is product-spec.md:53’s own arrangement: “It holds the
current answer, not the path to it. The path is in the history, one command
away.”
This is the normal case and not a corner. Every one of the eleven nodes in the worked example is in it. A ruling under which the only canvas that has ever had the shape shows nothing is a ruling that has to be able to say so plainly, and this one does: that canvas is a correct canvas, it has no crossings, and a page of it carries no reasons. What it shows a reader is two titled spaces with the right things under them, which is most of what the shape was for.
A node moved between two sections, neither of which is a solution space. A crossing, and its reason is shown. This is deliberate and is the direct consequence of never reading the title: the mechanism cannot tell a “Problem space → Solution space” move from an “Evidence → Appendix” move, and it is not supposed to. What the two moves have in common is the thing the rule is keyed on — a person decided this node belongs somewhere else and had to say why. If the reason says “this is background now, not evidence”, then the page says that, and the page is right. Refusing to show it would require the rule to know which sections are which, which is §1.
A canvas with no sections at all. No node has a <section> ancestor, S is
none everywhere and in every document, and no move can change it. No
crossing is expressible, no reason is shown, and nothing is wrong. Sixty-one of
the 62 canvases in the live store on 2026-09-28 are in this state. A canvas is
not required to have spaces, and this ruling introduces no pressure on it to
grow them: a canvas with no sections renders exactly as it renders today.
A canvas whose sections are titled something other than “Problem space” and
“Solution space”. Not a case. No rule here reads a title, so there is nothing
for a title to be wrong for. A canvas with sections titled Symptoms and
Fixes, or Before and After, or A and B, gets crossings and reasons on
exactly the same terms. This is the property that makes the ruling survive the
second person who invents the shape without having read the first one.
A node moved more than once. Each move is tested on its own by §2, so a
node may have several crossings in its history. Its crossing is the latest
one, and that is the single reason shown. The earlier ones are the path, and
the path is in bin/canvas history, oldest first, spanning every move
(engineering-spec.md:218). The latest is chosen and not the first because a
canvas holds the current answer: the node is where it is now, and the assumption
a reader needs is the one that put it there, not the one that put it somewhere it
has since left. A node that crossed into the solution space, was pulled back to
the problem space, and crossed again shows the third reason — and if the second
and third matter, they matter to somebody asking why, which is the history’s
question and not the page’s.
A node that moved more than once and ended where it began. Still crossed, by
§2, and the reason shown is the last move’s. S(after) ≠ S(before) is evaluated
per edit and never against the node’s birthplace, so a round trip is two
crossings and not zero. That is right: the reason a person gave for putting it
back is a reason, and a rule that cancelled it would be a rule that decides,
quietly, that returning a node to where it started was not a decision.
A node carried by a move of its container. Not a crossing — of the child.
node-identity.md §5 rules that a move on a container is one node and that the
subtree travels untouched; no commit names the children, their v does not bump,
and their history does not record the edit at all. So a child’s nearest
<section> ancestor can change with no edit of its own, and §2 finds nothing
because §2 only ever examines edits in the node’s own history. The container
crossed; the container carries the reason; the children carry none. This is the
right answer and not a limitation: one --why was written, one node was named,
and one reason is shown. A rule that propagated the container’s reason down to
every descendant would print one sentence N times and would attribute to each
child a decision that was made about the group.
A node moved within one section — a reorder. Not a crossing. S(after) =
S(before), so §2 returns false and no reason is shown. Order inside a section is
presentation of the author’s own making; a reason like “put the measurement
above the diagram it explains” is real and worth keeping, and keeping it in the
history is where it belongs.
The first commit in a canvas. It has no parent, so E.sha^ does not resolve.
It is also a create, which names no node at all (canvas/store.py: the root
commit carries no Canvas-Node:), so no node’s history contains it and §2 never
reaches step 2 for it. Nothing to rule; noted so that an implementer who hits the
missing parent knows it is unreachable rather than unhandled.
4. What a projection shows, and the bound on it
The bound, stated as the thing a test asserts:
- Every crossed node carries exactly one reason, and it is the
--whyof its crossing, reproduced verbatim. - No node carries more than one reason.
- A node that has not crossed carries no reason. It follows that a canvas with no crossings renders with no reason in it.
- Every reason shown is accompanied by the full sha of the commit it came from.
Everything else is presentation and belongs to canvas/render.py: where on the
node the reason sits, what introduces it, whether it is a <p> or a <figcaption>
or a bullet, the wording of any label, the CSS, and whether the sha is a link.
engineering-spec.md’s The substrate already gives the renderer all of those —
“It owns every presentation decision” — and rendering.md makes the same split
for the question index, which is the precedent this follows.
Both projections, one claim. The bound is identical for --format page and
--format comment. Not because the two look alike — they do not, and the comment
projection may emit no < at all — but for the reason canvas/render.py already
gives about the index: a reader who has one projection and then the other must
not have to work out whether they are the same claim. Which reasons appear is
the claim; how they are drawn is not.
The sha is required, and it is required for the same reason the page’s own sha
is. canvas/render.py’s page carries the head sha in full for a stated reason —
“A rendered page outlives the canvas it came from” — and the sha is the only
thing that tells a reader which of the two they hold. A reason shown on a page is a
quotation from a commit message, and guidelines/canvas-why.md in
malkovro/ledger-orchestrator is explicit about what a citation owes a reader:
“Name something a reader can resolve without already knowing the answer.” A
reason with its sha beside it is resolvable — git show <sha> in the canvas
directory — by someone who has the pasted page and nothing else. A reason without
one is a sentence in quotation marks that nobody can get back to.
Verbatim, and not truncated. The reason is reproduced as it was written,
escaped for the transport and otherwise unaltered. No ellipsis, no first
sentence, no summary. guidelines/canvas-why.md again: “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.” A renderer that shortened a long reason would be editing a person’s
words under their sha, and it would be making a judgement about which half
mattered — which is exactly the quiet decision this document exists to avoid
making by accident. The cost of that is real and is stated in What this costs.
5. Why one reason and not all of them
Because the alternative is the transcript the product spec refuses.
product-spec.md:52-53 states it twice in two bullets: “A wiki accumulates. A
canvas resolves”, and “Not a transcript or a summary of one. It holds the
current answer, not the path to it. The path is in the history, one command away,
marked dead.” Every reason on every node is the path — it is git log with a
stylesheet. A page built that way would grow monotonically with the number of
edits, which is the defining property of the wiki the first bullet refuses, and
it would do it while the document it projects stayed the same size.
Because the budget is a hard number and this projection goes in every prompt.
product-spec.md:133 settles it at 20,000 characters of the canvas as
bin/canvas read prints it. A canvas near that budget with a long edit history
would, under an unbounded rule, render several times its own size. The document
is the thing the budget was measured on; a projection that multiplies it defeats
the measurement without anyone noticing, because nothing counts the projection.
Because a bound of one is the only bound that is not a taste. The candidates
were: all reasons; the last N; the reasons since some marker; the reasons that
“look substantive”. The first is ruled out above. The middle two put a number or
a threshold in the renderer that nothing in either spec can argue for, and
node-state.md already refused a value set whose members nobody could enumerate
in advance — “a one-member value set cannot be a taxonomy that falls
through”. The last is a renderer grading prose, which is not something a test
can assert and not something two readers rule the same way. One reason, on
exactly the nodes that have a crossing, is checkable by counting: count the
crossings, count the reasons, and the page is either right or wrong. That is
rendering.md §2’s own standard — “An index built by omission cannot be checked
against anything” — applied to the same page in the same way.
Because it tells a writer which sentence gets read. Today a --why is
written into a log that a person reaches only by running a command, and the
measured result is docs/why-verdict/maintenance-check.md §1.2: seventeen
identical reasons in one canvas. A rule that says the reason on your move is
the one sentence a person will see on the page puts one --why in front of a
reader by default, and it says in advance which one. That is a pressure on the
reason’s quality that no schema could apply, and it is applied to exactly the
edit where the repository already says the meaning lives — node-identity.md §7
puts the pointer across a merge or a split “in the --why of the edit that made
it”, and this is the same arrangement for the same kind of fact.
And because showing a reason at all needs an argument, which is this. The
page is a projection of the canvas; a reason is not in the canvas, it is in the
log. node-state.md §3’s distinction — the document is not the log — is what
makes answered="true" narrow, and it could be read as forbidding the log from
reaching the page at all. It does not, and the page already disproves it: the
render sha in the header is log data, put there for a stated reason. What §3
forbids is the document carrying a second kind of state. A projection reading a
bounded, stated slice of the log adds nothing to the document, and the bound is
what keeps “a slice” from becoming “the log”. Widen the bound and the argument
stops holding, which is why the bound is the ruling and not a default.
What this costs, stated honestly
-
The renderer’s input widens from one read to a read plus a walk of the log.
canvas/render.pytoday callsstore.readonce and nothing else; under this ruling it must also, for each node, find the crossings in that node’s history and read two documents permove. That is more git than a projection has ever done, it makes rendering O(moves) subprocesses rather than O(1), and it means a render can now fail in ways a read cannot — a shallow clone, a rewritten history, a commit whose parent is gone. The mitigation is that moves are rare: there are zero in the live store. The cost is real and it is the price of the reason being in the log, which is where every other ruling in this repository put it. -
A reason shown on a page can go stale, and nothing checks it. This is the same tension
node-state.mdrecords foranswered="true"and the same oneengineering-spec.md’s Between the canvas and reality declines to solve: the assumption that carried a node across may have been falsified an hour later, and the page will still print it under its sha. The defence is thinner here than there, because a crossing’s reason is not re-asserted by any later commit the way an answered question’s marker sits in the current document. What the sha buys is that a reader can date it. That is a mitigation and not a fix. -
A long
--whyis printed in full and there is no cap. A four-hundred-word reason on a crossed node is four hundred words on the page and in the Basecamp comment. Refusing to truncate is argued in §4; the cost of that argument is paid here, and it is unbounded in the one dimension the count bound does not cover. If it bites, the fix is a shorter--why, and the place to argue otherwise is this paragraph. -
The container case will surprise someone. A person who moves a
<section>from one place to another, having written a careful reason, will see it once — on the section — and not on the six nodes inside it that a reader is actually looking at. That is correct bynode-identity.md§5 and it will still read as a gap. It is recorded here so that the next person to hit it finds it ruled rather than unnoticed. -
Nothing here makes the shape happen. This document says what the shape means when it is there. It does not teach an agent to build it, does not require any step to write to a canvas — settled 2026-09-23 and not reopened — and does not make a sectionless canvas defective. If no canvas ever grows a second section, every rule above is true and inert.
What this makes deliberate, and what stays free to change
Deliberate — a later change may not rewrite these without reopening this document
- A crossing is a
movethat changed the node’s nearest<section>ancestor, with no section as one of the values. Not aninsert, not areplace, not a reorder, not a container’s move seen from a child. - No rule, test, renderer or tool reads the characters of a
title. Which section is the problem space and which is the solution space is the person’s reading of their own heading, and the document does not assert it. - A node’s crossing is the latest one, and the reason shown is that one.
- Exactly one reason per crossed node, none on any other node, and the full sha beside it. These four claims — one per crossed node, never two, none elsewhere, always the sha — are what a test asserts, on both projections, on a canvas with crossings and on one with none.
- The reason is reproduced verbatim. No truncation, no summary, no ellipsis.
- No element, attribute or configuration file was added to serve any of this, and none may be. A later change that needs one has found the tripwire.
Free to change — implementation choices, not decisions
- Where the reason appears on the node, and everything about how it looks.
Above the node, below it, in a
<details>, as a caption, as a dimmed line; the label wording, if there is one; the CSS; whether the sha is abbreviated in the display while the full one is in the markup. Allcanvas/render.py’s, onengineering-spec.md’s The substrate’s terms. - How the crossings are computed. Two
git shows per move, onegit log -p, a cache, or a cheaper comparison somebody thinks of later. What must not change is the answer. - Whether
bin/canvas historygains a way to print the crossings of one node, orbin/canvas reada way to name them. Both are conveniences over the same computation and neither is a decision. - The file this ruling lives in and its name. It is one document because the second ruling is stated in terms of the first; if they ever stop being coupled they can be split.
Known tensions — unresolved, recorded
-
The worked example is a model of structure and an anti-model of reasons. §5 leans on
docs/why-verdict/maintenance-check.md§1.2 to argue that showing one reason improves reasons. It could as easily do nothing: the seventeen identical strings were written by a batch writer that would have written them the same way had it known a page would show one. Nothing here tests that, and the first canvas with a real crossing is the evidence. -
rendering.mddecided what a renderer may settle for itself and this document widens it.rendering.mdsays it decides “the two questionsbin/canvas rendercould not inherit from anywhere” and that everything else about the page is an ordinary presentation choice. This is a third question of the same kind, ruled in a different file. The two documents do not contradict each other, but a reader who finds onlyrendering.mdwill believe the renderer’s non-presentation commitments are two and they are now three. Whoever next editsrendering.mdshould say so there. -
canvas/render.py’s docstring already cites arendering.md§3 that does not exist. It reads “the three claimsrendering.md§3 says are not free”, andrendering.mdhas a §1 and a §2; the claims meant are §2’s. Found while reading for this ruling, unrelated to it, and left alone rather than fixed in passing — noted so the next reader of either file does not spend the same minute on it.
Still open
-
Whether an agent should be taught to build the shape, and by what words in
skills/canvas/SKILL.md. This document defines what the shape means, which is the half that had to be settled before the teaching could be written without inventing a rule. Written 2026-09-28, on this branch.skills/canvas/SKILL.md’s The shape over time states the lifecycle and restates §2’s definition of a crossing in §2’s terms rather than paraphrasing it, and Worked: the reason on amovethat carries a node across sits beside that file’s existing--whysection. It teaches the shape as the default way a canvas ends as an answer and says in as many words that none of it is asked of a step, which is the Genuinely open itemproduct-spec.md:132settled on 2026-09-23. Whether agents then build it unprompted is a post-ship observation about later runs and is not something this branch can check. -
Whether a crossing should be visible in
bin/canvas read, which is what goes into a step’s prompt. The projections are ruled above;readprints the document and is not a projection. A step reading a canvas therefore cannot see that anything crossed. That may be exactly right — the prompt carries the current answer — or it may be the most valuable place of all to show one line. Undecided, and deliberately not decided by a renderer. -
What happens to a crossing’s reason when the node is later
replaced into something else. The crossing stands, the reason stands, and the text under it is new. Whether that reads as a stale caption or as the useful fact that this node arrived here for a reason that outlived its wording is not something this ruling can settle from zero examples.