Phase 9: the document editor, the ICC desk, and four roots - #33
Merged
Conversation
The form died. /propose/ asked an author to pick an operation, pick a target
and fill a text box — a surface that assumed they already knew what those three
things were. It never went live, so nobody had to unlearn it.
What replaced it opens a copy of the constitution. Every provision is editable
in place; the operations are DERIVED from the difference. The proposer never
picks a target, never types an id, and never meets the words "operation" or
"YAML" — an e2e assertion, not an aspiration.
What the form left behind is the whole of it. Nothing under the surface moved:
the bill YAML is still the source of truth, the substantive hash is still
computed over the same canonical subject, approvals still bind to it, an edit
still voids them, Article 16(3) still needs all three bodies at two thirds, and
act enact and act apply are untouched. The standalone ajv validator, billToYaml,
classifyOperation, the ballot renderer, the instrument renderer and the e2e
harness are all reused rather than rewritten — one implementation of each, which
is the point.
Two rules in bill-derive.mjs are load-bearing rather than stylistic, because
classifyOperation compares fullText(node) with operationText(op):
- a substitute on an article that has clauses carries EVERY clause. The schema
permits omitting them "to leave them untouched", but such an operation can
never compare equal to the provision it produced — it would read as `apply`
forever, and as `divergent` the moment a base text is in play;
- an edit confined to one clause targets that clause, so the meeting reads one
clause rather than six.
Renumbering has no representation at all. Not a rejected input — there is no
control and no function that could emit one.
Re-uploading never re-opens old text. The page loads today's constitution and
replays the file's operations onto it, one at a time: unchanged targets carry
over, targets that already read as proposed are dropped as enacted by someone
else, and rewritten targets are parked with both versions side by side. Telling
the second from the third needs the version the proposer drafted against, so the
build now publishes archive/<version>/constitution.json; without it every
unproven edit becomes a conflict, because "this probably still fits" is not
something a tool may decide about a constitution. What comes out carries the
current version — not as a rule anyone follows, as the only thing the page can
produce.
/icc/ is new: the clerking desk. Numbering, the status transitions, the
resolution sentence, the three ballot sheets, each body's tally with the 16(3)
verdict computing live, and a browser-computed sha256 per signed minutes PDF.
It generates a file. It submits nothing and enacts nothing — assent, the signed
instrument and the change to the constitution stay with the CLI, where every
number it produced is independently re-derived. The e2e proves both halves: a
record act enact accepts, and the same record refused once a tally is edited or
a PDF goes missing.
Found while building it:
- the Phase 8 form collected a membership ID under the words "recorded so the
ICC can check its register — that is its whole function", and dropped it.
There was no field for it. moved_by.membership_id now exists and is carried;
- .field, .op and .hash-line had no CSS at all — the form shipped as an
unstyled column of controls, the same defect as the BILLS_CSS string nothing
imported;
- src/lib/paths.mjs read process.env at module scope, which takes a page's
entire module graph down on import rather than at the call site. A test now
walks the browser boundary instead of trusting it;
- a control that re-rendered the document sent the keyboard back to the top of
the page. Focus is now placed deliberately, and the walk is asserted;
- an autosave test that passed alone and failed under the parallel run: a
fixed sleep racing a debounce. It waits on the condition now;
- eight stale OG plates are committed under assets/og/ from earlier fixture
builds. Not fixed here, but OG_DIR now exists so test builds stop adding to
them.
The exclusion list is per-surface: the editor still cannot number, approve or
enact; the desk clerks fully and stops at enactment. Both lists are parameterised
into the existing serialiser tests rather than copied, because two copies drift
and the one nobody updates keeps passing.
Both pages sit behind PROPOSE_ENABLED and open together — a proposal surface
with no clerking desk behind it is the adoption failure one step later.
126 unit tests, 29 e2e. Constitution untouched; tripwire green at 82 provision
strings, zero permitted changes.
Ruling 1. A clause-scope `omit` validated, classified `apply`, reported
"applied", and changed nothing — silence dressed as success, inside the
applier. Reproducing it turned up three more at the same depth, none of which
any test could see because no test varied scope:
omit reported applied over an untouched provision
retitle ABORTED a lawful Act — so the fixture bill's own op-4 could
never have been applied
substitute aborted for the same reason
insert wrote a nameless article carrying a NaN number into the
constitution, instead of a clause into its article
Clause scope was not partly broken. It was unusable.
ONE RESOLVER. The disease was three traversals that could disagree about what
a target is: validate and classify resolved clauses correctly while the applier
carried its own `doc.articles.find(...)`. `resolveTarget` in bill-core.mjs is
now the only one, and all three consume it.
OMISSION IS A STATUS, AT EVERY DEPTH. The node stays, its number stays, and
every citation ever made to it still resolves — the same rule for a clause as
for an article, taken from OPERATION_STATUS so the mapping exists once.
THE STRUCTURAL CLOSURE. Applying is not finished when the loop ends; it is
finished when the document reads as the Act prescribes. `actApply` now
re-classifies every operation against what it just wrote, and aborts before any
write if one did not land. An operation that quietly does nothing cannot report
success — not this operation, any operation. A future bug can be silent only by
making its own target read as prescribed, which is the same thing as working.
The heading is checked separately, because classification compares text and
Article 12's title once failed to land behind a content match.
THE MANIFEST GUARD, MADE PRECISE. It measured an article by `fullText`, which
includes that article's clauses — so a lawful clause-scope operation made its
article look like an undeclared change. Per-provision own text now, plus an
ancestor rule: a clause may move when its own id is declared, or its article's
is. More precise, not weaker.
Also here, both found by the matrix: inserting a clause marked its parent
article `amended_by`, a mutation of a provision the Act never declared and one
the guard cannot see (it measures own text, not provenance) — the clause
records the Act, the article does not. And re-running an Act that inserted a
provision is refused at validation rather than classified: a refusal is safe, a
duplicate Article 10 would not be, and that now has its own test.
`actApply` and `actEnact` gained a constitution seam, default unchanged, so the
matrix can drive the real applier against a throwaway fixture. Without it no
test could reach this code at all, which is why none of these defects was
found by one.
The status cross-check no longer greps the applier for `status: 'omitted'` —
the applier names the shared mapping now, so the literal is gone and the grep
failed on an improvement. What replaced it runs the applier and reads the
status back out of the document it wrote.
Ruling 2. An article-scope substitution that omitted `sections` was permitted by the schema, and the schema's own description recommended it — "omit to leave the target's sections untouched". That form CANNOT be verified as applied: application compares a provision's complete text, clauses included, so an operation naming none reads as `apply` forever and as `divergent` the moment a base text is in play. A format that permits a bill which can never verify is a format defect, so this is an error (`incomplete-substitution`) and not advice. Until now the rule protected only bills the editor built. It protects the format now. The rule is keyed on what the TARGET is, not on the `scope` the operation claims, because the two are cross-checked nowhere and a rule keyed on a label would be evadable by mislabelling. The legitimate need the omitting form appeared to serve — amend only the opening words — is served exactly by restating the clauses unchanged, so nothing lawful is lost. The schema description now says so, and so do TEMPLATE.yaml and AUTHORING-BY-HAND.md, which taught the trap. `sections: []` — an article restated with no clauses at all — is a real resulting state, and a length test read it as "leave them alone". Presence, not truthiness: the fourth time that class has bitten here, after history[].evidence, recorded_by, and blockText's phantom newline. The derive, the emitter and the applier all honour presence now, and removing an article's last clause in the editor was reachable and produced exactly the unsettleable bill this rule forbids.
Three things the ruling's cleanup turned up, none of which any test could see. CORRECTION FIRST. I reported eight stale Open Graph plates as "committed". They are not: `assets/og/` is gitignored, so they were local build artefacts and never reached the repository or a deploy. Deleted locally so local matches CI; `OG_DIR` keeps test builds out of the real directory, which is the permanent fix and stands. Counting them turned up something real. A plate is named after its page's slug, and Article 16 is titled "Amendments" — the same slug the amendment register used. One overwrote the other, so the register's social card showed Article 16. Nothing failed: the file existed and the link resolved. The site's own pages are namespaced now, and a test asserts no two pages want one plate. A HASH THAT DID NOT MATCH WHAT IT SAT UNDER. `refresh()` in the editor awaits SubtleCrypto, so two can be in flight — the one the page starts on load, and the one a restored draft starts a moment later. Both resume and write to the same elements, and the last to resume won regardless of which was current. That put an empty draft's hash above a review showing a real proposal, and let a stale run's save() overwrite a restored draft in browser storage. A generation token now retires everything after the await that is not the newest call. THE FLAKE WAS NOT FLAKY. A test failed about one run in three and passed every time in isolation, which reads as timing and was not: a sibling suite's `before()` built a fixture site with `execFileSync`, blocking the event loop for seconds, so puppeteer's 5s timer fired the instant it unblocked. The page was never slow. Both e2e suites build asynchronously now, and the previously flaky test ran green eight times consecutively. Also: an assertion that dark means dark. Phase 8 shipped the engine whether the page did or not; a build without the flag must now contain only app.js under scripts/, and none of engine/, constitution.json, bills.json, /propose/ or /icc/.
Seven defects, four roots, and all four the same disease — duplicated logic
diverging. They land together because they are entangled: the comparator is
inside the applier, the applier is inside the walk, and the accounting is
inside all three. The order of work was the ruled order; the order of the diff
could not be.
ROOT 1 — the comparator: "the same text" is two questions.
`normalise` was born comparing YAML against `pdftotext` output, where folding
whitespace, case, typography and enumerator formatting is not a compromise but
the whole job. It was promoted into classification, the apply loop and the
self-audit, where both sides are canonical YAML and nothing is noise. There, a
renumbered clause IS an amendment and `units` → `Units` IS a retitle — and the
audit could not catch either, because it folded with the thing it audited.
forensicEqual the tolerant fold, only where one side came out of a PDF
operativeEqual exact, after the one normalisation YAML forces on us
A retitle now also asks whether the heading is ENACTED: an Act stating a
heading that stood as an editorial aid changes no words and changes everything
about where the heading comes from.
The historical re-check ran first and is committed as a tool
(src/comparator-recheck.mjs). It reads the record and writes nothing: 19
provision claims, all resolving, 0 reclassified, 0 collapsible. Nothing moves,
and the reason matters — the 2024 Acts carry no operative text at all. They
were prose transcribed by hand, so their verdicts were forensic and stay
forensic. PROVENANCE.md is not reopened by a comparator migration.
ROOT 2 — one applier.
`applyOperationsToModel` was a second applier and had diverged in both
directions a copy always does: it wrote a `removed` flag where the applier
writes a status, so a carried-over omission silently became nothing on rebase;
and the NaN clause-insert fixed in one copy went on living in the other.
`applyOperation` in bill-core is now the only one. `actApply` wraps it with IO,
the manifest guard and the self-audit; the editor wraps it with model state;
neither decides what an operation means. The test suite's own miniature applier
is gone too — it wrote `title` without `title_source`, so it could not have
caught the very defect it was standing in for.
THE SKIP DECISION IS `classifyOperation`. The loop's private shortcut was
Article 12's bug reborn in the code written to prevent it: skip on a text match,
leave the heading behind. The self-audit had upgraded that from silent loss to
permanent deadlock, which proved the audit worked and fixed nothing. The cause
is gone; a new heading over unchanged text now applies and settles, and that
exact scenario is a named test.
ROOT 3 — the walk.
A heading change stopped the descent, so every clause edit under it vanished —
silently, with the review confirming a bill that did not contain the proposer's
work. The gravest of the seven. Heading and clause changes are independent
findings on the same node now, pinned three ways: the unit case, the review,
and an invariant worth keeping forever — every provision the editor touched
appears among the derived operations, minus none.
ROOT 4 — omission accounting.
Presence was not completeness. A partial clause list validated clean and
silently repealed every clause it left out: no status, no note, no anchor, and
nothing in the document to say the clause had existed. And an article with an
already-omitted clause could not be amended at all — restating the living
deleted the dead, stating none was refused.
- A substitution ACCOUNTS FOR every clause of its target: each restated, or
carried as `status: omitted`. Silence over one is the error.
- Apply never deletes, at any depth. A tombstone keeps its node, its number,
its anchor and the Act that made it.
- Revival is explicit — including occupying a reserved number, which is a
substitution and not an insertion. The editor derived an `insert` there,
from its own button, producing a bill the validator refused outright.
- The instrument and the ballot sheets say it in words: "Clause (2) is
omitted." That sentence is what the bodies vote on.
Schema, validator, TEMPLATE, AUTHORING-BY-HAND, PROPOSING, AMENDMENT-PROCESS
and the regenerated SPEC move together, so nothing still teaches that unstated
clauses are "untouched".
HOUSEKEEPING — the OG collision, namespaced by kind (pages/…, articles/…), and
a build that ABORTS if two pages ever want one plate rather than letting the
last writer win.
Also: the two e2e suites were sharing a process, and Node's runner aborted the
whole file mid-flight — cancelling tests that had not failed and reading as a
flaky assertion. They are separate files now, which is the fix; the timeout was
never the problem.
The seven originals live on as named regression tests. 160 unit tests, 29 e2e.
Constitution untouched; tripwire green at zero, including under the re-check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the operations form at
/propose/with a document editor, adds the ICC clerking desk at/icc/, and closes four root causes found reviewing them. Both pages stay behindPROPOSE_ENABLED, so this merge ships nothing visible.The reframe
The proposer opens a copy of the constitution, changes the words they want changed, and downloads their proposal. Operations are derived from the difference — no target is picked, no id is typed, and the word YAML never appears on the page (an e2e assertion, not a claim). Re-opening a proposal replays it onto today's constitution, so "based on the latest version" is the only thing the page can produce.
/icc/clerks: numbering, transitions, the resolution sentence, the three ballot sheets, each body's Article 16(3) verdict computing live, and a browser-computed sha256 per signed minutes PDF. It generates a file; it submits nothing and enacts nothing.Four roots, one disease
Duplicated logic diverging. Seven reproduced defects, all downstream of four copies:
removedflag where the applier writes a status; a carried-over omission silently became nothingNow:
operativeEqual/forensicEqualnamed for their two jobs; oneapplyOperation; heading and clause changes independent; and nothing is ever deleted at any depth — a removed provision keeps its node, number and anchor, and the instrument states it in words.The apply loop's skip decision is
classifyOperation.actApplyends by re-classifying its own output, so an operation that quietly did nothing cannot report success.Verification
src/comparator-recheck.mjs, reads the record and writes nothingapp.jsunderscripts/; no engine, no/propose/, no/icc/Adoption conditions in
process/ADOPTION.mdare unchanged.🤖 Generated with Claude Code