Skip to content

Phase 9: the document editor, the ICC desk, and four roots - #33

Merged
KiranPranay merged 5 commits into
mainfrom
feature/document-editor
Aug 16, 2026
Merged

Phase 9: the document editor, the ICC desk, and four roots#33
KiranPranay merged 5 commits into
mainfrom
feature/document-editor

Conversation

@KiranPranay

Copy link
Copy Markdown
Member

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 behind PROPOSE_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:

root was
The comparator the PDF-era fold ran inside classification, so a renumbering-only Act read as already applied — and the self-audit folded with the thing it audited
Two appliers the editor's replay wrote a removed flag where the applier writes a status; a carried-over omission silently became nothing
The derive walk a heading change stopped the descent, so every clause edit under it vanished — with the review confirming a bill that lacked the proposer's work
Omission accounting presence was not completeness: a partial clause list silently repealed the clauses it omitted, anchors and all

Now: operativeEqual / forensicEqual named for their two jobs; one applyOperation; 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. actApply ends by re-classifying its own output, so an operation that quietly did nothing cannot report success.

Verification

  • 160 unit tests, 29 e2e; the seven originals live on as named regressions
  • historical comparator re-check: 19 provision claims, 0 reclassified — committed as src/comparator-recheck.mjs, reads the record and writes nothing
  • constitution untouched; tripwire green at 82 provision strings / 0 permitted changes throughout
  • dark build ships only app.js under scripts/; no engine, no /propose/, no /icc/

Adoption conditions in process/ADOPTION.md are unchanged.

🤖 Generated with Claude Code

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.
@KiranPranay
KiranPranay merged commit 38a9069 into main Aug 16, 2026
6 checks passed
@KiranPranay
KiranPranay deleted the feature/document-editor branch August 16, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant