diff --git a/.gitignore b/.gitignore index 0e3558f..4a12387 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ css/style.css.map .DS_Store assets/og/ bill-gate-report.md + +# Scratch space for reproductions and workflow agents. Never committed. +.tmp-scratch/ +.wf-scratch/ + +# Scratch space for reproductions. Never committed. +.tmp-scratch/ +.wf-scratch/ diff --git a/README.md b/README.md index 9b998ca..172102b 100644 --- a/README.md +++ b/README.md @@ -143,14 +143,31 @@ fewer. | | | |---|---| -| [process/PROPOSING.md](process/PROPOSING.md) | How to write a bill. Start here. | +| [process/PROPOSING.md](process/PROPOSING.md) | How to propose a change. Start here — no terminal, no YAML, no GitHub account. | +| [process/AUTHORING-BY-HAND.md](process/AUTHORING-BY-HAND.md) | Writing the bill file directly. Same result, for anyone who prefers it. | | [process/AMENDMENT-PROCESS.md](process/AMENDMENT-PROCESS.md) | Roles, lifecycle, thresholds, versioning. | | [process/MINUTES-TEMPLATE.md](process/MINUTES-TEMPLATE.md) | What each body's record of resolution must contain. | -| [process/ADOPTION.md](process/ADOPTION.md) | What must be true before the in-browser bill builder goes live. | - -The in-browser builder at `/propose/` is built but not yet published: it is an action surface, and -it opens when the ICC is ready to receive drafts. Authoring a bill by hand produces exactly the same -file — copy `bills/TEMPLATE.yaml` and run `npx opencodelaw bill validate`. +| [process/ADOPTION.md](process/ADOPTION.md) | What must be true before the two working surfaces go live. | + +Two pages are built but **not yet published**, behind `PROPOSE_ENABLED`. They are action surfaces, +and they open together when the ICC is ready to receive. + +- **`/propose/` — the document editor.** The proposer opens a copy of the constitution, changes the + words they want changed, and downloads their proposal. The operations are derived from the + difference: no target is picked, no id is typed, and the word YAML never appears. Re-opening a + proposal replays it onto *today's* constitution, so "based on the latest version" is not a rule + anyone follows — it is the only thing the page can produce. +- **`/icc/` — the clerking desk.** Numbering, the status transitions, the resolution sentence, the + three ballot sheets, each body's tally with the Article 16(3) verdict computing live, and a + browser-computed sha256 for each signed minutes PDF. It generates one record; it submits nothing + and **enacts nothing**. + +Neither page uploads anything, and neither has authority the CLI does not re-check: `act enact` +recomputes the thresholds, re-reads every evidence file and re-verifies its checksum. A hand-forged +record fails at that gate exactly as it would if the pages did not exist. + +Authoring a bill by hand produces exactly the same file — copy `bills/TEMPLATE.yaml` and run +`npx opencodelaw bill validate`. ## Honest limitations diff --git a/bills/TEMPLATE.yaml b/bills/TEMPLATE.yaml index 235988d..012a150 100644 --- a/bills/TEMPLATE.yaml +++ b/bills/TEMPLATE.yaml @@ -10,7 +10,9 @@ # what your bill would change, before and after. Read that diff — it is what # the approval meetings will read. # -# Author's guide: process/PROPOSING.md +# Prefer not to write YAML? The propose page does all of this for you: +# https://constitution.stmorg.in/propose/ +# Writing it by hand: process/AUTHORING-BY-HAND.md # The rules this follows: process/AMENDMENT-PROCESS.md # --------------------------------------------------------------------------- @@ -75,6 +77,21 @@ operations: text: | The complete new text of Article 3, written out in full, exactly as it should read once this Act is applied. + # If the provision has clauses, EVERY one must be ACCOUNTED FOR here — + # restated with its text, or carried as a tombstone with `status: omitted`. + # Leaving one out is refused: silence used to mean deletion, and a deleted + # clause takes its number and every citation to it with it. Nothing is ever + # deleted, at any depth. Changing only one clause? Target the clause + # instead: `target: art-3-s-2`, `scope: clause`. + # sections: + # - number: 1 + # title: Admission + # text: | + # The complete text of clause (1). + # - number: 2 + # title: Duties + # status: omitted + # note: Why clause (2) is being ended. # A second example — delete to remove it. # - id: op-2 diff --git a/examples/starter/fixture-constitution-moved.yaml b/examples/starter/fixture-constitution-moved.yaml new file mode 100644 index 0000000..d40c852 --- /dev/null +++ b/examples/starter/fixture-constitution-moved.yaml @@ -0,0 +1,174 @@ +# --------------------------------------------------------------------------- +# The FIXTURE constitution, MOVED ON to version 2.2.0. +# +# examples/starter/fixture-constitution.yaml, as it would stand after two +# other Acts had passed while a proposal sat waiting. Exactly two provisions +# differ, and each one exercises a different arm of the rebase: +# +# Article 1 untouched → a carried-over edit still applies cleanly +# Article 2 now reads as the proposer was going to ask for +# → their change has been enacted by someone else +# and is dropped +# Article 5 rewritten by a third party +# → their edit is parked and must be re-made against +# the words that are actually there +# +# The Guild does not exist. Nothing here is anyone's law. +# --------------------------------------------------------------------------- + +opencodelaw: "1.0" + +info: + title: MARROW VALE LAMPLIGHTERS + organization: The Marrow Vale Lamplighters' Guild, Incorporated + jurisdiction: Marrow Vale, Province of Thule + registration: GUILD/0042/1897 + + version: 2.2.0 + status: current + legal_status: adopted + effective_from: 2026-05-01 + + contact: + name: The Keeper of the Roll + email: keeper@marrowvalelamplighters.org + url: https://marrowvalelamplighters.org + license: CC-BY-4.0 + +preamble: + id: preamble + title: Preamble + title_source: editorial + content: | + We, the lamplighters of Marrow Vale, having kept the lamps of this town + alight through fog and frost since the founding, adopt these articles for + the ordering of the Guild. + adopted: 2025-09-01 + +articles: + - id: art-1 + number: 1 + title: Name + title_source: enacted + content: | + The Guild shall be known as the Marrow Vale Lamplighters' Guild, and in + these articles as "the Guild". + + # Untouched by the fixture bill, so a test has something to prove was left + # alone when the bill is applied. + - id: art-2 + number: 2 + title: Objects + title_source: enacted + content: | + The objects of the Guild are: + + 1. to keep every public lamp in Marrow Vale alight from dusk to dawn; + 2. to maintain the ladders, wicks, oil and glass in the Guild's keeping; + 3. to train apprentices in the safe handling of flame at height; + 4. to keep the Vale's clocks wound. + + # An article carrying both its own body and sections. The fixture bill + # substitutes this one in full, sections and all, which is what exercises + # the "complete resulting text" rule on a subdivided provision. + - id: art-3 + number: 3 + title: Membership + title_source: enacted + content: | + Membership of the Guild is open to any person of the Vale who keeps a + lamp and abides by these articles. + sections: + - id: art-3-s-1 + number: 1 + title: Admission + title_source: enacted + content: | + A person becomes a lamplighter on being entered in the Lantern Roll + by the Council. + - id: art-3-s-2 + number: 2 + title: Duties + title_source: enacted + content: | + A lamplighter shall light the lamps of their assigned round at dusk + and extinguish them at dawn. + - id: art-3-s-3 + number: 3 + title: Withdrawal + title_source: editorial + content: | + A lamplighter may withdraw by returning their taper to the Keeper of + the Roll. + + # A number held by no provision. The gap is recorded, never closed by + # renumbering the articles that follow it. + - id: art-4 + number: 4 + title: Reserved + title_source: editorial + status: reserved + note: Held for the article on night wardens, which the Guild has not yet adopted. + + - id: art-5 + number: 5 + title: The Council of Wicks + title_source: enacted + content: | + 1. The Council of Wicks consists of eleven lamplighters elected by the + Guild at the Michaelmas meeting, of whom two shall be apprentices. + 2. The Council appoints from among its number a Keeper of the Roll, a + Keeper of the Oil and a Keeper of the Ladders. + + # Reserved by the fixture bill: the number is kept, the text goes. + - id: art-6 + number: 6 + title: Meetings of the Council + title_source: enacted + content: | + 1. The Council meets at the Lamp House on the first evening of each + quarter. + 2. Notice of a meeting is given by lighting the blue lamp above the Lamp + House door for three nights beforehand. + + # Omitted by the fixture bill. The number is never reused. + - id: art-7 + number: 7 + title: The Taper Fund + title_source: enacted + content: | + The Taper Fund holds the pennies collected from members at admission and + is expended by the Keeper of the Oil on tapers and matches. + + - id: art-8 + number: 8 + title: Records and Accounts + title_source: enacted + sections: + - id: art-8-s-1 + number: 1 + title: The Roll + title_source: editorial + content: | + The Keeper of the Roll enters in the Lantern Roll the name of every + lamplighter, the round assigned to them, and the date of their + admission. + - id: art-8-s-2 + number: 2 + title: Annual Statement + title_source: enacted + content: | + The Keeper of the Oil lays before the Guild at the Michaelmas + meeting a statement of the oil bought, burned and remaining. + + - id: art-9 + number: 9 + title: Amendment + title_source: enacted + content: | + 1. These articles may be amended only by an instrument passed under this + article. + 2. An amendment must be approved by the board, the intermediate board and + the units of the Guild. + 3. Approval requires two thirds of those present and voting in each of + those bodies, abstentions being counted with neither side. diff --git a/examples/starter/fixture-versions/v2.1.0.yaml b/examples/starter/fixture-versions/v2.1.0.yaml new file mode 100644 index 0000000..f1b2289 --- /dev/null +++ b/examples/starter/fixture-versions/v2.1.0.yaml @@ -0,0 +1,167 @@ +# --------------------------------------------------------------------------- +# The FIXTURE constitution at version 2.1.0, archived. +# +# Identical to examples/starter/fixture-constitution.yaml except for the two +# lines that say it is superseded. It exists so a fixture build can publish +# archive/2.1.0/constitution.json — the snapshot /propose/ needs to tell a +# stale edit that still fits from one whose target was rewritten underneath +# it. Without it, both look the same and every carried-over edit is a +# conflict. +# --------------------------------------------------------------------------- + +opencodelaw: "1.0" + +info: + title: MARROW VALE LAMPLIGHTERS + organization: The Marrow Vale Lamplighters' Guild, Incorporated + jurisdiction: Marrow Vale, Province of Thule + registration: GUILD/0042/1897 + + version: 2.1.0 + status: superseded + superseded_by: 2.2.0 + legal_status: adopted + effective_from: 2025-09-01 + + contact: + name: The Keeper of the Roll + email: keeper@marrowvalelamplighters.org + url: https://marrowvalelamplighters.org + license: CC-BY-4.0 + +preamble: + id: preamble + title: Preamble + title_source: editorial + content: | + We, the lamplighters of Marrow Vale, having kept the lamps of this town + alight through fog and frost since the founding, adopt these articles for + the ordering of the Guild. + adopted: 2025-09-01 + +articles: + - id: art-1 + number: 1 + title: Name + title_source: enacted + content: | + The Guild shall be known as the Marrow Vale Lamplighters' Guild, and in + these articles as "the Guild". + + # Untouched by the fixture bill, so a test has something to prove was left + # alone when the bill is applied. + - id: art-2 + number: 2 + title: Objects + title_source: enacted + content: | + The objects of the Guild are: + + 1. to keep every public lamp in Marrow Vale alight from dusk to dawn; + 2. to maintain the ladders, wicks, oil and glass in the Guild's keeping; + 3. to train apprentices in the safe handling of flame at height. + + # An article carrying both its own body and sections. The fixture bill + # substitutes this one in full, sections and all, which is what exercises + # the "complete resulting text" rule on a subdivided provision. + - id: art-3 + number: 3 + title: Membership + title_source: enacted + content: | + Membership of the Guild is open to any person of the Vale who keeps a + lamp and abides by these articles. + sections: + - id: art-3-s-1 + number: 1 + title: Admission + title_source: enacted + content: | + A person becomes a lamplighter on being entered in the Lantern Roll + by the Council. + - id: art-3-s-2 + number: 2 + title: Duties + title_source: enacted + content: | + A lamplighter shall light the lamps of their assigned round at dusk + and extinguish them at dawn. + - id: art-3-s-3 + number: 3 + title: Withdrawal + title_source: editorial + content: | + A lamplighter may withdraw by returning their taper to the Keeper of + the Roll. + + # A number held by no provision. The gap is recorded, never closed by + # renumbering the articles that follow it. + - id: art-4 + number: 4 + title: Reserved + title_source: editorial + status: reserved + note: Held for the article on night wardens, which the Guild has not yet adopted. + + - id: art-5 + number: 5 + title: The Council of Wicks + title_source: enacted + content: | + 1. The Council of Wicks consists of nine lamplighters elected by the + Guild at the Michaelmas meeting. + 2. The Council appoints from among its number a Keeper of the Roll and a + Keeper of the Oil. + + # Reserved by the fixture bill: the number is kept, the text goes. + - id: art-6 + number: 6 + title: Meetings of the Council + title_source: enacted + content: | + 1. The Council meets at the Lamp House on the first evening of each + quarter. + 2. Notice of a meeting is given by lighting the blue lamp above the Lamp + House door for three nights beforehand. + + # Omitted by the fixture bill. The number is never reused. + - id: art-7 + number: 7 + title: The Taper Fund + title_source: enacted + content: | + The Taper Fund holds the pennies collected from members at admission and + is expended by the Keeper of the Oil on tapers and matches. + + - id: art-8 + number: 8 + title: Records and Accounts + title_source: enacted + sections: + - id: art-8-s-1 + number: 1 + title: The Roll + title_source: editorial + content: | + The Keeper of the Roll enters in the Lantern Roll the name of every + lamplighter, the round assigned to them, and the date of their + admission. + - id: art-8-s-2 + number: 2 + title: Annual Statement + title_source: enacted + content: | + The Keeper of the Oil lays before the Guild at the Michaelmas + meeting a statement of the oil bought, burned and remaining. + + - id: art-9 + number: 9 + title: Amendment + title_source: enacted + content: | + 1. These articles may be amended only by an instrument passed under this + article. + 2. An amendment must be approved by the board, the intermediate board and + the units of the Guild. + 3. Approval requires two thirds of those present and voting in each of + those bodies, abstentions being counted with neither side. diff --git a/process/ADOPTION.md b/process/ADOPTION.md index c8f8f73..4b7651a 100644 --- a/process/ADOPTION.md +++ b/process/ADOPTION.md @@ -1,12 +1,13 @@ -# Adoption — turning `/propose/` on +# Adoption — turning `/propose/` and `/icc/` on `/bills/` is live. It is **record**, and an empty register is a true statement: *no bills are before the board* is information, not absence. -`/propose/` is built but **dark**. It is **action**, and an action surface opens when the desk behind -it is staffed. Its one actionable instruction is *email this file to the ICC*. Put that in front of -the public before the ICC can receive, and the system's first impression on its first real author is -silence. +`/propose/` and `/icc/` are built but **dark**, behind one flag, and they open together. They are +**action**, and an action surface opens when the desk behind it is staffed. The proposer's one +actionable instruction is *email this file to the ICC*. Put that in front of the public before the +ICC can receive, and the system's first impression on its first real author is silence. Opening the +proposal surface without the clerking desk is the same failure one step later. Everything below is checked off before it goes live. Then: @@ -56,8 +57,11 @@ Not a document — a conversation. It covers: - **The resolution sentence and the hash.** `bill validate` prints it; the presiding officer reads it into the minutes, hash and all. A vote binds to that hash. Edit the bill afterwards and the approvals are void — including when the bill's own operations were untouched. -- **The ballot sheets**: `bill ballot` renders one pre-filled sheet per body, so nobody composes a - legal record from scratch and the hash cannot be mistyped. +- **The ballot sheets**: one pre-filled sheet per body, so nobody composes a legal record from + scratch and the hash cannot be mistyped. `bill ballot` writes them, and so does the download on + `/icc/` — the same renderer, the same sheets. +- **The desk itself**: walk `/icc/` once, top to bottom. It is the whole job on one page, and it + writes the record rather than leaving anyone to compose one. - **Who runs `bill validate`** — the ICC, or the technical department. Decide it; do not leave it ambiguous. @@ -99,9 +103,9 @@ Do it against `examples/starter/fixture-constitution.yaml`, never `constitution/ ## When all four are checked -1. Set `PROPOSE_ENABLED: 'true'` in `.github/workflows/deploy.yml`. +1. Set `PROPOSE_ENABLED: 'true'` in `.github/workflows/deploy.yml`. Both pages open together. 2. Merge; wait for the deploy, and allow ten minutes for the edge cache. -3. Run the propose end-to-end against the **live** page — including hash parity between the live +3. Run the end-to-end suites against the **live** pages — including hash parity between the live page and the CLI, which is the check that caught the trailing-newline defect. 4. Announce that it is open. @@ -109,10 +113,11 @@ Do it against `examples/starter/fixture-constitution.yaml`, never `constitution/ ## A note for whoever reads this later -While `/propose/` is dark, authoring a bill by hand is fully supported and produces exactly the same -file: copy `bills/TEMPLATE.yaml`, edit it, run `npx opencodelaw bill validate`. The page is -convenience, not gatekeeping — it exists so an author cannot accidentally write a partial-text -operation, not because YAML is forbidden. +While the two pages are dark, authoring a bill by hand is fully supported and produces exactly the +same file: copy `bills/TEMPLATE.yaml`, edit it, run `npx opencodelaw bill validate`. See +`process/AUTHORING-BY-HAND.md`. The pages are convenience, not gatekeeping — the editor exists so an +author cannot accidentally propose a half-described change, and the desk so nobody composes a legal +record from scratch. Neither holds any authority: `act enact` re-derives every claim they make. **The tripwire's zero-permitted era ends the day the first real Act applies.** From then on the Act's manifest *is* the permission, which is what all of this was built for. A legitimate first diff diff --git a/process/AMENDMENT-PROCESS.md b/process/AMENDMENT-PROCESS.md index cf12c88..9b23210 100644 --- a/process/AMENDMENT-PROCESS.md +++ b/process/AMENDMENT-PROCESS.md @@ -5,8 +5,11 @@ approved, enacted, and applied to the published text. **Authority:** Article 16, as in force at constitution version 3.0.0. **The machine contract:** [`schema/opencodelaw-bill-1.0.schema.json`](../schema/opencodelaw-bill-1.0.schema.json). -**If you are writing one:** [`bills/TEMPLATE.yaml`](../bills/TEMPLATE.yaml) and the author's guide, -[`PROPOSING.md`](PROPOSING.md). +**If you want to propose one:** the propose page, and [`PROPOSING.md`](PROPOSING.md) — no terminal, +no YAML, no GitHub account. +**If you would rather write the file:** [`bills/TEMPLATE.yaml`](../bills/TEMPLATE.yaml) and +[`AUTHORING-BY-HAND.md`](AUTHORING-BY-HAND.md). +**If you are clerking one:** the ICC desk, and §2a below. A few words used throughout: the **ICC** is the Internal Compliance Committee, and the **ICC Coordinator** is its officer; the **IBM** is the intermediate board; a **bill** is a proposed @@ -48,7 +51,8 @@ dropped register offered could not be found anywhere in the three Acts. **The ICC — registry and clerk.** The ICC receives bills, assigns their numbers, verifies drafting with the validator, schedules the approval meetings of **all three** bodies, records each body's -tally and its evidence, and attests the enacted Act. +tally and its evidence, and attests the enacted Act. The desk at `/icc/` (§2a) does all of that in +one page, and generates the record rather than having anyone compose it by hand. > **The ICC checks form, not substance, and its attestation never substitutes for a body's > approval.** @@ -73,6 +77,36 @@ enacted. --- +## 2a. The two working surfaces + +Both sit behind the flag in [`ADOPTION.md`](ADOPTION.md) and open together. Everything either of +them does is also available from the command line, and neither of them has any authority the CLI +does not re-check. + +**`/propose/` — the document editor.** The author opens a copy of the constitution, changes the +words they want changed, and downloads their proposal. The operations are derived from the +difference: the author never picks a target, never types an id, and never sees the file format. +Re-opening a proposal replays it onto **today's** constitution, so a stale draft is re-made rather +than re-sent — which is why `rebase-required` should stop appearing at the ICC's desk. + +**`/icc/` — the clerking desk.** The Coordinator opens the author's file and works down the page in +the lifecycle's own order: validate on arrival, read the before-and-after, assign the number, record +the submission and scheduling dates, download the three ballot sheets, enter each body's tally with +the Article 16(3) verdict computing live, fingerprint each signed minutes PDF in the browser, and +generate one record to send on with the PDFs. + +**What the desk deliberately does not do: enact, or apply.** Assent, the signed instrument's +registration and the change to the constitution stay with the technical department's +`act enact` and `act apply`, because that is where every claim the desk produced is independently +re-verified — evidence files present on disk and matching their checksums, thresholds recomputed +from the tallies, the bill's hash checked against every approval that cites it. A hand-forged record +fails at that gate exactly as it would if the desk had never been built. + +Neither page uploads anything. Files opened on either are read in the browser and never leave it; +what travels is what the user downloads and sends. + +--- + ## 3. Lifecycle ``` @@ -213,6 +247,28 @@ This is the cheap prevention for everything above. CI enforces the hard edge of amending the same provision fail the gate, naming both, because whichever applies second would overwrite or contradict the first. +## 5b. Nothing is ever deleted + +A provision that is removed keeps its node, its number and its anchor. It gains a status — +`omitted`, or `reserved` where the number is being held open — and the instrument that did it. +This is the same rule the numbering has: an article number is a permanent citation handle, and a +citation that stops resolving is a citation that was destroyed. **Deletion has no representation in +this system at any depth**, article or clause. + +Two consequences worth stating, because both were once wrong: + +- **A substitution accounts for every clause of its target.** Each one is either restated with its + text or carried as a tombstone (`status: omitted`). Silence over a clause is refused — + `incomplete-substitution` — because silence used to mean deletion. A meeting must be able to read + exactly what dies, so the instrument and the ballot sheets state omissions in words: + *clause (3) is omitted*. +- **Revival is explicit.** A clause or article that an earlier Act omitted, or a number held + reserved, comes back only through an operation that states its text. Carrying the tombstone is the + default, which is what makes an article with a dead clause amendable at all: restate the living, + carry the dead. + +--- + ## 6. What belongs to the by-laws — Article 16(2) Article 16(2) sends the conduct of amendments to the by-laws. So the by-laws own **notice periods, diff --git a/process/AUTHORING-BY-HAND.md b/process/AUTHORING-BY-HAND.md new file mode 100644 index 0000000..c475a07 --- /dev/null +++ b/process/AUTHORING-BY-HAND.md @@ -0,0 +1,422 @@ +# Authoring a bill by hand + +For the ICC, the technical department, and anyone who would rather write the file than +use the page. This path is fully supported and produces exactly the same bill as +[the propose page](https://constitution.stmorg.in/propose/) — the page is convenience, +not gatekeeping. + +**If you are a member who wants to change the constitution, you want +[PROPOSING.md](PROPOSING.md) instead.** It needs no terminal, no YAML and no GitHub +account, and it will take you ten minutes. Nothing on this page is required of you. + +Governance — who votes, when, and what happens after the bill is handed in — is in +[AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md). This page is only about writing the thing. + +--- + +## 1. What a bill is + +A **bill** is one file that says exactly what the constitution should say after your +change — not a description of the change, the change itself. If all three bodies named +in Article 16(3) approve it, that same file is signed as an **Act** and applied to the +constitution by a tool, not by a person with a keyboard. So the file you write is the +law you are proposing, and everything else — the printed Act, the diff the meetings +read, the amended constitution — is generated from it. + +That inversion is the whole point. The 2024 Acts were written as prose and typed into +the constitution by hand, which left provisions half-applied, an application nobody +recorded, and fourteen open questions in [RECONCILIATION.md](../RECONCILIATION.md). +Your bill cannot do that, because it is machine-applicable from the moment you save it. + +--- + +## 2. Copy the template + +If you have the repository already, skip to the two `cp` lines. If not, and you have a +terminal: + +```bash +git clone https://github.com/ServiceToMankind/OpenCodeLaw.git +cd OpenCodeLaw +npm ci +``` + +Then copy the template into a folder named for the year you are drafting in: + +```bash +mkdir -p bills/2026 +cp bills/TEMPLATE.yaml bills/2026/refreshments.yaml +``` + +- Put the file in `bills//`. Nothing else goes there. +- Name it after the subject, in lowercase with hyphens — `refreshments.yaml`, + `unit-finance.yaml`. Not after yourself, and not `final-v2-FINAL.yaml`. +- **Your bill file is the only file you touch.** Never edit anything under + `constitution/` or `acts/`. Editing the constitution directly is exactly what this + process exists to make impossible. + +**Never used a terminal?** Two honest options. Ask the ICC or any repo contributor to +run the validator for you and send you the output — that is normal and nobody minds. +Or install [Node.js](https://nodejs.org) 20 or newer and run the three commands above +once; after that, the only command you ever need is the one in section 5. + +Open the copied file in any plain text editor. It is a `.yaml` file, which means three +rules and no more: + +- **Indentation is structure.** Keep the leading spaces exactly as you found them. +- **`~` means "empty"**, and several fields are deliberately empty. Leave them. +- **`|` starts a block of text.** Everything indented under it is your text, and you + can write as many lines and paragraphs as you like. + +The template is commented line by line. It is worth reading before you type anything. + +--- + +## 3. Say who you are and what you are changing + +At the top of the file: + +| Field | What to put | +|---|---| +| `short_title` | How the Act will be titled: "An Act to ..." | +| `also_known_as` | Optional familiar name, the way Act 1 of 2024 called itself the "Membership Act, 2024" | +| `year` | The year you are drafting in | +| `number` | Leave it `~`. The ICC assigns a bill number at submission — "Bill 1 of 2026" — and a draft that carries a number is a draft pretending to be before someone | +| `type` | `amendment` unless you know otherwise. `corrigendum` only fixes a drafting error already recorded against an Act; `revision` re-adopts the whole constitution and is not something you will draft casually | +| `moved_by` | Your name, your role, your contact | +| `drafted` | Today's date, as `2026-01-15` | +| `base_version` | The version printed on the constitution's front page. **Today that is `3.0.0`** | +| `version_bump` | `minor` for an amendment. `major` is reserved for a `revision` | +| `status` | Leave it `draft`. The pipeline moves it; you do not | + +`moved_by` is recorded now, at drafting, and never reconstructed later. All three 2024 +Acts reached us with no reliable record of who moved them, and one register named an +author that appears nowhere in any of the instruments. That question is still open. It +will not be asked about your bill. + +**`objects_and_reasons`** is where you explain yourself, in numbered sentences. It is +printed at the end of the Act and it is **explanatory only** — it can never be cited as +the authority for anything, and the validator refuses any operation that tries. This is +not a technicality: Act 2 of 2024 set the amendment threshold at 2/3 in its operative +text and 3/4 in its statement of reasons, and the disagreement is still on the books as +Q3. Put your reasoning here; put your law in `operations`. + +**Finding the id of the thing you are changing.** Every provision has a permanent id. +Articles are `art-3`, `art-16`, `art-21`. A numbered subdivision inside an article is +`art-14-s-1`, `art-15-s-2`. The id is in the address bar when you open that provision on +the site, and in `constitution/current.yaml`. Use the id, not the heading — headings +change, ids are permanent citation handles and never move. + +--- + +## 4. The one rule that matters + +**Every operation carries the complete resulting text of the provision.** + +Not a diff. Not "insert after the words". Not "delete the third sentence". Write the +provision out in full, exactly as it should read once your Act has been applied — +including the parts you are not changing. + +Why: because application is then a comparison rather than a transcription, so applying +an Act twice changes nothing the second time, and an Act written against text that has +since moved refuses to apply instead of quietly corrupting it. + +Wrong — this is a description of a change, and nothing can safely apply it: + +```yaml + text: | + In clause (2), after "one week", insert "excluding public holidays". +``` + +Right — this is the provision: + +```yaml + text: | + 1. Notice of a general meeting shall be given not less than one week + before the meeting. + 2. The period of notice shall be one week, excluding public holidays. +``` + +Three smaller rules follow from the big one: + +- **`title:`** — set it only if your Act actually states a heading for the provision. If + you set it, that heading is recorded as enacted. If you leave it out, the existing + heading stays as it is and stays editorial. +- **`sections:`** — **every clause of the provision must be accounted for.** Each one is + either restated with its `number`, `title` and `text`, or carried as a tombstone: + + ```yaml + sections: + - number: 1 + title: Admission + text: | + The complete text of clause (1), unchanged. + - number: 2 + title: Duties + status: omitted # this Act ends clause (2) + note: Duties pass to the by-laws. + - number: 3 + title: Withdrawal + text: | + The complete text of clause (3). + ``` + + Leaving a clause out is refused (`incomplete-substitution`), and not as a matter of + taste. Silence used to mean deletion: the clause's node vanished, its number was free + to be reused, and `art-6-s-2` in somebody's minutes stopped resolving with nothing in + the document to say the clause had ever existed. **Nothing is ever deleted, at any + depth.** An omitted clause keeps its node, its number and its anchor. + + A clause that was omitted by an earlier Act stays carried as a tombstone in every later + substitution of its article. It comes back only through an entry that states its text — + revival is explicit. + + If you are changing only one clause, target the clause itself — `art-6-s-2`, scope + `clause` — and the question does not arise. To remove one clause and nothing else, use + `omit` on that clause. +- **`omit` and `reserve`** carry no text at all — they remove or park a provision — and + both require a `note` saying why. They work at any depth: `omit` on `art-6-s-2` ends + that clause and leaves its number standing. + +There is no `renumber`. Article numbers are permanent: every Act, every set of minutes +and every link anyone has ever shared points at them. Renumbering is lawful only inside +a `revision`, and the validator will say so if you try. + +--- + +## 5. Validate, and read your own diff + +```bash +npx opencodelaw bill validate bills/2026/refreshments.yaml +``` + +Run it as often as you like — after every edit, if you want. It checks your drafting and +then prints, for each operation, what the provision says now and what it would say +afterwards. + +**Read that diff. It is what the approval meetings will read.** Not your explanation, not +what you meant, not what you told the meeting last week — that output. If it does not say +what you intended, the meeting will vote on what it says, so fix it now while fixing it +costs nothing. + +The terminal shows the first hundred characters of each side, so for anything longer, +open your `text:` block next to the live provision on the site and read both through. +An hour with your own diff at draft stage is worth more than every review afterwards. + +`ERROR` lines must be fixed before you submit. `warn` lines will not stop you, but read +them — `no-op`, for instance, means the text you proposed is identical to the text that +is already there, which usually means you edited the wrong copy. + +--- + +## 6. What the validator will tell you off for + +| It says | It means | Do this | +|---|---|---| +| `rebase-required` | Your `base_version` is older than the constitution. Something else was applied while you were drafting | Re-read each of your operations against the **current** text, fold in anything that changed, update `base_version`, re-validate. Nobody may vote on text that no longer exists | +| `target-unresolved` | The provision id you named does not exist | Check the id on the site. If the provision genuinely is new, the operation is `insert` | +| `insert-exists` | You said `insert`, but that provision already exists | Use `substitute` to replace its text, or `retitle` for the heading alone | +| `renumber-forbidden` | You tried to move a provision's number | You cannot, in an amendment. Numbers are permanent citation handles; only a `revision` may move them, with a major bump and a map of where everything went | +| `numbered-draft` | Your draft carries a bill number | Set `number: ~`. Only the ICC assigns numbers, and only at submission | +| `unnumbered-bill` | The status has moved past `draft` but no number was assigned | The ICC's to fix, not yours | +| `sor-as-authority` | An operation leans on the statement of objects and reasons | Write the rule into the operation's own text. The statement explains; it never enacts | +| `corrigendum-scope` | A `corrigendum` aimed at something that is not a recorded drafting error | If the change is substantive — and it almost always is — it needs an `amendment` bill | +| `duplicate-op` | Two operations share an `id` | Number them `op-1`, `op-2`, `op-3` | + +The approval and enactment errors — missing bodies, missing minutes, below threshold — +are the ICC's problem, not yours. You will not see them on a draft. + +--- + +### Before you circulate — the text freezes + +Once your bill goes to the approving bodies, its text is frozen. A vote binds to the bill's +**substantive hash** — printed by `bill validate` and read into the minutes — not to its title. Edit +the bill afterwards and every recorded approval is void; those bodies must meet again. + +That is true even for a typo, and even when a rebase leaves your own operations untouched. **Get the +text right before you circulate.** + +Also worth knowing: any command that writes your bill strips comments. Notes for the record go in +`objects_and_reasons` or an operation's `note`. + +## 7. Submitting + +Send the file to the **Internal Compliance Coordinator**, who clerks it at +[/icc/](https://constitution.stmorg.in/icc/). If you do not know who holds +that office this year, ask at `pranay@stmorg.in`, the contact of record in the +constitution. Send the validator output with it; a bill that has never been validated +will come straight back. + +What happens next, briefly: + +1. The ICC numbers it — "Bill 1 of 2026" — and the status becomes `submitted`, then + `under-review`. A bill can be **returned** to you for redrafting and come back, as + many times as it takes. +2. Once it is `scheduled`, it goes to all three bodies Article 16(3) names: the board, + the intermediate board, and the units. **All three. Every time.** The 2024 Acts + recorded only the ICC's assent, and closing that gap permanently is why this pipeline + exists. +3. Each body's vote is recorded — present, for, against, abstain, and a reference to the + minutes. Abstentions do not count towards the threshold, because Article 16(3) says + "present and voting". +4. The threshold is two thirds. Article 16(3) says the three bodies approve + "collectively", and that word genuinely bears two readings: one pooled vote of + everyone sitting together, or two thirds inside each body separately. **Until the + board settles it by resolution, the stricter reading governs — two thirds in each + body.** Both tallies are recorded either way, so the Act stands under whichever + reading the board eventually adopts. +5. Approved, it is enacted: an Act number for the year, assent, a signed PDF rendered + from your file and checksummed against it. Then it is applied, and the constitution's + minor version goes up. + +You do not fill in `approvals` or `enactment`. Leave them exactly as the template has +them. The full procedure is in [AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md). + +You may withdraw your own bill at any time before it is approved. + +--- + +## 8. A fully worked example + +> ### This example is fiction. +> +> It amends the constitution of the **Worked Example Society for the Study of Nothing in +> Particular**, an organisation that does not exist, whose Article 99 concerns tea and +> biscuits. **It is not STM law, no part of it is, and none of its text belongs anywhere +> near a real bill.** Copy its *shape*. Never its words. +> +> It is written against the Example Society's own constitution version `7.2.0`, so +> running the validator on it inside this repository will correctly complain that +> `art-99` does not exist and that the base version is stale. That is the validator +> working, not a mistake in the example. + +`bills/2026/refreshments.yaml`: + +```yaml +opencodelaw_bill: "1.0" + +bill: + short_title: An Act to provide for refreshments at general meetings + also_known_as: Refreshments Act, 2026 + year: 2026 + number: ~ + type: amendment + + moved_by: + name: A. Coordinator + role: Unit Head, Example Unit + contact: a.coordinator@example.invalid + + drafted: 2026-01-15 + base_version: "7.2.0" + version_bump: minor + +status: draft + +history: [] + +objects_and_reasons: | + 1. Article 99 presently provides for tea only, and is silent on whether anything + may be eaten with it. The article is substituted to settle the question and to + name who is responsible. + 2. Article 100 is inserted to establish a rota, so that responsibility for + refreshments does not fall on whoever arrives first. + +operations: + - id: op-1 + operation: substitute + target: art-99 + scope: article + title: Refreshments and Catering + text: | + 1. Tea shall be provided at every general meeting of the Society. + 2. Coffee shall be provided at every general meeting of the Society, and no + member may be required to state a preference in advance. + 3. Each member present shall be entitled to not fewer than two biscuits. + 4. The Refreshments Secretary is responsible for the provision of refreshments + under this Article and shall report on it at the annual general meeting. + + - id: op-2 + operation: insert + target: art-100 + scope: article + title: Tea Rota + text: | + 1. The Refreshments Secretary shall maintain a rota of members responsible for + refreshments at each general meeting. + 2. The rota shall be published not less than one week before the meeting to + which it relates. + 3. A member named on the rota who is unable to attend shall arrange a + substitute and inform the Refreshments Secretary in writing. + +approvals: + - body: board + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ + - body: intermediate-board + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ + - body: units + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ + +enactment: + act_number: ~ + act_year: ~ + assent_date: ~ + assented_by: ~ + signed_by: ~ + signed_pdf: ~ + signed_pdf_sha256: ~ +``` + +Validated against the Example Society's own constitution, that bill prints roughly this +— and *this* is the thing three meetings will read: + +``` +Bill: An Act to provide for refreshments at general meetings + unnumbered draft · amendment · status draft + moved by A. Coordinator · against constitution 7.2.0 + +Operations (2): + op-1 substitute art-99 (article) + title: "Refreshments" → "Refreshments and Catering" + before: 1. Tea shall be provided at every general meeting of the Society. + after : 1. Tea shall be provided at every general meeting of the Society. 2. Coffee shall be prov… + op-2 insert art-100 (article) + title: null → "Tea Rota" + after : 1. The Refreshments Secretary shall maintain a rota of members responsible for refreshme… + +Approvals — Article 16(3) requires all three bodies: + board not recorded + intermediate-board not recorded + units not recorded + +OK — 0 warning(s) +``` + +Notice what op-1 does and does not say. It does not say "add coffee and biscuits to +Article 99". It sets out Article 99 entire — tea included, unchanged — because the +operation's text *is* the article afterwards. That is the rule in section 4, and it is +the only one you have to get right. + +--- + +**See also:** [AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md) (governance and lifecycle) · +[`bills/TEMPLATE.yaml`](../bills/TEMPLATE.yaml) (the commented template) · +[`schema/opencodelaw-bill-1.0.schema.json`](../schema/opencodelaw-bill-1.0.schema.json) +(every field, authoritatively) · [RECONCILIATION.md](../RECONCILIATION.md) (why this +process is shaped the way it is). diff --git a/process/PROPOSING.md b/process/PROPOSING.md index 73546bc..98a1edb 100644 --- a/process/PROPOSING.md +++ b/process/PROPOSING.md @@ -1,386 +1,181 @@ -# Proposing an amendment +# Proposing a change to the constitution -For anyone in STM who wants to change the constitution. It assumes you have never -written YAML and are not sure what a terminal is. It does not assume you are slow — -read it once, straight through, and you will be able to draft a bill. +For anyone in STM who wants the constitution to say something different. -Governance — who votes, when, and what happens after you hand it in — is in +You do not need a GitHub account, a text editor, or any technical knowledge at all. If +you can read the constitution and type, you can propose a change to it. + +Governance — who votes, when, and what happens after you send it in — is in [AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md). This page is only about writing the thing. --- -## 1. What a bill is +## The whole of it, in four steps -A **bill** is one file that says exactly what the constitution should say after your -change — not a description of the change, the change itself. If all three bodies named -in Article 16(3) approve it, that same file is signed as an **Act** and applied to the -constitution by a tool, not by a person with a keyboard. So the file you write is the -law you are proposing, and everything else — the printed Act, the diff the meetings -read, the amended constitution — is generated from it. +1. **Open** [constitution.stmorg.in/propose/](https://constitution.stmorg.in/propose/). +2. **Change** the words you want changed. The page shows you the constitution as it + stands, with every provision editable in place. +3. **Download** your proposal when you are done. Two files come down: your proposal, and + a readable copy of how it would look as an Act. +4. **Email both** to the Internal Compliance Committee at `pranay@stmorg.in`. -That inversion is the whole point. The 2024 Acts were written as prose and typed into -the constitution by hand, which left provisions half-applied, an application nobody -recorded, and fourteen open questions in [RECONCILIATION.md](../RECONCILIATION.md). -Your bill cannot do that, because it is machine-applicable from the moment you save it. +That is the entire process on your side. Everything below is detail you can read when +you need it, not before. --- -## 2. Copy the template - -If you have the repository already, skip to the two `cp` lines. If not, and you have a -terminal: +## What you are actually doing -```bash -git clone https://github.com/ServiceToMankind/OpenCodeLaw.git -cd OpenCodeLaw -npm ci -``` +The page gives you **a copy of the constitution in your own browser**. Nothing you type +there changes the real one — you could delete every article on the screen and the +constitution would be untouched. -Then copy the template into a folder named for the year you are drafting in: +When you press Generate, the page works out what you changed and writes it down +precisely: which provisions you touched, and exactly what each one would say afterwards. +That written-down form is your proposal. If all three bodies named in Article 16(3) +approve it, that same file becomes the Act and is applied to the constitution by a tool, +not by a person retyping it. -```bash -mkdir -p bills/2026 -cp bills/TEMPLATE.yaml bills/2026/refreshments.yaml -``` +That matters more than it sounds. The three Amendment Acts of 2024 were written as prose +and typed into the constitution by hand. That left provisions half-applied, one +application nobody recorded, and fourteen open questions in +[RECONCILIATION.md](../RECONCILIATION.md). Your proposal cannot do that, because the +words you typed are the words that get applied. -- Put the file in `bills//`. Nothing else goes there. -- Name it after the subject, in lowercase with hyphens — `refreshments.yaml`, - `unit-finance.yaml`. Not after yourself, and not `final-v2-FINAL.yaml`. -- **Your bill file is the only file you touch.** Never edit anything under - `constitution/` or `acts/`. Editing the constitution directly is exactly what this - process exists to make impossible. +--- -**Never used a terminal?** Two honest options. Ask the ICC or any repo contributor to -run the validator for you and send you the output — that is normal and nobody minds. -Or install [Node.js](https://nodejs.org) 20 or newer and run the three commands above -once; after that, the only command you ever need is the one in section 5. +## Changing things -Open the copied file in any plain text editor. It is a `.yaml` file, which means three -rules and no more: +**Editing text.** Every provision on the page is a box holding what it says now. Change +it to what you want it to say. Write the whole provision, including the parts you are +not changing — that is what the box already contains, so as long as you edit rather than +retype, this takes care of itself. -- **Indentation is structure.** Keep the leading spaces exactly as you found them. -- **`~` means "empty"**, and several fields are deliberately empty. Leave them. -- **`|` starts a block of text.** Everything indented under it is your text, and you - can write as many lines and paragraphs as you like. +**Changing a heading.** The heading of each provision is editable too. If you change it, +your proposal states that heading; if you leave it, the existing one stays as it is. -The template is commented line by line. It is worth reading before you type anything. +**Adding an article or a clause.** Use the buttons. **The number is assigned for you** — +the next free one, or a number that was deliberately reserved. There is nowhere to type +a number, because a number is how the constitution is cited: in Acts, in minutes, in +links people have already sent each other. Choosing one yourself would break somebody's +citation. ---- +**Removing an article or a clause.** There is a button, and it asks you to confirm, +showing you exactly what would disappear. **What you remove keeps its number.** It is +marked as removed rather than deleted, so every citation ever made to it still resolves, +and nothing after it shifts up — an article, a clause, the same rule at both levels. You +must say why: a provision should not leave a constitution with nobody having given a +reason, and the words you write are printed in the Act. -## 3. Say who you are and what you are changing - -At the top of the file: - -| Field | What to put | -|---|---| -| `short_title` | How the Act will be titled: "An Act to ..." | -| `also_known_as` | Optional familiar name, the way Act 1 of 2024 called itself the "Membership Act, 2024" | -| `year` | The year you are drafting in | -| `number` | Leave it `~`. The ICC assigns a bill number at submission — "Bill 1 of 2026" — and a draft that carries a number is a draft pretending to be before someone | -| `type` | `amendment` unless you know otherwise. `corrigendum` only fixes a drafting error already recorded against an Act; `revision` re-adopts the whole constitution and is not something you will draft casually | -| `moved_by` | Your name, your role, your contact | -| `drafted` | Today's date, as `2026-01-15` | -| `base_version` | The version printed on the constitution's front page. **Today that is `3.0.0`** | -| `version_bump` | `minor` for an amendment. `major` is reserved for a `revision` | -| `status` | Leave it `draft`. The pipeline moves it; you do not | - -`moved_by` is recorded now, at drafting, and never reconstructed later. All three 2024 -Acts reached us with no reliable record of who moved them, and one register named an -author that appears nowhere in any of the instruments. That question is still open. It -will not be asked about your bill. - -**`objects_and_reasons`** is where you explain yourself, in numbered sentences. It is -printed at the end of the Act and it is **explanatory only** — it can never be cited as -the authority for anything, and the validator refuses any operation that tries. This is -not a technicality: Act 2 of 2024 set the amendment threshold at 2/3 in its operative -text and 3/4 in its statement of reasons, and the disagreement is still on the books as -Q3. Put your reasoning here; put your law in `operations`. - -**Finding the id of the thing you are changing.** Every provision has a permanent id. -Articles are `art-3`, `art-16`, `art-21`. A numbered subdivision inside an article is -`art-14-s-1`, `art-15-s-2`. The id is in the address bar when you open that provision on -the site, and in `constitution/current.yaml`. Use the id, not the heading — headings -change, ids are permanent citation handles and never move. +**Renumbering and reordering.** There is no control for these, anywhere. An ordinary +amendment never moves a number. --- -## 4. The one rule that matters +## The review, before you send -**Every operation carries the complete resulting text of the provision.** +At the bottom of the page you will find **"You are proposing N changes"** and, under it, +each change with what the provision says now and what it would say afterwards. -Not a diff. Not "insert after the words". Not "delete the third sentence". Write the -provision out in full, exactly as it should read once your Act has been applied — -including the parts you are not changing. +**Read that.** It is the same before-and-after the ICC will read, and the same one the +three meetings will vote on. Not your explanation, not what you meant, not what you said +in a meeting last week — that. If it does not say what you intended, fix it now, while +fixing it costs nothing. -Why: because application is then a comparison rather than a transcription, so applying -an Act twice changes nothing the second time, and an Act written against text that has -since moved refuses to apply instead of quietly corrupting it. +You will also be asked for two things: -Wrong — this is a description of a change, and nothing can safely apply it: +- **Who you are** — your name, role and membership number. Your name is recorded in the + proposal permanently, from the moment you draft it. The membership number is recorded + too, and the ICC checks it against the register when your proposal arrives; nothing on + the page can verify it. +- **Your explanation, in plain words.** Write it for someone who has not read the + constitution today. It is printed at the end of the Act as the *Statement of Objects + and Reasons* — it explains, and it can never be cited as authority for anything. If + the rule matters, it belongs in the provision you edited, not here. -```yaml - text: | - In clause (2), after "one week", insert "excluding public holidays". -``` - -Right — this is the provision: +--- -```yaml - text: | - 1. Notice of a general meeting shall be given not less than one week - before the meeting. - 2. The period of notice shall be one week, excluding public holidays. -``` +## The reference number -Three smaller rules follow from the big one: +The page shows a long **reference number** for your proposal, and it changes whenever +your proposal changes. -- **`title:`** — set it only if your Act actually states a heading for the provision. If - you set it, that heading is recorded as enacted. If you leave it out, the existing - heading stays as it is and stays editorial. -- **`sections:`** — if the provision has titled subdivisions and you are restructuring - them, list them all, each with its `number`, `title` and `text` in full. Leaving - `sections` out leaves the existing subdivisions untouched. -- **`omit` and `reserve`** carry no text at all — they remove or park a provision — and - both require a `note` saying why. +That number is what the three bodies vote on. Each presiding officer reads it into the +minutes. If the proposal is edited after a vote — even a typo, even a change to a +different provision entirely — the number changes, every approval already given is void, +and those bodies have to meet again. -There is no `renumber`. Article numbers are permanent: every Act, every set of minutes -and every link anyone has ever shared points at them. Renumbering is lawful only inside -a `revision`, and the validator will say so if you try. +So: **get it right before it goes out.** Once the ICC circulates your proposal to the +bodies, the text is frozen. --- -## 5. Validate, and read your own diff +## Coming back to it later -```bash -npx opencodelaw bill validate bills/2026/refreshments.yaml -``` +Your work is saved in your browser as you type, so a closed tab does not lose it. It is +saved **only there** — the file you download is your real copy. On a shared or public +computer, use "Clear the copy saved in this browser" when you are finished: what is +saved carries your name and membership number. -Run it as often as you like — after every edit, if you want. It checks your drafting and -then prints, for each operation, what the provision says now and what it would say -afterwards. +To carry on later, open the page again and use **"Continue a proposal you started"**. -**Read that diff. It is what the approval meetings will read.** Not your explanation, not -what you meant, not what you told the meeting last week — that output. If it does not say -what you intended, the meeting will vote on what it says, so fix it now while fixing it -costs nothing. +Something worth knowing about that. The page never re-opens your old text as you left +it. It loads **today's** constitution and re-makes your changes against it, one at a +time, and tells you what it found: -The terminal shows the first hundred characters of each side, so for anything longer, -open your `text:` block next to the live provision on the site and read both through. -An hour with your own diff at draft stage is worth more than every review afterwards. +- *Carried over* — nobody touched that provision, so your change still applies as + written. +- *No longer needed* — the provision already reads the way you proposed. Somebody else's + Act made that change while your proposal was waiting, so yours is dropped. +- *Needs re-making* — the provision was rewritten while you waited. You will see the + current words beside your version, and you make the change again against what is + actually there. -`ERROR` lines must be fixed before you submit. `warn` lines will not stop you, but read -them — `no-op`, for instance, means the text you proposed is identical to the text that -is already there, which usually means you edited the wrong copy. +Nothing is carried over silently. This is why you never have to check whether your +proposal is out of date: a proposal based on the current constitution is the only thing +the page can produce. --- -## 6. What the validator will tell you off for +## After you send it -| It says | It means | Do this | -|---|---|---| -| `rebase-required` | Your `base_version` is older than the constitution. Something else was applied while you were drafting | Re-read each of your operations against the **current** text, fold in anything that changed, update `base_version`, re-validate. Nobody may vote on text that no longer exists | -| `target-unresolved` | The provision id you named does not exist | Check the id on the site. If the provision genuinely is new, the operation is `insert` | -| `insert-exists` | You said `insert`, but that provision already exists | Use `substitute` to replace its text, or `retitle` for the heading alone | -| `renumber-forbidden` | You tried to move a provision's number | You cannot, in an amendment. Numbers are permanent citation handles; only a `revision` may move them, with a major bump and a map of where everything went | -| `numbered-draft` | Your draft carries a bill number | Set `number: ~`. Only the ICC assigns numbers, and only at submission | -| `unnumbered-bill` | The status has moved past `draft` but no number was assigned | The ICC's to fix, not yours | -| `sor-as-authority` | An operation leans on the statement of objects and reasons | Write the rule into the operation's own text. The statement explains; it never enacts | -| `corrigendum-scope` | A `corrigendum` aimed at something that is not a recorded drafting error | If the change is substantive — and it almost always is — it needs an `amendment` bill | -| `duplicate-op` | Two operations share an `id` | Number them `op-1`, `op-2`, `op-3` | +1. The ICC checks its drafting and gives it a number — "Bill 1 of 2026". +2. It goes to all three bodies Article 16(3) names: the board, the intermediate board, + and the units. **All three. Every time.** The 2024 Acts recorded only the ICC's + assent, and closing that gap permanently is why this process exists. +3. Each body votes. The threshold is two thirds of those **present and voting** — + abstentions are not counted on either side. +4. Article 16(3) says the three bodies approve "collectively", and that word genuinely + bears two readings: one pooled vote of everyone together, or two thirds inside each + body separately. **Until the board settles it by resolution, the stricter reading + governs** — two thirds in each body. Both tallies are recorded either way. +5. Approved, it is enacted and applied, and the constitution's version goes up. -The approval and enactment errors — missing bodies, missing minutes, below threshold — -are the ICC's problem, not yours. You will not see them on a draft. - ---- +You may withdraw your own proposal at any time before it is approved. -### Before you circulate — the text freezes +If your proposal comes back to you for redrafting, that is ordinary and it can happen as +many times as it takes. Open it again on the page, change what needs changing, and send +it back. -Once your bill goes to the approving bodies, its text is frozen. A vote binds to the bill's -**substantive hash** — printed by `bill validate` and read into the minutes — not to its title. Edit -the bill afterwards and every recorded approval is void; those bodies must meet again. - -That is true even for a typo, and even when a rebase leaves your own operations untouched. **Get the -text right before you circulate.** - -Also worth knowing: any command that writes your bill strips comments. Notes for the record go in -`objects_and_reasons` or an operation's `note`. - -## 7. Submitting - -Send the file to the **Internal Compliance Coordinator**. If you do not know who holds -that office this year, ask at `pranay@stmorg.in`, the contact of record in the -constitution. Send the validator output with it; a bill that has never been validated -will come straight back. - -What happens next, briefly: +--- -1. The ICC numbers it — "Bill 1 of 2026" — and the status becomes `submitted`, then - `under-review`. A bill can be **returned** to you for redrafting and come back, as - many times as it takes. -2. Once it is `scheduled`, it goes to all three bodies Article 16(3) names: the board, - the intermediate board, and the units. **All three. Every time.** The 2024 Acts - recorded only the ICC's assent, and closing that gap permanently is why this pipeline - exists. -3. Each body's vote is recorded — present, for, against, abstain, and a reference to the - minutes. Abstentions do not count towards the threshold, because Article 16(3) says - "present and voting". -4. The threshold is two thirds. Article 16(3) says the three bodies approve - "collectively", and that word genuinely bears two readings: one pooled vote of - everyone sitting together, or two thirds inside each body separately. **Until the - board settles it by resolution, the stricter reading governs — two thirds in each - body.** Both tallies are recorded either way, so the Act stands under whichever - reading the board eventually adopts. -5. Approved, it is enacted: an Act number for the year, assent, a signed PDF rendered - from your file and checksummed against it. Then it is applied, and the constitution's - minor version goes up. +## If you would rather not use the page -You do not fill in `approvals` or `enactment`. Leave them exactly as the template has -them. The full procedure is in [AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md). +You do not have to. A proposal written in an email to the ICC is perfectly valid — they +will draft it into a bill with you, and your name goes on it as the mover. -You may withdraw your own bill at any time before it is approved. +And if you are technical and would prefer to write the file yourself, that path is fully +supported and produces exactly the same thing: +[AUTHORING-BY-HAND.md](AUTHORING-BY-HAND.md). The page is convenience, not gatekeeping — +it exists so that nobody can accidentally propose a half-described change, not because +the file format is forbidden. --- -## 8. A fully worked example - -> ### This example is fiction. -> -> It amends the constitution of the **Worked Example Society for the Study of Nothing in -> Particular**, an organisation that does not exist, whose Article 99 concerns tea and -> biscuits. **It is not STM law, no part of it is, and none of its text belongs anywhere -> near a real bill.** Copy its *shape*. Never its words. -> -> It is written against the Example Society's own constitution version `7.2.0`, so -> running the validator on it inside this repository will correctly complain that -> `art-99` does not exist and that the base version is stale. That is the validator -> working, not a mistake in the example. - -`bills/2026/refreshments.yaml`: - -```yaml -opencodelaw_bill: "1.0" - -bill: - short_title: An Act to provide for refreshments at general meetings - also_known_as: Refreshments Act, 2026 - year: 2026 - number: ~ - type: amendment - - moved_by: - name: A. Coordinator - role: Unit Head, Example Unit - contact: a.coordinator@example.invalid - - drafted: 2026-01-15 - base_version: "7.2.0" - version_bump: minor - -status: draft - -history: [] - -objects_and_reasons: | - 1. Article 99 presently provides for tea only, and is silent on whether anything - may be eaten with it. The article is substituted to settle the question and to - name who is responsible. - 2. Article 100 is inserted to establish a rota, so that responsibility for - refreshments does not fall on whoever arrives first. - -operations: - - id: op-1 - operation: substitute - target: art-99 - scope: article - title: Refreshments and Catering - text: | - 1. Tea shall be provided at every general meeting of the Society. - 2. Coffee shall be provided at every general meeting of the Society, and no - member may be required to state a preference in advance. - 3. Each member present shall be entitled to not fewer than two biscuits. - 4. The Refreshments Secretary is responsible for the provision of refreshments - under this Article and shall report on it at the annual general meeting. - - - id: op-2 - operation: insert - target: art-100 - scope: article - title: Tea Rota - text: | - 1. The Refreshments Secretary shall maintain a rota of members responsible for - refreshments at each general meeting. - 2. The rota shall be published not less than one week before the meeting to - which it relates. - 3. A member named on the rota who is unable to attend shall arrange a - substitute and inform the Refreshments Secretary in writing. - -approvals: - - body: board - date: ~ - present: ~ - for: ~ - against: ~ - abstain: ~ - evidence: ~ - - body: intermediate-board - date: ~ - present: ~ - for: ~ - against: ~ - abstain: ~ - evidence: ~ - - body: units - date: ~ - present: ~ - for: ~ - against: ~ - abstain: ~ - evidence: ~ - -enactment: - act_number: ~ - act_year: ~ - assent_date: ~ - assented_by: ~ - signed_by: ~ - signed_pdf: ~ - signed_pdf_sha256: ~ -``` - -Validated against the Example Society's own constitution, that bill prints roughly this -— and *this* is the thing three meetings will read: - -``` -Bill: An Act to provide for refreshments at general meetings - unnumbered draft · amendment · status draft - moved by A. Coordinator · against constitution 7.2.0 - -Operations (2): - op-1 substitute art-99 (article) - title: "Refreshments" → "Refreshments and Catering" - before: 1. Tea shall be provided at every general meeting of the Society. - after : 1. Tea shall be provided at every general meeting of the Society. 2. Coffee shall be prov… - op-2 insert art-100 (article) - title: null → "Tea Rota" - after : 1. The Refreshments Secretary shall maintain a rota of members responsible for refreshme… - -Approvals — Article 16(3) requires all three bodies: - board not recorded - intermediate-board not recorded - units not recorded - -OK — 0 warning(s) -``` - -Notice what op-1 does and does not say. It does not say "add coffee and biscuits to -Article 99". It sets out Article 99 entire — tea included, unchanged — because the -operation's text *is* the article afterwards. That is the rule in section 4, and it is -the only one you have to get right. +## Who to ask ---- +The **Internal Compliance Coordinator**, at `pranay@stmorg.in` — the contact of record +in the constitution — if you do not know who holds that office this year. -**See also:** [AMENDMENT-PROCESS.md](AMENDMENT-PROCESS.md) (governance and lifecycle) · -[`bills/TEMPLATE.yaml`](../bills/TEMPLATE.yaml) (the commented template) · -[`schema/opencodelaw-bill-1.0.schema.json`](../schema/opencodelaw-bill-1.0.schema.json) -(every field, authoritatively) · [RECONCILIATION.md](../RECONCILIATION.md) (why this -process is shaped the way it is). +Nobody minds being asked. A proposal that is unclear costs a meeting; a question costs a +minute. diff --git a/schema/opencodelaw-bill-1.0.schema.json b/schema/opencodelaw-bill-1.0.schema.json index 8572cbb..757cd7b 100644 --- a/schema/opencodelaw-bill-1.0.schema.json +++ b/schema/opencodelaw-bill-1.0.schema.json @@ -30,7 +30,7 @@ }, "objects_and_reasons": { "type": "string", - "description": "Explanatory only. Convention C1: a Statement of Objects and Reasons is never operative and never a source of authority. It is structurally impossible for it to be one here \u2014 operations carry their own text \u2014 and the validator additionally asserts that no operation refers to it." + "description": "Explanatory only. Convention C1: a Statement of Objects and Reasons is never operative and never a source of authority. It is structurally impossible for it to be one here — operations carry their own text — and the validator additionally asserts that no operation refers to it." }, "operations": { "type": "array", @@ -69,7 +69,7 @@ "withdrawn", "lapsed" ], - "description": "draft \u2192 submitted \u2192 under-review \u2192 (returned \u21c4 under-review) \u2192 scheduled \u2192 approved | rejected \u2192 enacted \u2192 applied. `withdrawn` is the author's, any time before approval. `lapsed` is the board's and is set by hand \u2014 no timeout is invented, because the constitution states none." + "description": "draft → submitted → under-review → (returned ⇄ under-review) → scheduled → approved | rejected → enacted → applied. `withdrawn` is the author's, any time before approval. `lapsed` is the board's and is set by hand — no timeout is invented, because the constitution states none." }, "billMeta": { "type": "object", @@ -127,6 +127,10 @@ "role": { "type": "string" }, + "membership_id": { + "type": "string", + "description": "The mover's membership number, as they state it. Nothing in this system can verify it — the ICC checks it against the register when the draft arrives, which is its whole function. It is recorded because the surfaces that ask for it say it is recorded." + }, "contact": { "type": "string" } @@ -141,7 +145,7 @@ }, "base_version": { "$ref": "#/$defs/semver", - "description": "The constitution version this bill was drafted against. If the constitution has moved past it, validation fails with a rebase instruction \u2014 so an approval meeting always sees what it is actually voting on." + "description": "The constitution version this bill was drafted against. If the constitution has moved past it, validation fails with a rebase instruction — so an approval meeting always sees what it is actually voting on." }, "version_bump": { "enum": [ @@ -226,7 +230,7 @@ "target": { "type": "string", "pattern": "^(preamble|art-[1-9]\\d*(-s-[1-9]\\d*)?)$", - "description": "Must resolve against base_version \u2014 except for `insert`, where it must not already exist." + "description": "Must resolve against base_version — except for `insert`, where it must not already exist." }, "scope": { "enum": [ @@ -240,17 +244,15 @@ }, "text": { "type": "string", - "description": "The COMPLETE resulting text of the target provision \u2014 never a diff, never a splice, never \"insert after the words\u2026\". This is what makes application idempotent: the applier compares the target's current text against this, so re-running an applied Act is a no-op by construction rather than by luck." + "description": "The COMPLETE resulting text of the target provision — never a diff, never a splice, never \"insert after the words…\". This is what makes application idempotent: the applier compares the target's current text against this, so re-running an applied Act is a no-op by construction rather than by luck." }, "sections": { "type": "array", - "description": "Full replacement subdivisions, where the operation restructures a provision. Omit to leave the target's sections untouched.", + "description": "Every clause of the target, as it will stand once the Act is applied. Each existing clause must be ACCOUNTED FOR: either restated with its text, or carried as a tombstone with status 'omitted'. Silence over a clause is refused (incomplete-substitution) — it used to mean deletion, which destroyed the clause's citation anchor with nothing in the document to say it had ever existed. Nothing is ever deleted at any depth: an omitted clause keeps its node, its number and its anchor. A previously omitted clause returns only through an entry that states its text.", "items": { "type": "object", "required": [ - "number", - "title", - "text" + "number" ], "additionalProperties": false, "properties": { @@ -265,8 +267,41 @@ "text": { "type": "string", "minLength": 1 + }, + "status": { + "const": "omitted", + "description": "This clause is carried as omitted: it keeps its number and its anchor, and holds no text. A meeting reads exactly what dies." + }, + "note": { + "type": "string", + "description": "Why this clause is omitted." } - } + }, + "allOf": [ + { + "$comment": "A live clause states its heading and its text; a tombstone states neither.", + "if": { + "not": { + "required": [ + "status" + ] + } + }, + "then": { + "required": [ + "title", + "text" + ] + }, + "else": { + "not": { + "required": [ + "text" + ] + } + } + } + ] } }, "note": { @@ -445,7 +480,7 @@ "ballot-tally", "poll-export" ], - "description": "A poll is a voting MECHANISM, not a record. Where the by-laws permit an online vote, the evidence is a static export attested by the ICC coordinator, archived and hashed like minutes \u2014 never the poll's URL." + "description": "A poll is a voting MECHANISM, not a record. Where the by-laws permit an online vote, the evidence is a static export attested by the ICC coordinator, archived and hashed like minutes — never the poll's URL." }, "path": { "type": "string", @@ -528,7 +563,7 @@ "string", "null" ], - "description": "Bill file the instrument was rendered from \u2014 the audit link between the printed Act and the patch it came from." + "description": "Bill file the instrument was rendered from — the audit link between the printed Act and the patch it came from." } } } diff --git a/src/ballot.mjs b/src/ballot.mjs index 99af4da..ee7ad87 100644 --- a/src/ballot.mjs +++ b/src/ballot.mjs @@ -8,8 +8,14 @@ * Rendered only for a bill at `scheduled` or later. Circulation is the freeze * point: a ballot for a bill still under form review would carry a hash that * the ICC is about to change. + * + * The hash is PASSED IN, never computed here, so that this file has no Node + * dependency and the /icc/ page downloads the same sheets `bill ballot` writes + * rather than a lookalike. It is required rather than defaulted: a sheet that + * silently printed `undefined` where the hash belongs is a sheet a meeting + * would sign. */ -import { substantiveHash, resolutionSentence, REQUIRED_BODIES } from './bill.mjs' +import { resolutionSentenceFor, REQUIRED_BODIES } from './scripts/bill-core.mjs' import { houseStyle } from './bill-render.mjs' const BALLOTABLE = new Set(['scheduled', 'approved', 'enacted', 'applied']) @@ -35,7 +41,12 @@ export function ballotGuard (bill) { export function ballotSheet (bill, body, options = {}) { const m = houseStyle(options) const b = bill.bill - const hash = substantiveHash(bill) + const hash = options.hash + if (!/^[a-f0-9]{64}$/.test(String(hash ?? ''))) { + throw new Error('ballotSheet needs the bill\'s substantive hash passed as options.hash — ' + + 'the sheet exists to carry that hash into the minutes, so rendering one without it would ' + + 'produce a resolution sheet that resolves on nothing.') + } const name = b.number ? `Bill ${b.number} of ${b.year}` : `the draft bill "${b.short_title}"` const ops = bill.operations.map((op, i) => { @@ -44,7 +55,16 @@ export function ballotSheet (bill, body, options = {}) { : op.operation === 'reserve' ? `reserve ${op.target}` : op.operation === 'retitle' ? `retitle ${op.target}` : `substitute ${op.target}` - return `
  • ${i + 1}. ${esc(what)}${op.title ? ` — ${esc(op.title)}` : ''} (${esc(op.scope)} scope)
  • ` + // Clauses this operation removes are named here, in words. A body voting on + // a restatement must be told which clauses it is voting to end, not left to + // notice their absence from a list. + const dying = (op.sections ?? []).filter(s => s.status === 'omitted') + .map(s => `clause (${s.number})`) + return `
  • ${i + 1}. ${esc(what)}${op.title ? ` — ${esc(op.title)}` : ''} (${esc(op.scope)} scope)` + + (dying.length + ? `
    and omits ${esc(dying.join(', '))}, which keep${dying.length > 1 ? '' : 's'} its number and every citation to it` + : '') + + '
  • ' }).join('') return `
    @@ -54,7 +74,7 @@ export function ballotSheet (bill, body, options = {}) {

    Record of Resolution — ${esc(BODY_LABEL[body] ?? body)}

    -

    ${esc(resolutionSentence(bill))}

    +

    ${esc(resolutionSentenceFor(bill, hash))}

    The presiding officer reads the sentence above, including the hash, into the minutes. A vote binds to that hash and not to the bill's title: if the bill is edited afterwards, this resolution is void and this body must resolve again.

    diff --git a/src/bill-cli.mjs b/src/bill-cli.mjs index 1233300..aa81e20 100644 --- a/src/bill-cli.mjs +++ b/src/bill-cli.mjs @@ -8,9 +8,12 @@ import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import yaml from 'js-yaml' -import { ROOT, loadBill, loadConstitution, validateBill, report, tally, classifyOperation, fullText, operationText, REQUIRED_BODIES } from './bill.mjs' +import { + ROOT, loadBill, loadConstitution, validateBill, report, tally, classifyOperation, + fullText, operationText, REQUIRED_BODIES, OPERATION_STATUS, + resolveTarget, parentIdOf, ownText, provisionIndex, unsettledOperations, applyOperation +} from './bill.mjs' import { billToYaml } from './scripts/bill-serialise.mjs' -import { normalise } from './text-compare.mjs' const OPTS = { schema: yaml.CORE_SCHEMA } const DUMP = { lineWidth: -1, noRefs: true, quotingType: '"' } @@ -100,9 +103,16 @@ export function billSubmit (file, { actor = 'ICC' } = {}) { // --------------------------------------------------------------------------- -export function actEnact (file, { actor = 'ICC', signedPdf, signedBy, assentDate, assentedBy } = {}) { +/** + * `constitution` is a seam, not a feature: `actApply` already validates against + * a document it is handed, and this one could only ever validate against the + * real constitution — so a bill drafted on the fixture could not be enacted + * even in a test, and the enactment guards had no fixture coverage at all. The + * CLI passes nothing and the default is unchanged. + */ +export function actEnact (file, { actor = 'ICC', signedPdf, signedBy, assentDate, assentedBy, constitution } = {}) { const bill = loadBill(file) - const { problems, tally: t } = validateBill(file) + const { problems, tally: t } = validateBill(file, { constitution }) const blockers = [] if (!['approved', 'scheduled', 'submitted', 'under-review'].includes(bill.status)) { @@ -166,12 +176,12 @@ export function actEnact (file, { actor = 'ICC', signedPdf, signedBy, assentDate * manifest: nothing outside them may move, and each operation is classified * three ways before anything is written. */ -export function actApply (file, { actor = 'ICC', dryRun = false } = {}) { +export function actApply (file, { actor = 'ICC', dryRun = false, constitutionFile = 'constitution/current.yaml' } = {}) { const bill = loadBill(file) if (bill.status !== 'enacted') { throw new Error(`This bill is "${bill.status}". Only an enacted Act can be applied.`) } - const doc = loadConstitution() + const doc = loadConstitution(constitutionFile) if (bill.bill.base_version !== doc.info.version) { throw new Error( `This Act was drafted against constitution ${bill.bill.base_version} and the constitution is ` + @@ -186,21 +196,15 @@ export function actApply (file, { actor = 'ICC', dryRun = false } = {}) { } const before = structuredClone(doc) - const index = () => { - const m = new Map() - if (doc.preamble) m.set(doc.preamble.id, doc.preamble) - for (const a of doc.articles ?? []) { - m.set(a.id, a) - for (const s of a.sections ?? []) m.set(s.id, s) - } - return m - } - const actId = `act-${bill.enactment.act_number}-${bill.enactment.act_year}` const outcomes = [] for (const op of bill.operations) { - const node = index().get(op.target) + const node = resolveTarget(doc, op.target)?.node ?? null + + // THE SKIP DECISION IS classifyOperation, and nothing else. A private + // shortcut here is how a heading came to be skipped behind a content match + // — Article 12's defect, reborn inside the applier written to prevent it. const verdict = classifyOperation(op, node, null) if (verdict === 'divergent') { @@ -211,61 +215,49 @@ export function actApply (file, { actor = 'ICC', dryRun = false } = {}) { } if (verdict === 'already-applied') { outcomes.push({ op: op.id, target: op.target, result: 'already applied' }); continue } - if (op.operation === 'insert') { - const number = Number(op.target.replace('art-', '')) - doc.articles.push({ - id: op.target, - number, - title: op.title, - title_source: 'enacted', - ...(op.text ? { content: op.text.trimEnd() + '\n' } : {}), - ...(op.sections?.length - ? { sections: op.sections.map(s => ({ id: `${op.target}-s-${s.number}`, number: s.number, title: s.title, title_source: 'enacted', content: s.text.trimEnd() + '\n' })) } - : {}), - amended_by: [actId] - }) - doc.articles.sort((a, b) => a.number - b.number) - } else if (op.operation === 'omit') { - const art = doc.articles.find(a => a.id === op.target) - if (art) { - for (const k of Object.keys(art)) if (!['id', 'number'].includes(k)) delete art[k] - Object.assign(art, { title: 'Omitted', title_source: 'enacted', status: 'omitted', note: op.note, amended_by: [actId] }) - } - } else if (op.operation === 'reserve') { - const art = doc.articles.find(a => a.id === op.target) - if (art) { - for (const k of Object.keys(art)) if (!['id', 'number'].includes(k)) delete art[k] - Object.assign(art, { title: 'Reserved', title_source: 'editorial', status: 'reserved', note: op.note, amended_by: [actId] }) - } - } else if (op.operation === 'retitle') { - node.title = op.title - node.title_source = 'enacted' - node.amended_by = [...new Set([...(node.amended_by ?? []), actId])] - } else { - if (op.title) { node.title = op.title; node.title_source = 'enacted' } - if (op.text != null) node.content = op.text.trimEnd() + '\n' - if (op.sections?.length) { - node.sections = op.sections.map(s => ({ - id: `${op.target}-s-${s.number}`, number: s.number, title: s.title, - title_source: 'enacted', content: s.text.trimEnd() + '\n' - })) - } - node.amended_by = [...new Set([...(node.amended_by ?? []), actId])] + try { + applyOperation(doc, op, { actId }) + } catch (err) { + throw new Error(`ABORT: ${op.id} (${op.operation} ${op.target}) could not be applied — ${err.message} Nothing has been written.`) } outcomes.push({ op: op.id, target: op.target, result: 'applied' }) } + // --- the Act must have actually landed ----------------------------------- + // + // Applying is not finished when the loop ends; it is finished when the + // document reads as the Act prescribes. Every operation is re-classified + // against what was just written, so an operation that quietly did nothing + // cannot report success. That is the structural close on the whole class of + // defect the clause-scope omission belonged to, rather than on that one + // instance of it: a future operation bug can be silent only by making its own + // target read as prescribed, which is the same thing as working. + const unsettled = unsettledOperations(bill, doc) + if (unsettled.length) { + throw new Error( + 'ABORT: this Act was applied and the constitution still does not read as it prescribes. ' + + 'Nothing has been written.\n' + + unsettled.map(u => ` - ${u.op.id} (${u.op.operation} ${u.op.target}): ${u.reason}`).join('\n')) + } + // --- nothing outside the manifest may have moved ------------------------- + // + // Per provision, on its OWN text. `fullText` includes a provision's clauses, + // so a lawful clause-scope operation made its article look like an undeclared + // change and aborted the Act — which is why no clause-scope substitution or + // retitle could be applied at all. A provision may move when its own id is + // declared, or when its article's is: an article-scope substitution restates + // the clauses it carries. const declared = new Set(bill.operations.map(o => o.target)) + const permitted = id => declared.has(id) || declared.has(parentIdOf(id)) const snap = d => { const m = new Map() - m.set('preamble', fullText(d.preamble) + '' + d.preamble.title) - for (const a of d.articles ?? []) m.set(a.id, fullText(a) + '' + a.title) + for (const [id, entry] of provisionIndex(d)) m.set(id, ownText(entry.node)) return m } const s0 = snap(before); const s1 = snap(doc) const moved = [...new Set([...s0.keys(), ...s1.keys()])].filter(k => s0.get(k) !== s1.get(k)) - const outside = moved.filter(k => !declared.has(k)) + const outside = moved.filter(k => !permitted(k)) if (outside.length) { throw new Error(`ABORT: these provisions changed but the Act does not touch them: ${outside.join(', ')}. ` + 'Nothing has been written.') @@ -278,7 +270,7 @@ export function actApply (file, { actor = 'ICC', dryRun = false } = {}) { doc.info.legal_status = 'adopted' if (!dryRun) { - fs.writeFileSync(path.join(ROOT, 'constitution/current.yaml'), yaml.dump(doc, DUMP)) + fs.writeFileSync(path.join(ROOT, constitutionFile), yaml.dump(doc, DUMP)) push(bill, 'enacted', 'applied', actor, null, `Applied to the constitution; version ${doc.info.version}.`) save(file, bill) } diff --git a/src/bill-commands.mjs b/src/bill-commands.mjs index 5f7117b..58c2c54 100644 --- a/src/bill-commands.mjs +++ b/src/bill-commands.mjs @@ -60,13 +60,13 @@ export async function runBillCommand (group, args) { if (group === 'bill' && sub === 'ballot') { const { ballotDocument, ballotGuard } = await import('./ballot.mjs') - const { loadConstitution } = await import('./bill.mjs') + const { loadConstitution, substantiveHash } = await import('./bill.mjs') const bill = loadBill(need()) const blocked = ballotGuard(bill) if (blocked) { console.error(blocked); process.exit(1) } const c = loadConstitution() const out = file.replace(/\.ya?ml$/, '-ballot.html') - fs.writeFileSync(out, ballotDocument(bill, { info: c.info })) + fs.writeFileSync(out, ballotDocument(bill, { info: c.info, hash: substantiveHash(bill) })) console.log(`Resolution sheets written to ${path.relative(ROOT, out)}`) console.log(' One page per body: board, intermediate board, units.') console.log(' Print it, take it to the meetings, and read the resolution sentence — hash and all —') diff --git a/src/bill-render.mjs b/src/bill-render.mjs index 94bc3da..6e1e254 100644 --- a/src/bill-render.mjs +++ b/src/bill-render.mjs @@ -460,6 +460,14 @@ function bodyOf (op, t, titles) { } for (const s of op.sections ?? []) { + // A clause the Act removes is STATED, in words, because that sentence is + // what the bodies vote on. It is not enough that the clause quietly stops + // appearing: a meeting must be able to read exactly what dies. + if (s.status === 'omitted') { + out.push({ heading: `(${s.number}) ${s.title ?? ''}`.trim() }) + out.push({ text: `Clause (${s.number}) is omitted.${s.note ? ` ${String(s.note).trim()}` : ''}` }) + continue + } out.push({ heading: `(${s.number}) ${s.title}` }) out.push({ text: s.text }) } diff --git a/src/bill.mjs b/src/bill.mjs index 91ba9c6..1cc9144 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -15,75 +15,30 @@ import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import Ajv from 'ajv/dist/2020.js' import addFormats from 'ajv-formats' -import { normalise } from './text-compare.mjs' +import { operativeEqual } from './text-compare.mjs' import { canonicalJson, substantiveSubject, blockText, SUBSTANTIVE_FIELDS } from './scripts/bill-serialise.mjs' +import { + REQUIRED_BODIES, THRESHOLD, OPERATION_STATUS, provisionIndex, resolveTarget, parentIdOf, + fullText, ownText, operationText, classifyOperation, tally, buildBillManifest, + unsettledOperations, applyOperation, resolutionSentenceFor +} from './scripts/bill-core.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OPTS = { schema: yaml.CORE_SCHEMA } export const BILL_SCHEMA = 'schema/opencodelaw-bill-1.0.schema.json' -/** The three bodies Article 16(3) names. All are required; none is inferred. */ -export const REQUIRED_BODIES = ['board', 'intermediate-board', 'units'] - /** - * Article 16(3) requires "2/3rd present and voting of the board, the - * intermediate board and units of the NGO collectively". - * - * "Collectively" bears two readings: a pooled vote of all three sitting - * together, or 2/3 within each body. Until the board adopts one by resolution, - * enactment requires the STRICTER reading — 2/3 in each body separately — and - * both tallies are recorded, so an Act cannot later be challenged under - * whichever reading is adopted. - * - * Abstentions are excluded from the denominator: the text says present AND - * VOTING. + * The rules the browser needs too — the Article 16(3) arithmetic, the three-way + * classification, the manifest — live in scripts/bill-core.mjs and are + * re-exported here so every Node-side caller keeps importing them from one + * place. There is exactly one implementation of each; the pages import the same + * file the CLI does. */ -export const THRESHOLD = 2 / 3 - -/** What each removing operation leaves behind on the provision it acts on. */ -export const OPERATION_STATUS = Object.freeze({ omit: 'omitted', reserve: 'reserved' }) - -export { canonicalJson, blockText, SUBSTANTIVE_FIELDS } - -export function tally (approvals = []) { - const perBody = REQUIRED_BODIES.map(body => { - const a = approvals.find(x => x.body === body) - const forVotes = a?.for ?? null - const against = a?.against ?? null - const voting = forVotes == null || against == null ? null : forVotes + against - const ratio = voting ? forVotes / voting : null - return { - body, - recorded: !!a, - date: a?.date ?? null, - present: a?.present ?? null, - for: forVotes, - against, - abstain: a?.abstain ?? null, - voting, - ratio, - passes: ratio != null && ratio >= THRESHOLD, - evidence: a?.evidence ?? null, - billSha256: a?.bill_sha256 ?? null, - meeting: a?.meeting ?? null - } - }) - - const complete = perBody.every(b => b.recorded && b.voting != null) - const pooledFor = perBody.reduce((n, b) => n + (b.for ?? 0), 0) - const pooledVoting = perBody.reduce((n, b) => n + (b.voting ?? 0), 0) - const pooledRatio = pooledVoting ? pooledFor / pooledVoting : null - - return { - perBody, - complete, - pooled: { for: pooledFor, voting: pooledVoting, ratio: pooledRatio, passes: pooledRatio != null && pooledRatio >= THRESHOLD }, - // The stricter reading governs. - passes: complete && perBody.every(b => b.passes), - missingBodies: perBody.filter(b => !b.recorded).map(b => b.body), - missingEvidence: perBody.filter(b => b.recorded && !b.evidence?.path).map(b => b.body), - failedBodies: perBody.filter(b => b.recorded && b.voting != null && !b.passes).map(b => b.body) - } +export { + canonicalJson, blockText, SUBSTANTIVE_FIELDS, + REQUIRED_BODIES, THRESHOLD, OPERATION_STATUS, provisionIndex, resolveTarget, parentIdOf, + fullText, ownText, operationText, classifyOperation, tally, buildBillManifest, + unsettledOperations, applyOperation } // --------------------------------------------------------------------------- @@ -115,24 +70,7 @@ export function loadConstitution (rel = 'constitution/current.yaml') { return yaml.load(fs.readFileSync(path.join(ROOT, rel), 'utf8'), OPTS) } -const provisionsOf = doc => { - const m = new Map() - if (doc.preamble) m.set(doc.preamble.id, { node: doc.preamble, kind: 'preamble' }) - for (const a of doc.articles ?? []) { - m.set(a.id, { node: a, kind: 'article' }) - for (const s of a.sections ?? []) m.set(s.id, { node: s, kind: 'section', parent: a }) - } - return m -} - -/** Everything a reader sees under a provision, for the three-way comparison. */ -export const fullText = node => node - ? [node.content ?? '', ...(node.sections ?? []).flatMap(s => [s.title ?? '', s.content ?? ''])].join('\n').trim() - : '' - -/** The text an operation results in, in the same shape as fullText. */ -export const operationText = op => - [op.text ?? '', ...(op.sections ?? []).flatMap(s => [s.title ?? '', s.text ?? ''])].join('\n').trim() +const provisionsOf = provisionIndex // --------------------------------------------------------------------------- @@ -221,13 +159,50 @@ export function validateBill (file, { constitution } = {}) { } if (op.operation === 'substitute' && existing) { - const current = normalise(fullText(existing.node)) - const proposed = normalise(operationText(op)) - if (current === proposed && normalise(op.title ?? existing.node.title) === normalise(existing.node.title)) { + const current = fullText(existing.node) + const proposed = operationText(op) + if (operativeEqual(current, proposed) && + operativeEqual(op.title ?? existing.node.title, existing.node.title)) { p.warn('no-op', `${at}: the text proposed for ${op.target} is identical to what it already says. This ` + 'operation would change nothing.', at) } + + // A substitution must ACCOUNT FOR every clause of its target. + // + // Presence is not completeness. The first form of this rule asked only + // that `sections` be there, so a partial list validated clean and + // silently repealed every clause it left out — no status, no note, no + // anchor, 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 clauses deleted the dead one, and stating none was + // refused. + // + // Each existing clause is either restated or carried as a tombstone. + // Silence over one is the error, and the error names it. + // + // Keyed on what the target IS, not on the declared `scope`, because the + // two are cross-checked nowhere and a rule keyed on a label would be + // evadable by mislabelling. + const clauses = existing.node.sections ?? [] + if (clauses.length) { + const accounted = new Set((op.sections ?? []).map(x => Number(x.number))) + const unaccounted = clauses.map(c => Number(c.number)).filter(n => !accounted.has(n)) + if (op.sections === undefined) { + p.error('incomplete-substitution', + `${at}: ${op.target} has ${clauses.length} clauses, and a substitution of it must set ` + + 'out every one as it will stand once this Act is applied — including the ones it does ' + + 'not change. Without them the Act can never be verified as applied: application ' + + 'compares the whole provision, so it would read as unapplied forever.', at) + } else if (unaccounted.length) { + p.error('incomplete-substitution', + `${at}: ${op.target} clause${unaccounted.length > 1 ? 's' : ''} ` + + `${unaccounted.map(n => `(${n})`).join(', ')} ${unaccounted.length > 1 ? 'are' : 'is'} ` + + 'unaccounted for. Every clause of the provision must appear: restated with its text, or ' + + 'carried as `status: omitted` if this Act removes it. Leaving one out would delete it ' + + 'silently — the number, the anchor and every citation ever made to it.', at) + } + } } } @@ -373,74 +348,6 @@ function knownDraftingDefects () { return out } -/** - * What the bill would change, per operation, with before and after. - * This is what an approval meeting reads. - */ -export function buildBillManifest (bill, doc) { - const provisions = provisionsOf(doc) - return (bill.operations ?? []).map(op => { - const existing = provisions.get(op.target) - const before = existing ? fullText(existing.node) : null - const after = ['omit', 'reserve'].includes(op.operation) ? null : operationText(op) - return { - id: op.id, - operation: op.operation, - target: op.target, - scope: op.scope, - exists: !!existing, - title_before: existing?.node.title ?? null, - title_after: op.title ?? existing?.node.title ?? null, - before, - after, - unchanged: before != null && after != null && normalise(before) === normalise(after) - } - }) -} - -/** - * The three-way check that makes application idempotent. - * - * current == proposed → already applied, safe no-op - * current == base → apply - * neither → divergence, abort - * - * Re-running an applied Act cannot corrupt anything, which is the defect that - * made re-running Act 1 of 2024 unsafe: its clause edits were line splices into - * text that no longer existed after the first run. - */ -export function classifyOperation (op, currentNode, baseText = null) { - const proposed = normalise(operationText(op)) - const current = normalise(fullText(currentNode)) - - if (op.operation === 'insert') { - // Absent: insert it. Present and already reading as the Act prescribes: - // this Act has been applied, and re-running it must be a no-op like every - // other operation. Present and reading as something else: another - // provision occupies that number, and inserting would overwrite it. - if (!currentNode) return 'apply' - return current === proposed ? 'already-applied' : 'divergent' - } - if (['omit', 'reserve'].includes(op.operation)) { - // The provision is not deleted — its number is never reused, so the entry - // remains carrying a status. "Already applied" is that status being set, - // not the node being gone. - if (!currentNode) return 'already-applied' - // The operation is `omit`; the status it leaves behind is `omitted`. - // Comparing the two directly made re-applying an omission look like work - // forever — and the lifecycle test agreed, because it wrote the same wrong - // status the check expected. Both sides now name the mapping once. - return currentNode.status === OPERATION_STATUS[op.operation] ? 'already-applied' : 'apply' - } - if (op.operation === 'retitle') { - return normalise(currentNode?.title ?? '') === normalise(op.title) ? 'already-applied' : 'apply' - } - if (current === proposed) return 'already-applied' - if (baseText != null && current === normalise(baseText)) return 'apply' - if (baseText == null) return 'apply' - return 'divergent' -} - export function report ({ problems, manifest, tally: t, bill }) { const out = [] const b = bill.bill @@ -509,10 +416,8 @@ export function substantiveHash (bill) { } /** The sentence a meeting reads into its minutes. */ -export function resolutionSentence (bill) { - const b = bill.bill - const name = b.number ? `Bill ${b.number} of ${b.year}` : `the draft bill "${b.short_title}"` - return `This meeting resolves on ${name}, substantive hash ${substantiveHash(bill)}.` +export function resolutionSentence (bill, hash = substantiveHash(bill)) { + return resolutionSentenceFor(bill, hash) } export function fileSha256 (abs) { diff --git a/src/build.mjs b/src/build.mjs index 638afa5..593f6c2 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -18,6 +18,7 @@ import { renderArticle, renderPreamble } from './templates/provision.mjs' import { generateOgImages } from './og.mjs' import { billsMain, billsTocItems } from './templates/bills.mjs' import { proposeMain, proposeTocItems } from './templates/propose.mjs' +import { iccMain, iccTocItems } from './templates/icc.mjs' import { generateBillValidator } from './gen-bill-validator.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -31,21 +32,43 @@ export const SITE_ORIGIN = DEFAULT_SITE_ORIGIN const INCLUDE_CNAME = process.env.INCLUDE_CNAME !== 'false' /** - * /bills/ and /propose/ are different kinds of surface, so they ship - * differently. + * /bills/ and the two working surfaces are different kinds of page, so they + * ship differently. * * /bills/ is RECORD, and always ships: an empty register is a true statement. * "No bills are before the board" is information, not absence. * - * /propose/ is ACTION, and an action surface opens when the desk behind it is - * staffed. Its one actionable instruction is "email this file to the ICC"; put - * that in front of the public before the ICC can receive, and the system's - * first impression on its first real author is silence. + * /propose/ and /icc/ are ACTION, and an action surface opens when the desk + * behind it is staffed. The proposer's one actionable instruction is "email + * this file to the ICC"; put that in front of the public before the ICC can + * receive, and the system's first impression on its first real author is + * silence. The two open together — a proposal surface with no clerking desk is + * the same failure one step later. * * Flip with PROPOSE_ENABLED=true once process/ADOPTION.md is checked off. */ const PROPOSE_ENABLED = process.env.PROPOSE_ENABLED === 'true' +/** + * The engine modules the two pages load in the browser. + * + * Copied to dist/engine/ with their source tree shape intact, so every relative + * import inside them resolves unchanged and there is no build-time rewriting to + * get wrong. This list IS the browser boundary: a module on it must not import + * node:anything, and tests/browser-modules.test.mjs walks the graph to say so. + */ +const BROWSER_MODULES = Object.freeze([ + 'lib/paths.mjs', + 'text-compare.mjs', + 'bill-render.mjs', + 'ballot.mjs', + 'scripts/bill-serialise.mjs', + 'scripts/bill-core.mjs', + 'scripts/bill-derive.mjs', + 'scripts/editor.js', + 'scripts/icc.js' +]) + // Engine and content are separate. Point these at your own files and the // engine needs no modification; versions/ and the act register are optional. const CONSTITUTION_FILE = process.env.CONSTITUTION_FILE ?? 'constitution/current.yaml' @@ -64,19 +87,67 @@ const write = (rel, body) => { return rel } -const copyDir = (from, to) => { +const copyDir = (from, to, keep = null) => { const src = path.join(ROOT, from) if (!fs.existsSync(src)) return 0 const dst = path.join(OUT(), to) fs.mkdirSync(dst, { recursive: true }) let n = 0 for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - if (entry.isDirectory()) n += copyDir(path.join(from, entry.name), path.join(to, entry.name)) - else { fs.copyFileSync(path.join(src, entry.name), path.join(dst, entry.name)); n++ } + if (entry.isDirectory()) n += copyDir(path.join(from, entry.name), path.join(to, entry.name), keep) + else if (!keep || keep(entry.name)) { fs.copyFileSync(path.join(src, entry.name), path.join(dst, entry.name)); n++ } } return n } +const copyFile = (from, to) => { + const full = path.join(OUT(), to) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.copyFileSync(path.join(ROOT, from), full) + return to +} + +/** + * The constitution as the editor holds it. + * + * Shaped exactly like constitution/current.yaml, minus what a proposer's editor + * has no business in, so that `provisionIndex`, `fullText` and + * `buildBillManifest` read it without a translation step. A translation layer + * between "what the page holds" and "what the rules read" is precisely where a + * second interpretation of a provision's text would grow — which is the class + * of failure this project began with. + */ +function constitutionJson (doc) { + const provision = n => ({ + id: n.id, + number: n.number ?? null, + title: n.title ?? '', + title_source: n.title_source ?? 'editorial', + content: n.content ?? '', + ...(n.status ? { status: n.status } : {}), + ...(n.note ? { note: n.note } : {}) + }) + return { + // The version is stated twice on purpose: `info.version` is where every + // shared rule looks for it, and the top-level copy is what a human opening + // the file reads first. + version: doc.info.version, + info: { + version: doc.info.version, + organization: doc.info.organization, + title: doc.info.title, + ...(doc.info.instrument ? { instrument: doc.info.instrument } : {}) + }, + preamble: provision(doc.preamble), + articles: doc.articles.map(a => ({ + ...provision(a), + sections: (a.sections ?? []).map(provision) + })), + next_article: Math.max(...doc.articles.map(a => a.number)) + 1, + reserved: doc.articles.filter(a => a.status === 'reserved').map(a => a.number) + } +} + // --------------------------------------------------------------------------- /** @@ -252,7 +323,7 @@ export function build () { title: `Constitution of ${info.organization}`, description: summarise(toPlainText(doc.preamble.content)), canonical: abs(''), - og: { type: 'article', image: abs('assets/og/home.png'), imageAlt: `Constitution of ${info.organization}` }, + og: { type: 'article', image: abs('assets/og/pages/home.png'), imageAlt: `Constitution of ${info.organization}` }, jsonLd: [legislationLd(info, doc), organizationLd(info), breadcrumbLd([{ name: 'Constitution', url: abs('') }])], main: indexMain @@ -281,7 +352,7 @@ export function build () { title: `Article ${a.number}: ${a.title} — ${info.title}`, description: summarise(text), canonical, - og: { type: 'article', image: abs(`assets/og/${slug}.png`), imageAlt: `Article ${a.number}: ${a.title}` }, + og: { type: 'article', image: abs(`assets/og/articles/${slug}.png`), imageAlt: `Article ${a.number}: ${a.title}` }, jsonLd: [ { '@context': 'https://schema.org', '@type': 'Article', @@ -317,7 +388,7 @@ export function build () { title: `Amendment register — ${info.title}`, description: `Every instrument amending the constitution of ${info.organization}, with dates of assent, the provisions each touches, and the signed Act as published.`, canonical: abs('amendments/'), - og: { image: abs('assets/og/amendments.png'), imageAlt: 'Amendment register' }, + og: { image: abs('assets/og/pages/amendments.png'), imageAlt: 'Amendment register' }, jsonLd: [breadcrumbLd([ { name: 'Constitution', url: abs('') }, { name: 'Amendments', url: abs('amendments/') } @@ -347,29 +418,46 @@ export function build () { title: `Bills — ${info.title}`, description: `Proposed amendments to the constitution of ${info.organization}, including bills that were rejected or withdrawn.`, canonical: abs('bills/'), - og: { image: abs('assets/og/amendments.png'), imageAlt: 'Bills' }, + og: { image: abs('assets/og/pages/amendments.png'), imageAlt: 'Bills' }, jsonLd: [breadcrumbLd([ { name: 'Constitution', url: abs('') }, { name: 'Bills', url: abs('bills/') } ])], - main: billsMain(bills, { url, escapeHtml, actIndex }) + main: billsMain(bills, { url, escapeHtml, actIndex, proposeEnabled: PROPOSE_ENABLED }) }))) - // ---- propose: author a bill without editing YAML ---- + // ---- propose: edit the constitution; the machine writes the bill ---- if (PROPOSE_ENABLED) { - written.push(write('propose/index.html', layout({ - ...shell, - showToc: false, - toc: tocSections(proposeTocItems(), { heading: 'Propose a bill' }), - title: `Propose an amendment — ${info.title}`, - description: `Draft a bill to amend the constitution of ${info.organization}. The page produces a draft for the Internal Compliance Committee; it does not submit, number or approve anything.`, - canonical: abs('propose/'), - og: { image: abs('assets/og/amendments.png'), imageAlt: 'Propose an amendment' }, - jsonLd: [breadcrumbLd([ - { name: 'Constitution', url: abs('') }, { name: 'Propose', url: abs('propose/') } - ])], - head: ``, - main: proposeMain({ url, escapeHtml, info }) - }))) + written.push(write('propose/index.html', layout({ + ...shell, + showToc: false, + toc: tocSections(proposeTocItems(), { heading: 'Propose a change' }), + title: `Propose an amendment — ${info.title}`, + description: `Open the constitution of ${info.organization}, change what you want changed, and download your proposal. The page produces a file for the Internal Compliance Committee; it does not submit, number or approve anything.`, + canonical: abs('propose/'), + og: { image: abs('assets/og/pages/amendments.png'), imageAlt: 'Propose an amendment' }, + jsonLd: [breadcrumbLd([ + { name: 'Constitution', url: abs('') }, { name: 'Propose', url: abs('propose/') } + ])], + head: ``, + main: proposeMain({ url, escapeHtml, info }) + }))) + + // ---- the ICC's clerking desk ---- + // + // noindex: a working surface for one committee, not a page the public is + // looking for. It is behind the same flag either way; this keeps it out of + // results where a proposer might mistake it for where they file. + written.push(write('icc/index.html', layout({ + ...shell, + showToc: false, + toc: tocSections(iccTocItems(), { heading: 'Clerking a bill' }), + title: `ICC desk — ${info.title}`, + description: 'The Internal Compliance Committee\'s working surface: number a bill, record what the three bodies resolved, attach the minutes, and generate the file for the technical department.', + canonical: abs('icc/'), + extraHead: '', + head: ``, + main: iccMain({ url, escapeHtml, info }) + }))) } // ---- archive ---- @@ -383,7 +471,7 @@ export function build () { title: `Archive — ${info.title}`, description: `Every superseded version of the constitution of ${info.organization}, frozen as published.`, canonical: abs('archive/'), - og: { image: abs('assets/og/archive.png'), imageAlt: 'Archive' }, + og: { image: abs('assets/og/pages/archive.png'), imageAlt: 'Archive' }, jsonLd: [breadcrumbLd([ { name: 'Constitution', url: abs('') }, { name: 'Archive', url: abs('archive/') } ])], @@ -412,6 +500,14 @@ export function build () { const v = f.replace(/^v|\.yaml$/g, '') const d = load(`${VERSIONS_DIR}/${f}`) const successor = d.info.superseded_by + + // The archived text, machine-readable, beside the archived page. + // + // This is what lets /propose/ tell a stale edit that still fits from one + // whose target was rewritten underneath it. Without the version a draft was + // written against, "unchanged" and "rewritten" are indistinguishable, and + // the page has to treat every carried-over edit as a conflict. + if (PROPOSE_ENABLED) write(`archive/${v}/constitution.json`, JSON.stringify(constitutionJson(d))) written.push(write(`archive/${v}/index.html`, layout({ ...shell, articles: d.articles, @@ -429,7 +525,7 @@ export function build () { // exists to publish. v1.0.0 and v3.0.0 are different documents. canonical: abs(`archive/${v}/`), extraHead: ``, - og: { type: 'article', url: abs(`archive/${v}/`), image: abs('assets/og/archive.png'), imageAlt: `Version ${v}` }, + og: { type: 'article', url: abs(`archive/${v}/`), image: abs('assets/og/pages/archive.png'), imageAlt: `Version ${v}` }, jsonLd: [breadcrumbLd([ { name: 'Constitution', url: abs('') }, { name: 'Archive', url: abs('archive/') }, @@ -474,30 +570,23 @@ export function build () { // ---- data, assets, static files ---- write('search-index.json', JSON.stringify(buildSearchIndex(doc, slugs))) - // What /propose/ needs to build an operation: the id a target is cited by, - // and the CURRENT text, so a substitute can be prefilled and edited into the - // complete resulting text. An author never types a target id or a partial edit. - if (PROPOSE_ENABLED) write('provisions.json', JSON.stringify({ - base_version: info.version, - generated_for: 'the propose page — targets are picked from this list, never typed', - provisions: [ - { id: doc.preamble.id, kind: 'preamble', number: null, title: doc.preamble.title, - title_source: doc.preamble.title_source ?? 'editorial', text: doc.preamble.content ?? '' }, - ...doc.articles.flatMap(a => [ - { id: a.id, kind: 'article', number: a.number, title: a.title, - title_source: a.title_source ?? 'editorial', status: a.status ?? 'active', - text: a.content ?? '', - sections: (a.sections ?? []).map(x => ({ number: x.number, title: x.title, text: x.content ?? '' })) }, - ...(a.sections ?? []).map(x => ({ - id: x.id, kind: 'section', number: x.number, title: x.title, - title_source: x.title_source ?? 'editorial', article: a.id, article_number: a.number, - text: x.content ?? '' })) - ]) - ], - // The next free article number, and any reserved slot an insert may occupy. - next_article: Math.max(...doc.articles.map(a => a.number)) + 1, - reserved: doc.articles.filter(a => a.status === 'reserved').map(a => a.number) - })) + // The document the editor opens a copy of, and the bill numbers already + // taken, so the ICC desk suggests the next one instead of asking. + if (PROPOSE_ENABLED) { + write('constitution.json', JSON.stringify(constitutionJson(doc))) + write('bills.json', JSON.stringify({ + generated_for: 'the ICC desk — which numbers are already taken, per year', + bills: bills.map(({ file, bill: b }) => ({ + file, + year: b.bill?.year ?? null, + number: b.bill?.number ?? null, + short_title: b.bill?.short_title ?? '', + status: b.status ?? null, + act_number: b.enactment?.act_number ?? null, + act_year: b.enactment?.act_year ?? null + })) + })) + } write('legacy-anchors.json', JSON.stringify(legacyAnchorMap(doc))) for (const [from, to] of Object.entries(LEGACY_REDIRECTS)) { written.push(write(from, redirectStub(url(to), abs(to)))) @@ -508,23 +597,49 @@ export function build () { write('sitemap.xml', sitemap(doc, slugs, versions)) // Open Graph plates. Generated before assets are copied so they land in dist/assets/og/. + // + // The site's own pages are namespaced, because an article's plate is named + // after its slug and Article 16 is titled "Amendments" — which is the same + // slug as the amendment register. One plate overwrote the other, so the + // register's social card showed Article 16 and nobody could have noticed + // without counting the files. A namespace makes the collision impossible + // rather than currently-absent. const ogPages = [ - { slug: 'home', kicker: 'CONSTITUTION', title: `Constitution of ${info.organization}`, + { slug: 'pages/home', kicker: 'CONSTITUTION', title: `Constitution of ${info.organization}`, footer: `Version ${info.version} · effective ${info.effective_from}`, badge: state && !state.complete ? 'In reconciliation' : null }, - { slug: 'amendments', kicker: 'AMENDMENTS', title: 'Amendment register', + { slug: 'pages/amendments', kicker: 'AMENDMENTS', title: 'Amendment register', footer: `${(register.acts ?? []).length} instruments · ${info.organization}` }, - { slug: 'archive', kicker: 'ARCHIVE', title: 'Superseded versions', + { slug: 'pages/archive', kicker: 'ARCHIVE', title: 'Superseded versions', footer: info.organization }, ...doc.articles.map(a => ({ - slug: slugs.get(a.id), + slug: `articles/${slugs.get(a.id)}`, kicker: `ARTICLE ${a.number}`, title: a.title, footer: `${info.organization} · Constitution ${info.version}`, badge: (a.amended_by ?? []).length ? 'Amended' : null })) ] - const og = generateOgImages(path.join(ROOT, 'assets/og'), ogPages, { + // TWO PAGES MAY NOT WANT ONE PLATE. Article 16 is titled "Amendments", which + // is the slug the amendment register used, and one plate overwrote the other + // — the register's social card showed Article 16. Nothing failed: the file + // existed and the link resolved. Namespaced directories make the collision + // impossible between the two kinds of page, and this makes any remaining + // collision a build error rather than a last-writer win. + const claimed = new Map() + for (const p2 of ogPages) { + if (claimed.has(p2.slug)) { + throw new Error(`build aborted: two pages both want assets/og/${p2.slug}.png — ` + + `"${claimed.get(p2.slug)}" and "${p2.title}". One would silently overwrite the other.`) + } + claimed.set(p2.slug, p2.title) + } + + // Rendered into the repo's assets by default, because they are build + // artefacts under a gitignored path. A fixture build points this elsewhere so + // a test build of another organisation's constitution does not leave that + // organisation's plates behind. + const og = generateOgImages(path.join(ROOT, process.env.OG_DIR ?? 'assets/og'), ogPages, { logoPath: path.join(ROOT, 'assets/img/OpenCodeLaw.png'), bannerPath: path.join(ROOT, 'assets/img/openlawcode_banner.png') }) @@ -533,12 +648,28 @@ export function build () { copyDir('favicons', 'favicons') copyDir('acts/pdf', 'acts/pdf') copyDir('src/styles', 'styles') - copyDir('src/scripts', 'scripts') + // Only the site's own enhancement script. Everything else under src/scripts/ + // is engine, and ships — or does not — with the surfaces that load it. + copyDir('src/scripts', 'scripts', name => name === 'app.js') - // The propose page enforces the same schema the CLI does, compiled to a - // standalone module. A second hand-written check in the page would be a - // second implementation, free to drift. - if (PROPOSE_ENABLED) write('scripts/bill-validator.mjs', generateBillValidator()) + if (PROPOSE_ENABLED) { + // The engine, with its source tree shape intact, so every relative import + // inside it resolves without any build-time rewriting. + for (const rel of BROWSER_MODULES) copyFile(`src/${rel}`, `engine/${rel}`) + + // Both pages enforce the same schema the CLI does, compiled to a standalone + // module. A second hand-written check in a page would be a second + // implementation, free to drift. + write('engine/bill-validator.mjs', generateBillValidator()) + + // The same YAML parser the CLI uses, at the same pinned version, read from + // node_modules at build time rather than transcribed. The pages have to + // read an uploaded bill file; a hand-rolled parser for "just this shape" + // would be a second reader of the format, and the two would disagree on + // some file nobody thought to test — which is exactly how a bill would come + // to mean one thing on screen and another to the applier. + copyFile('node_modules/js-yaml/dist/js-yaml.mjs', 'engine/vendor/js-yaml.mjs') + } // The custom domain stays on the old site until it has been reviewed. if (INCLUDE_CNAME && fs.existsSync(path.join(ROOT, 'CNAME'))) { diff --git a/src/comparator-recheck.mjs b/src/comparator-recheck.mjs new file mode 100644 index 0000000..d267d2a --- /dev/null +++ b/src/comparator-recheck.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * The historical re-check, run once when the comparator was split in two. + * + * `normalise` was born comparing YAML against `pdftotext` output, where folding + * whitespace, case, typography and enumerator formatting is correct — noise is + * not text. It was then promoted into `classifyOperation`, the apply loop and + * the self-audit, where both sides are canonical YAML and NOTHING is noise. In + * that world a renumbered clause is an amendment and `units` → `Units` is a + * retitle, so the fold was silently deciding that real changes had already + * happened. + * + * Splitting it changes what the system would say about the record. This reads + * the record and says what changed. It WRITES NOTHING, and it fixes nothing: + * + * - The 2024 verdicts stay FORENSIC. Those Acts were compared against text + * extracted from scanned PDFs; tolerance was their job and remains correct + * for them. PROVENANCE.md is not reopened by a comparator migration. + * - Where the two comparators now disagree, that is a FINDING for the record. + * A migration that quietly absorbed such a disagreement would be doing the + * exact thing this project exists to prevent — changing what the document + * is taken to say, with no instrument behind it. + * + * node src/comparator-recheck.mjs + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { normalise, operativeEqual, forensicEqual, similarity, MATCH_THRESHOLD } from './text-compare.mjs' +import { provisionIndex, fullText, classifyOperation } from './scripts/bill-core.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const OPTS = { schema: yaml.CORE_SCHEMA } +const load = rel => yaml.load(fs.readFileSync(path.join(ROOT, rel), 'utf8'), OPTS) + +export function recheck () { + const doc = load('constitution/current.yaml') + const index = provisionIndex(doc) + const register = fs.existsSync(path.join(ROOT, 'acts/register.yaml')) + ? load('acts/register.yaml') + : { acts: [] } + + const rows = [] + + // --- 1. The applied record: every Act, every provision it claims --------- + // + // The 2024 Acts exist as prose instruments, not as bill files: their + // operations were transcribed by hand, which is the defect the whole + // pipeline was built to retire. So there is no operative text to compare + // against — only the extracted PDF text, which is forensic by construction. + // That is itself the finding, and it is stated rather than worked around. + for (const act of register.acts ?? []) { + for (const p of act.provisions ?? []) { + const node = index.get(p.target)?.node ?? null + rows.push({ + kind: 'act-provision', + act: `Act ${act.number} of ${act.year}`, + target: p.target, + resolves: !!node, + source: 'pdf-extract', + comparator: 'forensic', + note: 'no operative text on record — this Act was transcribed from a signed PDF' + }) + } + } + + // --- 2. Bills on disk: these DO carry operative text --------------------- + const bills = [] + const base = path.join(ROOT, 'bills') + if (fs.existsSync(base)) { + for (const year of fs.readdirSync(base)) { + const dir = path.join(base, year) + if (!fs.statSync(dir).isDirectory()) continue + for (const f of fs.readdirSync(dir).filter(n => /\.ya?ml$/.test(n))) { + bills.push({ rel: `bills/${year}/${f}`, bill: load(`bills/${year}/${f}`) }) + } + } + } + + for (const { rel, bill } of bills) { + for (const op of bill.operations ?? []) { + const node = index.get(op.target)?.node ?? null + // The verdict each comparator gives, side by side. A reclassification is + // the only thing that matters here. + const operative = classifyOperation(op, node, null) + const forensicSame = forensicEqual(fullText(node), op.text ?? '') + const operativeSame = operativeEqual(fullText(node), op.text ?? '') + if (forensicSame !== operativeSame || op.title != null) { + rows.push({ + kind: 'bill-operation', + file: rel, + op: op.id, + target: op.target, + operative, + forensicSame, + operativeSame, + reclassified: forensicSame !== operativeSame + }) + } + } + } + + // --- 3. Where the two comparators disagree about the live text ----------- + // + // Every provision, against itself under both folds. They cannot disagree + // here — a string equals itself — but a provision whose text differs from its + // own title only by case or numbering is exactly the shape that used to be + // invisible, so the count of provisions the forensic fold would collapse is + // worth stating. + const collapsible = [] + const seen = new Map() + for (const [id, entry] of index) { + const key = normalise(fullText(entry.node)) + if (!key) continue + if (seen.has(key)) collapsible.push([seen.get(key), id]) + else seen.set(key, id) + } + + return { rows, collapsible, provisions: index.size, version: doc.info.version, bills: bills.length } +} + +const direct = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +if (direct) { + const r = recheck() + console.log(`comparator re-check — constitution ${r.version}, ${r.provisions} provisions, ${r.bills} bill file(s)`) + console.log('') + + const acts = r.rows.filter(x => x.kind === 'act-provision') + console.log(`Applied record: ${acts.length} provision claim(s) across the register.`) + const unresolved = acts.filter(a => !a.resolves) + console.log(` resolve against the current text: ${acts.length - unresolved.length}/${acts.length}`) + if (unresolved.length) { + for (const a of unresolved) console.log(` UNRESOLVED ${a.act} → ${a.target}`) + } + console.log(' comparator: FORENSIC, unchanged. Every one of these Acts was transcribed from a') + console.log(' signed PDF, so there is no operative text on record to compare. Splitting the') + console.log(' comparator cannot reclassify them, and PROVENANCE.md is not reopened.') + console.log('') + + const ops = r.rows.filter(x => x.kind === 'bill-operation') + const moved = ops.filter(o => o.reclassified) + console.log(`Bill operations carrying operative text: ${ops.length}`) + console.log(` RECLASSIFIED by the split: ${moved.length}`) + for (const o of moved) { + console.log(` ${o.file} ${o.op} ${o.target}: forensic ${o.forensicSame ? 'same' : 'differs'} → operative ${o.operativeSame ? 'same' : 'differs'} (now ${o.operative})`) + } + console.log('') + + console.log(`Provisions the forensic fold would collapse onto each other: ${r.collapsible.length}`) + for (const [a, b] of r.collapsible) console.log(` ${a} ≡ ${b} under the forensic fold`) + console.log('') + console.log(r.collapsible.length || moved.length + ? 'FINDINGS ABOVE. Nothing has been written. Rule on them before relying on the split.' + : 'No reclassification. The split changes no verdict already on the record.') +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index ec48980..d06f8fa 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -12,8 +12,19 @@ * read these, so the base path they assume cannot drift apart — a mismatch * ships a site whose every link points at a directory that is not there. */ -export const DEFAULT_BASE_PATH = process.env.BASE_PATH ?? '/' -export const DEFAULT_SITE_ORIGIN = (process.env.SITE_ORIGIN ?? 'https://constitution.stmorg.in').replace(/\/+$/, '') +/** + * Read at module load, so it must not assume a Node runtime: `escapeHtml` below + * is imported by bill-render.mjs, which the /propose/ and /icc/ pages serve to + * the browser. An unguarded `process.env` here threw a ReferenceError on import + * and took the whole page's module graph down with it — before any of the + * page's own code ran, so the failure looked like a blank page rather than a + * broken one. + */ +const env = (name, fallback) => + (typeof process !== 'undefined' ? process.env?.[name] : undefined) ?? fallback + +export const DEFAULT_BASE_PATH = env('BASE_PATH', '/') +export const DEFAULT_SITE_ORIGIN = env('SITE_ORIGIN', 'https://constitution.stmorg.in').replace(/\/+$/, '') /** Trailing slash, leading slash, no doubles. `/OpenCodeLaw/` or `/`. */ export function normaliseBase (base) { diff --git a/src/og.mjs b/src/og.mjs index ec5ee35..22b9e58 100644 --- a/src/og.mjs +++ b/src/og.mjs @@ -101,6 +101,9 @@ export function generateOgImages (outDir, pages, { logoPath, bannerPath } = {}) for (const page of pages) { const out = path.join(outDir, `${page.slug}.png`) + // Slugs are namespaced by kind (pages/…, articles/…), so the directory has + // to exist before the rasteriser writes into it. + fs.mkdirSync(path.dirname(out), { recursive: true }) if (r) { fs.writeFileSync(tmp, svg({ ...page, logoDataUri })) try { diff --git a/src/scripts/bill-core.mjs b/src/scripts/bill-core.mjs new file mode 100644 index 0000000..3aeb781 Binary files /dev/null and b/src/scripts/bill-core.mjs differ diff --git a/src/scripts/bill-derive.mjs b/src/scripts/bill-derive.mjs new file mode 100644 index 0000000..13bf938 --- /dev/null +++ b/src/scripts/bill-derive.mjs @@ -0,0 +1,514 @@ +/** + * Deriving a bill's operations from an edited copy of the constitution. + * + * ───────────────────────────────────────────────────────────────────────────── + * THE REFRAME + * + * The proposer edits the law. The machine writes the bill. + * + * Phase 8's page asked an author to describe a change: pick an operation, pick + * a target, fill a text box. That surface assumed the author knew what an + * "operation" was. This one does not exist: the author opens a copy of the + * constitution, changes the words they want changed, and the operations are + * DERIVED by comparing their copy against the original. + * ───────────────────────────────────────────────────────────────────────────── + * + * Nothing behind the surface moved. The bill YAML is still the source of truth, + * the substantive hash is still computed the same way, and every operation + * still carries the COMPLETE resulting text of its target — which here is true + * by construction twice over: the text is literally what is in the editor's + * box, and a diff was never taken. + * + * Two rules in this file are not style. Both exist because + * `classifyOperation` compares `fullText(node)` with `operationText(op)`: + * + * 1. A substitute on an article that has clauses carries EVERY clause, and an + * article left with none says `sections: []`. An operation that names no + * clauses can never compare equal to the provision it produced — it would + * classify as `apply` forever, and once a base text is in play, as + * `divergent`. The schema once described that form as leaving the clauses + * "untouched"; it is now a validator error (`incomplete-substitution`), + * because a format that permits a bill which can never verify as applied is + * a format defect. This rule therefore no longer protects only bills the + * editor built. + * + * 2. An edit confined to one clause targets THAT CLAUSE, not its article. + * A clause has no subdivisions, so its text compares exactly, and the + * before/after a meeting reads is the clause rather than six of them. + * + * Renumbering and reordering have no representation here at all. There is no + * function that could emit one — which is a stronger guarantee than a rejected + * input, because there is nothing to reject. + */ +import { + classifyOperation, fullText, operationText, provisionIndex, applyOperation, OPERATION_STATUS +} from './bill-core.mjs' +import { blockText } from './bill-serialise.mjs' + +/** Provisions that hold no text: their number is kept, deliberately empty. */ +const EMPTIED = new Set(['omitted', 'reserved']) + +const clone = v => (typeof structuredClone === 'function' + ? structuredClone(v) + : JSON.parse(JSON.stringify(v))) + +/** + * The working copy the editor edits. + * + * Shaped exactly like the constitution document, so `fullText`, `provisionIndex` + * and `buildBillManifest` read it without translation. A translation layer + * between what the editor holds and what the rules read is precisely where a + * second interpretation of "the text of a provision" would grow. + */ +export function modelFromDoc (doc) { + return { + version: doc.info?.version ?? doc.version ?? null, + preamble: clone(doc.preamble), + articles: (doc.articles ?? []).map(a => clone(a)) + } +} + +export const isEmptied = node => EMPTIED.has(node?.status) + +/** The next free article number. Assigned, never chosen. */ +export function nextArticleNumber (model) { + const numbers = (model.articles ?? []).map(a => Number(a.number)).filter(Number.isFinite) + return (numbers.length ? Math.max(...numbers) : 0) + 1 +} + +/** Numbers held deliberately empty, which an insertion may occupy instead. */ +export function reservedNumbers (model) { + return (model.articles ?? []).filter(a => a.status === 'reserved').map(a => a.number) +} + +/** The next free clause number within an article. Also assigned, never chosen. */ +export function nextClauseNumber (article) { + const numbers = (article.sections ?? []).map(s => Number(s.number)).filter(Number.isFinite) + return (numbers.length ? Math.max(...numbers) : 0) + 1 +} + +// --------------------------------------------------------------------------- +// Editing the model +// +// Every mutation the editor can perform is a function here, so the set of +// possible edits is a list one can read, and `renumber` is not on it. +// --------------------------------------------------------------------------- + +export function addArticle (model, { number = null, title = '', text = '' } = {}) { + const n = number ?? nextArticleNumber(model) + const existing = model.articles.find(a => Number(a.number) === Number(n)) + // A reserved number is the one slot an insertion may occupy: the number was + // held open for exactly this. Anything else would overwrite a provision. + if (existing && existing.status !== 'reserved') { + throw new Error(`Article ${n} already exists.`) + } + const node = { + id: `art-${n}`, + number: Number(n), + title, + title_source: 'enacted', + content: text, + sections: [], + added: true + } + if (existing) model.articles.splice(model.articles.indexOf(existing), 1, node) + else model.articles.push(node) + model.articles.sort((a, b) => a.number - b.number) + return node +} + +export function addClause (article, { title = '', text = '' } = {}) { + const n = nextClauseNumber(article) + const node = { id: `${article.id}-s-${n}`, number: n, title, title_source: 'enacted', content: text } + article.sections ??= [] + article.sections.push(node) + article.sections.sort((a, b) => a.number - b.number) + return node +} + +/** + * Removal marks; it never splices. + * + * An article's number is a permanent citation handle. Every Act, every minute + * and every shared link points at it, so a removed provision keeps its number + * and the articles after it do not move. + * + * It is expressed exactly as the APPLIER expresses it — a status on the node — + * rather than in a private vocabulary. A `removed` flag here and a status there + * meant a carried-over omission arrived on rebase as a flag nothing read, and + * the proposer was told their change survived while the generated bill dropped + * it. + */ +export function removeArticle (article, reason = '') { + article.status = OPERATION_STATUS.omit + if (reason) article.note = reason + else delete article.note + return article +} + +export function restoreArticle (article) { + delete article.status + delete article.note + return article +} + +/** + * Removing a clause marks it too. A clause's number is an anchor like any + * other: `art-6-s-2` appears in minutes and in links, and dropping it from the + * list would destroy that citation silently. + */ +export function removeClause (article, clause, reason = '') { + clause.status = OPERATION_STATUS.omit + if (reason) clause.note = reason + else delete clause.note + return article +} + +export function restoreClause (article, clause) { + delete clause.status + delete clause.note + return clause +} + +// --------------------------------------------------------------------------- +// Derivation +// --------------------------------------------------------------------------- + +const changed = (a, b) => blockText(a) !== blockText(b) +const titleChanged = (a, b) => String(a ?? '') !== String(b ?? '') + +/** + * Every clause of a provision, as it will stand — the OMISSION ACCOUNTING. + * + * A clause that is going is carried as a tombstone, not dropped. Silence over a + * clause used to mean deletion: its node vanished, its number was free to be + * reused, and `art-6-s-2` in somebody's minutes stopped resolving with nothing + * in the document to say it ever had. A meeting must be able to read exactly + * what dies, so the operation says it. + */ +const sectionsOf = node => (node.sections ?? []).map(s => isEmptied(s) + ? { number: Number(s.number), title: String(s.title ?? 'Omitted'), status: OPERATION_STATUS.omit, ...(s.note ? { note: s.note } : {}) } + : { number: Number(s.number), title: String(s.title ?? ''), text: blockText(s.content) }) + +/** + * Have clauses been ADDED? + * + * Not "has one been marked as going" — a clause that is going is a finding of + * its own, targeting that clause, so a meeting reads one clause and not six. + * The tombstone is carried in `sections` only when the article is being + * restated anyway. + */ +function clauseSetChanged (baseNode, node) { + const key = n => (n.sections ?? []).map(s => Number(s.number)).join(',') + return key(baseNode) !== key(node) +} + +/** + * The edited model, expressed as bill operations, in document order. + * + * Operation ids are positional and therefore deterministic: the same edits + * always derive the same bill, so the substantive hash is stable across a + * reload and a restored autosave. A random or time-based id would move the hash + * without anything having been proposed differently. + */ +export function deriveOperations (baseDoc, model) { + const base = provisionIndex(baseDoc) + const ops = [] + const add = op => { ops.push({ ...op, id: `op-${ops.length + 1}` }) } + + /** Returns what it emitted: 'substitute' subsumes clauses, 'retitle' does not. */ + const emit = (node, baseNode, scope) => { + const textMoved = changed(baseNode.content, node.content) + const headingMoved = titleChanged(baseNode.title, node.title) + const structural = scope === 'article' && clauseSetChanged(baseNode, node) + const carriesClauses = scope === 'article' && + ((node.sections ?? []).length > 0 || (baseNode.sections ?? []).length > 0) + + if (textMoved || structural) { + add({ + operation: 'substitute', + target: node.id, + scope, + ...(headingMoved ? { title: node.title } : {}), + text: blockText(node.content), + ...(carriesClauses ? { sections: sectionsOf(node) } : {}) + }) + return 'substitute' + } + if (headingMoved) { + add({ operation: 'retitle', target: node.id, scope, title: node.title }) + return 'retitle' + } + return null + } + + if (model.preamble && base.has(model.preamble.id)) { + emit(model.preamble, base.get(model.preamble.id).node, 'article') + } + + for (const article of model.articles ?? []) { + const entry = base.get(article.id) + + // A number that did not exist is an insertion. A number that DID exist — + // one held reserved, or omitted by an earlier Act — is not: reviving a + // provision is stating its text, which is a substitution. Deriving an + // insert for it produced a bill the validator refused outright + // (`insert-exists`), from a button the editor itself offered. + if (!entry) { + const clauses = sectionsOf(article) + add({ + operation: 'insert', + target: article.id, + scope: 'article', + title: article.title, + text: blockText(article.content), + ...(clauses.length ? { sections: clauses } : {}) + }) + continue + } + + const baseNode = entry.node + const wasEmptied = isEmptied(baseNode) + const nowEmptied = isEmptied(article) + + if (nowEmptied) { + // Already a tombstone before this proposal touched it: nothing to say. + if (wasEmptied && baseNode.status === article.status) continue + add({ + operation: article.status === OPERATION_STATUS.reserve ? 'reserve' : 'omit', + target: article.id, + scope: 'article', + ...(String(article.note ?? '').trim() ? { note: article.note.trim() } : {}) + }) + continue + } + + // An article-scope substitution restates the whole provision, clauses + // included, so per-clause operations on it would duplicate what it carries. + // A RETITLE does not: a heading change and a clause change are independent + // findings on the same node, and treating the heading as the end of the + // matter silently discarded every clause edit under it. + if (emit(article, baseNode, 'article') === 'substitute') continue + + for (const clause of article.sections ?? []) { + const bc = base.get(clause.id) + if (!bc) continue + if (isEmptied(clause) && !isEmptied(bc.node)) { + add({ + operation: 'omit', + target: clause.id, + scope: 'clause', + ...(String(clause.note ?? '').trim() ? { note: clause.note.trim() } : {}) + }) + continue + } + if (isEmptied(clause)) continue + emit(clause, bc.node, 'clause') + } + } + + return ops +} + +/** + * Problems that would make the derived bill invalid, in the proposer's words. + * + * The compiled schema catches these too — this exists so the message names the + * provision on screen rather than a JSON pointer. + */ +export function reviewProblems (model, ops, meta = {}) { + const out = [] + if (!String(meta.name ?? '').trim()) { + out.push('Add your name. A bill records who moved it, permanently, from the moment it is drafted.') + } + if (!String(meta.short_title ?? '').trim()) { + out.push('Give your proposal a short title — how the Act would be named.') + } + if (!String(meta.objects_and_reasons ?? '').trim()) { + out.push('Explain your changes in plain words. That explanation is printed at the end of the Act.') + } + if (!ops.length) out.push('Nothing has been changed yet.') + + const label = id => { + if (id === 'preamble') return 'The preamble' + const art = (model.articles ?? []).find(a => a.id === id || (a.sections ?? []).some(s => s.id === id)) + if (!art) return id + if (art.id === id) return `Article ${art.number}` + return `Article ${art.number}, clause (${art.sections.find(s => s.id === id).number})` + } + + for (const op of ops) { + if (op.operation === 'omit' && !String(op.note ?? '').trim()) { + out.push(`${label(op.target)}: say why it is being removed.`) + } + for (const s2 of op.sections ?? []) { + if (s2.status === OPERATION_STATUS.omit && !String(s2.note ?? '').trim()) { + out.push(`${label(op.target)}, clause (${s2.number}): say why it is being removed.`) + } + } + if (op.operation === 'insert' && !String(op.title ?? '').trim()) { + out.push(`${label(op.target)}: a new article needs a heading.`) + } + if (['insert', 'substitute'].includes(op.operation) && + !blockText(op.text) && !(op.sections ?? []).length) { + out.push(`${label(op.target)}: it cannot be left empty. To take a provision out, remove it.`) + } + for (const s of op.sections ?? []) { + if (s.status === OPERATION_STATUS.omit) continue + // The schema requires both, and an untitled or empty clause is a drafting + // mistake rather than a position anyone means to take. + if (!s.title.trim()) out.push(`${label(op.target)}, clause (${s.number}): give the clause a heading.`) + if (!blockText(s.text)) out.push(`${label(op.target)}, clause (${s.number}): it cannot be left empty.`) + } + } + return out +} + +/** + * A complete draft bill. + * + * Status, number and every clerking field are fixed here rather than offered: + * this surface produces drafts, and the exclusion list in bill-serialise.mjs is + * the same boundary written down where the emitter can be tested against it. + */ +export function buildDraft ({ baseDoc, model, meta = {}, today, year }) { + const stamp = today ?? new Date().toISOString().slice(0, 10) + const operations = deriveOperations(baseDoc, model) + return { + opencodelaw_bill: '1.0', + bill: { + short_title: String(meta.short_title ?? '').trim() || 'An Act to …', + ...(String(meta.also_known_as ?? '').trim() ? { also_known_as: meta.also_known_as.trim() } : {}), + year: year ?? Number(stamp.slice(0, 4)), + number: null, + type: meta.type === 'corrigendum' ? 'corrigendum' : 'amendment', + moved_by: { + name: String(meta.name ?? '').trim(), + ...(String(meta.role ?? '').trim() ? { role: meta.role.trim() } : {}), + // Recorded, because the page says it is. Phase 8's form collected a + // membership ID under the words "recorded so the ICC can check its + // register — that is its whole function", and then dropped it: there + // was no field for it and nothing noticed. A surface that asks for + // something must carry it. + ...(String(meta.membership_id ?? '').trim() ? { membership_id: meta.membership_id.trim() } : {}), + ...(String(meta.contact ?? '').trim() ? { contact: meta.contact.trim() } : {}) + }, + drafted: stamp, + base_version: baseDoc.info?.version ?? baseDoc.version, + version_bump: 'minor' + }, + status: 'draft', + history: [], + // Block-scalar form: a YAML `|` block always round-trips with exactly one + // trailing newline, so the object hashed here must carry it too. Otherwise + // the hash the page shows and the hash the CLI computes from the downloaded + // file disagree, and a meeting resolves on a number that does not match the + // file it is voting on. + objects_and_reasons: blockText(meta.objects_and_reasons || '(none given)'), + operations, + approvals: ['board', 'intermediate-board', 'units'].map(body => ({ + body, meeting: { date: null }, present: null, for: null, against: null, abstain: null, bill_sha256: null + })), + enactment: { + act_number: null, act_year: null, assent_date: null, assented_by: null, + signed_by: null, signed_pdf: null, signed_pdf_sha256: null + } + } +} + +// --------------------------------------------------------------------------- +// The rebase +// --------------------------------------------------------------------------- + +/** + * Replay an uploaded file's operations onto the CURRENT constitution. + * + * The page never re-opens a proposer's old text as-is. It loads what the + * constitution says today and asks, of each operation, one of three questions — + * the same three `classifyOperation` asks the applier: + * + * already-applied the target now reads as the proposer's text. Somebody + * else's Act carried this change while the draft sat. Drop + * the operation and say so. + * apply the target reads as it did when they drafted. Their edit + * carries over unchanged. + * divergent the target reads as neither. Their edit is parked; they + * re-make it against the words that are there now. + * + * Telling `apply` from `divergent` needs the text as it stood at the file's + * `base_version`. When that snapshot is not published, every operation that is + * not already-applied becomes a conflict — because "this probably still fits" + * is not something a tool may decide on someone's behalf about a constitution. + */ +export function rebasePlan (bill, currentDoc, baseDoc = null) { + const current = provisionIndex(currentDoc) + const base = baseDoc ? provisionIndex(baseDoc) : null + const baseVersion = bill?.bill?.base_version ?? null + const currentVersion = currentDoc.info?.version ?? currentDoc.version ?? null + const moved = baseVersion !== currentVersion + + const carried = [] + const dropped = [] + const conflicts = [] + + for (const op of bill?.operations ?? []) { + const node = current.get(op.target)?.node ?? null + // Unmoved constitution: the text is its own base, so nothing can diverge. + const baseText = !moved + ? fullText(node) + : base + ? (base.get(op.target) ? fullText(base.get(op.target).node) : null) + : null + + const verdict = classifyOperation(op, node, baseText) + if (verdict === 'already-applied') { + dropped.push({ op, reason: 'enacted', current: fullText(node) }) + continue + } + + // `classifyOperation` answers `apply` when it is given no base text, + // because that is right for the applier: an enacted Act is applied to the + // version it was approved against, and there is nothing to diverge from. + // Here it is wrong. The constitution HAS moved, and with no snapshot of + // where it moved from, "unchanged" and "rewritten" are indistinguishable — + // so the honest verdict is neither, and the proposer re-makes the edit + // against the words that are actually there. + if (verdict === 'apply' && !(moved && !base)) { carried.push(op); continue } + + conflicts.push({ + op, + reason: verdict === 'divergent' ? 'diverged' : 'unverifiable', + current: fullText(node), + proposed: operationText(op), + title: node?.title ?? null + }) + } + + return { + carried, + dropped, + conflicts, + moved, + baseVersion, + currentVersion, + // Stated rather than inferred: the page says which of the two reasons a + // conflict has, and "we could not check" is not the same as "it clashes". + baseAvailable: !moved || !!base + } +} + +/** + * Replay carried operations onto a fresh model of the current constitution. + * + * The result is what the editor shows: the constitution as it is today, with + * the proposer's surviving edits already made in it. Re-deriving from that + * model reproduces the same operations — which is the property that makes + * "based on the latest constitution" the only thing the page can produce. + */ +export function applyOperationsToModel (model, operations = []) { + // No logic of its own. `applyOperation` is the only applier there is; this + // hands it the model, which is shaped exactly like a constitution document + // for precisely this reason. + for (const op of operations) applyOperation(model, op) + return model +} diff --git a/src/scripts/bill-serialise.mjs b/src/scripts/bill-serialise.mjs index 6f7e681..94d0248 100644 --- a/src/scripts/bill-serialise.mjs +++ b/src/scripts/bill-serialise.mjs @@ -66,33 +66,68 @@ export function substantiveSubject (bill) { // --------------------------------------------------------------------------- /** - * Keys the propose page must never emit with a value. This list IS the page's - * authority boundary, written down: the form produces drafts, and structurally - * cannot produce a numbered, approved or enacted bill. + * What each surface may not emit. These lists ARE the authority boundaries, + * written down where the emitter can be tested against them. + * + * They are per-surface because the surfaces have different standing. The + * proposer's editor produces drafts and nothing else: it cannot number a bill, + * cannot record a vote, cannot enact. The ICC's desk is a clerk's tool — it + * numbers, schedules, and writes down what three bodies resolved — but it + * cannot enact either, because assent, the signed instrument and the + * application to the constitution belong to the technical department's CLI, + * where every claim either page produced is independently re-verified against + * files on disk. + * + * Neither list is what makes any of that true. `act enact` recomputes the + * thresholds, re-reads the evidence files and re-checks their checksums against + * the bill's own hash, so a hand-forged file fails exactly as it would if these + * pages had never been built. The lists are here so a REGRESSION in a page is + * caught by a test rather than by a coordinator. */ -export const PAGE_EXCLUDED = Object.freeze([ - 'bill.number', // the ICC numbers a bill at submission - 'history', // written by the pipeline, never by an author - 'approvals[].meeting.mode', - 'approvals[].meeting.place', - 'approvals[].meeting.presiding', - 'approvals[].present', - 'approvals[].for', - 'approvals[].against', - 'approvals[].abstain', - 'approvals[].bill_sha256', - 'approvals[].evidence', - 'approvals[].recorded_by', - 'approvals[].note', - 'enactment.act_number', - 'enactment.act_year', - 'enactment.assent_date', - 'enactment.assented_by', - 'enactment.signed_by', - 'enactment.signed_pdf', - 'enactment.signed_pdf_sha256', - 'enactment.rendered_from' -]) +export const SURFACE_EXCLUSIONS = Object.freeze({ + propose: Object.freeze([ + 'bill.number', // the ICC numbers a bill at submission + 'history', // written by the pipeline, never by an author + 'approvals[].meeting.mode', + 'approvals[].meeting.place', + 'approvals[].meeting.presiding', + 'approvals[].present', + 'approvals[].for', + 'approvals[].against', + 'approvals[].abstain', + 'approvals[].bill_sha256', + 'approvals[].evidence', + 'approvals[].recorded_by', + 'approvals[].note', + 'enactment.act_number', + 'enactment.act_year', + 'enactment.assent_date', + 'enactment.assented_by', + 'enactment.signed_by', + 'enactment.signed_pdf', + 'enactment.signed_pdf_sha256', + 'enactment.rendered_from' + ]), + icc: Object.freeze([ + // Enactment, entire. The ICC attests; it does not assent, and it does not + // register the signed instrument. + 'enactment.act_number', + 'enactment.act_year', + 'enactment.assent_date', + 'enactment.assented_by', + 'enactment.signed_by', + 'enactment.signed_pdf', + 'enactment.signed_pdf_sha256', + 'enactment.rendered_from' + ]) +}) + +/** Fields NO surface may emit — what the serialiser is on the hook for alone. */ +export const NEVER_EMITTED = Object.freeze( + SURFACE_EXCLUSIONS.propose.filter(p => SURFACE_EXCLUSIONS.icc.includes(p))) + +/** Retained under its Phase 8 name: the propose surface is still the propose surface. */ +export const PAGE_EXCLUDED = SURFACE_EXCLUSIONS.propose /** * Emit an optional key only when it is PRESENT — never when it is merely @@ -155,6 +190,7 @@ export function billToYaml (bill, { header = true } = {}) { out.push(' moved_by:') out.push(` name: ${scalar(b.moved_by?.name)}`) out.push(...opt(b.moved_by ?? {}, 'role', v => ` role: ${scalar(v)}`)) + out.push(...opt(b.moved_by ?? {}, 'membership_id', v => ` membership_id: ${scalar(v)}`)) out.push(...opt(b.moved_by ?? {}, 'contact', v => ` contact: ${scalar(v)}`)) out.push(` drafted: ${b.drafted == null ? '~' : b.drafted}`) out.push(` base_version: "${b.base_version}"`) @@ -195,11 +231,23 @@ export function billToYaml (bill, { header = true } = {}) { if (t === '') out.push(' text: ""') else { out.push(' text: |'); out.push(indentBlock(op.text, 6)) } } + // Presence, not truthiness: `sections: []` is "this provision ends up with + // no clauses", which a length test silently turned into "leave them alone". + // Same class as history[].evidence and recorded_by before it. + if (op.sections !== undefined && !op.sections.length) out.push(' sections: []') if (op.sections?.length) { out.push(' sections:') for (const s of op.sections) { out.push(` - number: ${s.number}`) - out.push(` title: ${scalar(s.title)}`) + out.push(...opt(s, 'title', v => ` title: ${scalar(v)}`)) + // A tombstone states no text: the clause keeps its number and its + // anchor and holds nothing. Emitting an empty string instead would be + // a clause that says "" rather than one that is gone. + if (s.status) { + out.push(` status: ${s.status}`) + out.push(...opt(s, 'note', v => ` note: ${scalar(v)}`)) + continue + } const st = blockText(s.text) if (st === '') out.push(' text: ""') else { out.push(' text: |'); out.push(indentBlock(s.text, 10)) } diff --git a/src/scripts/editor.js b/src/scripts/editor.js new file mode 100644 index 0000000..a20baa2 --- /dev/null +++ b/src/scripts/editor.js @@ -0,0 +1,727 @@ +/** + * The document editor behind /propose/. + * + * The proposer edits a copy of the constitution. Everything legislative — + * operations, targets, ids, scopes, numbering — is derived from the difference + * between their copy and the original, by bill-derive.mjs, which the tests + * drive directly in Node. This file is the surface: it renders the document, + * collects keystrokes, and shows what has been derived. + * + * Three properties are structural rather than checked: + * + * - Operations carry the complete resulting text, because the text IS what is + * in the box. No diff is ever taken. + * - Numbers are assigned, never typed. There is no input for one. + * - Renumbering and reordering cannot be expressed. There is no control, and + * no function in bill-derive.mjs that could emit one. + * + * The hash shown here is computed over the same canonical subject the CLI + * hashes — see the invariant at the top of bill-serialise.mjs. It is the number + * a meeting reads into its minutes, so the page and the file must never + * disagree about it. + */ + +import { billToYaml, canonicalJson, substantiveSubject } from './bill-serialise.mjs' +import { + modelFromDoc, deriveOperations, buildDraft, reviewProblems, rebasePlan, + applyOperationsToModel, addArticle, addClause, removeArticle, restoreArticle, + removeClause, restoreClause, nextArticleNumber, reservedNumbers, isEmptied +} from './bill-derive.mjs' +import { renderBillText, titlesFromConstitution } from '../bill-render.mjs' +import { provisionIndex } from './bill-core.mjs' + +const $ = (sel, root = document) => root.querySelector(sel) +const esc = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const STORAGE_KEY = 'opencodelaw.propose.draft.v1' +const SITE = new URL('../../', import.meta.url) + +let DOC = null // the constitution as published +let BASE = new Map() // its provisions, to tell an earlier Act's omission from this proposal's +let MODEL = null // the proposer's working copy +let VALIDATE = null +let ops = [] + +// Transient screen state, deliberately outside the model: what the proposer is +// being asked to confirm is not part of what they are proposing. +const CONFIRMING = new Set() +let CHOOSING_NUMBER = false + +const el = $('#editor') +if (el) start().catch(err => fail(err)) + +function fail (err) { + if (!el) return + el.setAttribute('aria-busy', 'false') + el.innerHTML = `

    The editor could not start: ${esc(err.message)}. You can still + propose a change by writing to the ICC in an email.

    ` +} + +async function start () { + const [doc, validator] = await Promise.all([ + fetch(new URL('constitution.json', SITE)).then(r => { + if (!r.ok) throw new Error(`the constitution could not be loaded (${r.status})`) + return r.json() + }), + import(new URL('../bill-validator.mjs', import.meta.url).href) + .then(m => m.default ?? m).catch(() => null) + ]) + DOC = doc + VALIDATE = typeof validator === 'function' ? validator : null + BASE = provisionIndex(DOC) + MODEL = modelFromDoc(DOC) + + renderDocument() + wire() + offerRestore() + refresh() +} + +// --------------------------------------------------------------------------- +// Rendering the document +// --------------------------------------------------------------------------- + +const articleLabel = a => `Article ${a.number}` +const clauseLabel = (a, s) => `Article ${a.number}, clause (${s.number})` + +function renderDocument () { + const focused = document.activeElement?.id ?? null + const parts = [provisionCard(MODEL.preamble, { label: 'Preamble', kind: 'preamble' })] + for (const a of MODEL.articles) parts.push(articleCard(a)) + parts.push(` +
    + ${CHOOSING_NUMBER + ? numberChoice() + : ''} +

    ${addArticleHelp()}

    +
    `) + el.innerHTML = parts.join('') + el.setAttribute('aria-busy', 'false') + if (focused) document.getElementById(focused)?.focus() +} + +function addArticleHelp () { + const next = nextArticleNumber(MODEL) + const held = reservedNumbers(MODEL) + return held.length + ? `The number is assigned for you: the next free one is ${next}. ` + + `Article ${held.join(', ')} ${held.length > 1 ? 'are' : 'is'} reserved — held deliberately ` + + 'empty — and may be occupied instead. Numbering may not otherwise skip.' + : `The number is assigned for you: the next free one is ${next}. Numbering may not skip.` +} + +/** + * The number is offered, never typed. Where a slot was held open deliberately, + * it appears as its own button; there is no field in which a number could be + * invented, and nothing to validate afterwards. + */ +function numberChoice () { + const next = nextArticleNumber(MODEL) + const held = reservedNumbers(MODEL) + return ` +
    + Which number should the new article take? + + ${held.map(n => ``).join('')} + +
    ` +} + +function articleCard (a) { + // A number held deliberately empty — reserved, or omitted by an earlier Act. + // It is shown, because the number is part of the document and a reader + // looking for Article 19 must find it. It is not editable, because typing + // text into a reserved provision would derive an operation that gives it a + // body while leaving it marked reserved. Occupying the number is a different + // act, and it has its own control. + const baseNode = BASE.get(a.id)?.node ?? null + if (isEmptied(a) && isEmptied(baseNode)) { + const held = a.status === 'reserved' + return ` +
    +

    + ${esc(articleLabel(a))} + ${esc(a.title)} +

    +

    ${held + ? 'This number is reserved — held deliberately empty rather than skipped.' + : 'This article was removed by an earlier Act. Its number is never reused, so every citation made to it still resolves.'} + ${a.note ? esc(a.note) : ''}

    + ${held + ? '

    To put an article here, use Add a new article at the ' + + 'foot of the document and choose this number.

    ' + : ''} +
    ` + } + + if (isEmptied(a)) { + return ` +
    +

    + ${esc(articleLabel(a))} + ${esc(a.title)} +

    +

    You are proposing to remove this article. + Its number stays and is never reused, so every citation ever made to it still resolves. The + articles after it do not move.

    +
    + + +
    + +
    ` + } + + const clauses = (a.sections ?? []).map(s => provisionCard(s, { + label: clauseLabel(a, s), kind: 'clause', parent: a.id + })).join('') + + return ` +
    + ${a.added ? '

    New article

    ' : ''} + ${provisionHead(a, articleLabel(a))} + ${textBox(a, `Text of ${articleLabel(a)}`)} +
    ${clauses} +
    + +
    +
    +
    + ${CONFIRMING.has(a.id) + ? confirmPanel(a, articleLabel(a), false) + : ``} +
    +
    ` +} + +function provisionCard (node, { label, kind, parent }) { + const baseNode = BASE.get(node.id)?.node ?? null + // A clause this proposal is removing. It is not spliced out of the list: + // `art-6-s-2` appears in minutes and in links, and a clause that vanished + // would take that citation with it. + if (kind === 'clause' && isEmptied(node) && !isEmptied(baseNode)) { + return ` +
    +

    + ${esc(label)} + ${esc(node.title ?? '')} +

    +

    You are proposing to remove this clause. + It keeps its number, so every citation ever made to it still resolves, and the clauses after + it do not move.

    +
    + + +
    + +
    ` + } + if (kind === 'clause' && isEmptied(node)) { + return ` +
    +

    + ${esc(label)} + ${esc(node.title ?? '')} +

    +

    This clause was removed by an earlier Act. Its + number is never reused.${node.note ? ' ' + esc(node.note) : ''}

    +
    ` + } + return ` +
    + ${provisionHead(node, label)} + ${textBox(node, `Text of ${label}`)} + ${kind === 'clause' + ? `
    + ${CONFIRMING.has(node.id) + ? confirmPanel(node, label, true) + : ''} +
    ` + : ''} +
    ` +} + +/** The confirmation shows exactly what disappears — in full, not summarised. */ +function confirmPanel (node, label, isClause) { + const body = String(node.content ?? '').trim() + const clauses = (node.sections ?? []).map(s => + `(${s.number}) ${s.title}\n${String(s.content ?? '').trim()}`).join('\n\n') + return ` +
    +

    Remove ${esc(label)}? This is what would go:

    + ${node.title ? `

    ${esc(node.title)}

    ` : ''} +
    ${esc([body, clauses].filter(Boolean).join('\n\n') || '(no text)')}
    +

    ${isClause + ? 'The clauses after it keep their own numbers. Nothing is renumbered.' + : 'Its number stays and is never reused, so every citation ever made to it still resolves. The articles after it do not move.'}

    + + +
    ` +} + +function provisionHead (node, label) { + return ` +

    + ${esc(label)} + + +

    ` +} + +function textBox (node, label) { + const rows = Math.max(3, Math.min(24, String(node.content ?? '').split('\n').length + 1)) + return ` + + ` +} + +// --------------------------------------------------------------------------- +// Editing +// --------------------------------------------------------------------------- + +const find = id => { + if (MODEL.preamble?.id === id) return { node: MODEL.preamble } + for (const a of MODEL.articles) { + if (a.id === id) return { node: a, article: a } + for (const s of a.sections ?? []) if (s.id === id) return { node: s, article: a } + } + return null +} + +function wire () { + el.addEventListener('input', e => { + const card = e.target.closest('.prov') + if (!card) return + const hit = find(card.dataset.id) + if (!hit) return + const role = e.target.dataset.role + if (role === 'title') hit.node.title = e.target.value + else if (role === 'text') hit.node.content = e.target.value + else if (role === 'reason') hit.node.note = e.target.value + schedule() + }) + + el.addEventListener('click', e => { + const btn = e.target.closest('[data-act]') + if (!btn) return + const act = btn.dataset.act + // Where the keyboard should land once the document is re-rendered. A + // control that re-renders and says nothing about focus sends whoever + // pressed it back to the top of the page — which for a document this long + // means losing their place entirely. + let focusAfter = null + + if (act === 'add-article') { CHOOSING_NUMBER = true; focusAfter = '[data-act="new-article"]' } + else if (act === 'cancel-add') { CHOOSING_NUMBER = false; focusAfter = '[data-act="add-article"]' } + else if (act === 'new-article') { + const number = Number(btn.dataset.number) + addArticle(MODEL, { number, title: '', text: '' }) + CHOOSING_NUMBER = false + focusAfter = `#h-art-${number}` + } else { + const card = btn.closest('.prov') + if (!card) return + const hit = find(card.dataset.id) + if (!hit) return + const isClause = hit.node !== hit.article + const within = sel => `.prov[data-id="${CSS.escape(hit.node.id)}"] ${sel}` + + if (act === 'ask-remove') { + CONFIRMING.add(hit.node.id) + focusAfter = within('[data-act="confirm-remove"]') + } else if (act === 'cancel-remove') { + CONFIRMING.delete(hit.node.id) + focusAfter = within('[data-act="ask-remove"]') + } else if (act === 'confirm-remove') { + CONFIRMING.delete(hit.node.id) + if (isClause) removeClause(hit.article, hit.node, '') + else removeArticle(hit.node, '') + // Straight to the reason box, because a removal is not complete + // without one and the review will say so. + focusAfter = `#why-${hit.node.id}` + } else if (act === 'restore') { + if (isClause) restoreClause(hit.article, hit.node) + else restoreArticle(hit.node) + focusAfter = within('[data-act="ask-remove"]') + } else if (act === 'add-clause') { + const clause = addClause(hit.article, { title: '', text: '' }) + focusAfter = `#h-${clause.id}` + } else return + } + + renderDocument() + if (focusAfter) el.querySelector(focusAfter)?.focus() + refresh() + }) + + for (const id of ['#p-name', '#p-role', '#p-id', '#p-contact', '#p-title', '#p-objects', '#p-type']) { + $(id)?.addEventListener('input', schedule) + $(id)?.addEventListener('change', schedule) + } + $('#generate')?.addEventListener('click', download) + $('#hash-copy')?.addEventListener('click', copyHash) + $('#clear-draft')?.addEventListener('click', clearDraft) + $('#upload')?.addEventListener('change', onUpload) +} + +// --------------------------------------------------------------------------- +// Deriving, checking, showing +// --------------------------------------------------------------------------- + +let timer +const schedule = () => { clearTimeout(timer); timer = setTimeout(refresh, 200) } + +const meta = () => ({ + name: $('#p-name')?.value ?? '', + role: $('#p-role')?.value ?? '', + membership_id: $('#p-id')?.value ?? '', + contact: $('#p-contact')?.value ?? '', + short_title: $('#p-title')?.value ?? '', + objects_and_reasons: $('#p-objects')?.value ?? '', + type: $('#p-type')?.value ?? 'amendment' +}) + +/** + * Which refresh is the current one. + * + * `refresh` awaits SubtleCrypto, so two of them can be in flight — the one the + * page starts on load, and the one a restored draft starts a moment later. Both + * resume after their await and write to the same elements, and the LAST to + * resume wins regardless of which is current. That put an empty draft's hash on + * screen above a review showing a real proposal: a hash that did not match what + * it sat under, which is the one defect this project cannot tolerate. It also + * let a stale run's `save()` overwrite a restored draft in browser storage. + * + * Everything before the await is synchronous and cannot interleave. Everything + * after it belongs to the newest call or to nobody. + */ +let generation = 0 + +async function refresh () { + if (!MODEL) return + const mine = ++generation + + ops = deriveOperations(DOC, MODEL) + const bill = buildDraft({ baseDoc: DOC, model: MODEL, meta: meta() }) + + renderReview() + + const problems = reviewProblems(MODEL, ops, meta()) + if (VALIDATE && !problems.length && !VALIDATE(bill)) { + for (const e of VALIDATE.errors ?? []) problems.push(`${e.instancePath || 'the proposal'} ${e.message}`) + } + report(problems) + + const hash = await substantiveHash(bill) + if (mine !== generation) return + + $('#hash-out').textContent = hash + $('#preview').textContent = instrumentPreview(bill) + $('#generate').disabled = problems.length > 0 + save() +} + +function renderReview () { + const count = $('#change-count') + const list = $('#changes') + if (!ops.length) { + count.textContent = 'You have not changed anything yet.' + list.innerHTML = '' + return + } + count.textContent = `You are proposing ${ops.length} change${ops.length > 1 ? 's' : ''}.` + + const base = index(DOC) + list.innerHTML = ops.map((op, i) => { + const before = base.get(op.target) + const label = labelFor(op.target) + const beforeText = before ? nodeText(before) : null + const afterText = ['omit', 'reserve'].includes(op.operation) ? null : operationTextOf(op) + const headingMoved = op.title != null && before && op.title !== before.title + + return ` +
    +

    ${i + 1}. ${esc(verbFor(op, label))}

    + ${headingMoved + ? `

    Heading: ${esc(before.title)}${esc(op.title)}

    ` + : ''} + ${beforeText != null + ? `

    It says now

    ${esc(beforeText)}
    ` + : '

    This provision does not exist yet.

    '} + ${afterText == null + ? '

    It would be removed. Its number stays, and is never reused.

    ' + : `

    It would say

    ${esc(afterText)}
    `} + ${op.note ? `

    Reason recorded: ${esc(op.note)}

    ` : ''} +
    ` + }).join('') +} + +const index = doc => { + const m = new Map() + if (doc.preamble) m.set(doc.preamble.id, doc.preamble) + for (const a of doc.articles ?? []) { + m.set(a.id, a) + for (const s of a.sections ?? []) m.set(s.id, s) + } + return m +} + +const nodeText = node => + [node.content ?? '', ...(node.sections ?? []).flatMap(s => [s.title ?? '', s.content ?? ''])] + .join('\n').trim() + +const operationTextOf = op => + [op.text ?? '', ...(op.sections ?? []).flatMap(s => [s.title ?? '', s.text ?? ''])].join('\n').trim() + +function labelFor (id) { + if (id === 'preamble') return 'the preamble' + const m = /^art-(\d+)(?:-s-(\d+))?$/.exec(id) + if (!m) return id + return m[2] ? `Article ${m[1]}, clause (${m[2]})` : `Article ${m[1]}` +} + +const verbFor = (op, label) => op.operation === 'insert' + ? `A new ${label}${op.title ? ` — ${op.title}` : ''}` + : op.operation === 'omit' ? `Remove ${label}` + : op.operation === 'reserve' ? `Empty ${label}, keeping its number` + : op.operation === 'retitle' ? `Change the heading of ${label}` + : `Change ${label}` + +function report (problems) { + const box = $('#check-report') + if (!problems.length) { + box.className = 'banner' + box.innerHTML = '

    Your proposal is ready. Download it, then email it to the ICC.

    ' + return + } + box.className = 'banner banner--superseded' + box.innerHTML = `` +} + +function instrumentPreview (bill) { + try { + return renderBillText(bill, { info: DOC.info, titles: titlesFromConstitution(DOC) }) + } catch (err) { + return `The readable preview could not be rendered (${err.message}). Your proposal file is unaffected.` + } +} + +async function substantiveHash (bill) { + // Hash what will be parsed, never what is displayed — see bill-serialise.mjs. + const bytes = new TextEncoder().encode(canonicalJson(substantiveSubject(bill))) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('') +} + +// --------------------------------------------------------------------------- +// Continuing a proposal: the rebase +// --------------------------------------------------------------------------- + +async function onUpload (e) { + const file = e.target.files?.[0] + if (!file) return + const box = $('#rebase-report') + box.className = 'banner' + box.innerHTML = '

    Reading your file…

    ' + try { + const { load, CORE_SCHEMA } = await import(new URL('../vendor/js-yaml.mjs', import.meta.url).href) + const bill = load(await file.text(), { schema: CORE_SCHEMA }) + if (!bill?.operations) throw new Error('this does not look like a proposal file') + await adopt(bill, box) + } catch (err) { + box.className = 'banner banner--superseded' + box.innerHTML = ` +

    ${esc(err.message)}. If it came from this page it should end in .yaml. Send it + to the ICC as it is and they will look at it.

    ` + } + e.target.value = '' +} + +/** + * Replay an uploaded proposal onto TODAY's constitution. + * + * The old text is never re-opened as-is. The model is rebuilt from what the + * constitution says now, the file's operations are classified one by one, and + * only the ones that still fit are re-made. Whatever is generated afterwards + * therefore carries the current version as its base — not as a rule anyone + * follows, but as the only thing this page can produce. + */ +async function adopt (bill, box) { + const baseVersion = bill.bill?.base_version ?? null + const baseDoc = baseVersion && baseVersion !== DOC.version ? await fetchArchived(baseVersion) : null + const plan = rebasePlan(bill, DOC, baseDoc) + + MODEL = applyOperationsToModel(modelFromDoc(DOC), plan.carried) + fill(bill) + renderDocument() + refresh() + + const bits = [] + bits.push(``) + if (!plan.moved) { + bits.push('

    The constitution has not changed since you drafted this, so every change carried over exactly.

    ') + } else { + bits.push(`

    You drafted this against version ${esc(String(baseVersion))}; the constitution is + now at ${esc(DOC.version)}. Each change was checked against the words that are there today.

    `) + } + if (plan.carried.length) { + bits.push(`

    ${plan.carried.length} change${plan.carried.length > 1 ? 's' : ''} carried + over unchanged. ${plan.carried.map(o => esc(labelFor(o.target))).join(', ')}.

    `) + } + if (plan.dropped.length) { + bits.push(`

    ${plan.dropped.length} change${plan.dropped.length > 1 ? 's are' : ' is'} + no longer needed. ${plan.dropped.map(d => esc(labelFor(d.op.target))).join(', ')} + already ${plan.dropped.length > 1 ? 'read' : 'reads'} the way you proposed — somebody else's + Act carried the change while your draft was waiting. ${plan.dropped.length > 1 ? 'They have' : 'It has'} + been dropped.

    `) + } + if (plan.conflicts.length) { + bits.push(``) + bits.push(plan.baseAvailable + ? '

    These provisions were rewritten while your draft was waiting. Your version and the ' + + 'current one are below. Nothing has been carried over for these — make the change again ' + + 'in the document, against the words that are actually there.

    ' + : `

    Version ${esc(String(baseVersion))} is not published in machine-readable form, so this + page cannot prove these provisions still say what they said when you drafted. It will not + guess on a constitution. Make each change again against the current words.

    `) + bits.push(plan.conflicts.map(c => ` +
    +

    ${esc(labelFor(c.op.target))}

    +
    +

    It says now

    ${esc(c.current)}
    +

    You had proposed

    ${esc(c.proposed)}
    +
    +

    Go to ${esc(labelFor(c.op.target))} in the document →

    +
    `).join('')) + } + box.className = plan.conflicts.length ? 'banner banner--superseded' : 'banner' + box.innerHTML = bits.join('') +} + +async function fetchArchived (version) { + try { + const r = await fetch(new URL(`archive/${version}/constitution.json`, SITE)) + return r.ok ? await r.json() : null + } catch { return null } +} + +function fill (bill) { + const b = bill.bill ?? {} + const set = (sel, v) => { const n = $(sel); if (n && v != null) n.value = String(v) } + set('#p-name', b.moved_by?.name) + set('#p-role', b.moved_by?.role) + set('#p-id', b.moved_by?.membership_id) + set('#p-contact', b.moved_by?.contact) + set('#p-title', b.short_title) + set('#p-objects', (bill.objects_and_reasons ?? '').replace(/\n$/, '')) + set('#p-type', b.type) +} + +// --------------------------------------------------------------------------- +// The copy saved in this browser +// +// Crash protection, not archival — and the page says exactly that. What is +// stored is the DERIVED operations and the metadata, never the model and never +// the DOM: restoring runs the same rebase an uploaded file runs, so a draft +// that has been sitting while the constitution moved can no more re-open stale +// text than a file can. +// --------------------------------------------------------------------------- + +function save () { + const m = meta() + const empty = !ops.length && !m.name.trim() && !m.short_title.trim() && !m.objects_and_reasons.trim() + try { + if (empty) { localStorage.removeItem(STORAGE_KEY); stamp(null); return } + const savedAt = new Date().toISOString() + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + savedAt, base_version: DOC.version, meta: m, operations: ops + })) + stamp(savedAt) + } catch { /* private mode, or full: the download is the real copy either way */ } +} + +function stamp (iso) { + const n = $('#saved-at') + if (!n) return + n.textContent = iso ? `Last saved in this browser at ${new Date(iso).toLocaleString()}.` : '' +} + +function offerRestore () { + let saved + try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') } catch { saved = null } + if (!saved?.operations?.length && !saved?.meta?.name) return + + const box = $('#restore') + box.hidden = false + box.innerHTML = ` + +

    Saved ${esc(new Date(saved.savedAt).toLocaleString())}${saved.meta?.short_title + ? ` — “${esc(saved.meta.short_title)}”` : ''}, with + ${saved.operations?.length ?? 0} change${(saved.operations?.length ?? 0) === 1 ? '' : 's'} + against version ${esc(String(saved.base_version))}.

    +

    ` + + $('#restore-yes').addEventListener('click', async () => { + box.hidden = true + await adopt({ + bill: { base_version: saved.base_version, ...restoreMeta(saved.meta) }, + objects_and_reasons: saved.meta?.objects_and_reasons ?? '', + operations: saved.operations ?? [] + }, $('#rebase-report')) + }) + $('#restore-no').addEventListener('click', () => { box.hidden = true; clearDraft() }) +} + +const restoreMeta = (m = {}) => ({ + short_title: m.short_title, + type: m.type, + moved_by: { name: m.name, role: m.role, membership_id: m.membership_id, contact: m.contact } +}) + +function clearDraft () { + try { localStorage.removeItem(STORAGE_KEY) } catch { /* nothing to clear */ } + stamp(null) + toast('The copy saved in this browser has been cleared.') +} + +// --------------------------------------------------------------------------- + +async function download () { + const bill = buildDraft({ baseDoc: DOC, model: MODEL, meta: meta() }) + const slug = (bill.bill.short_title || 'proposal').toLowerCase() + .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'proposal' + + send(billToYaml(bill), `proposal-${slug}.yaml`, 'text/yaml') + // The readable copy, so the proposer can see what they are sending without + // opening a file format they were never asked to learn. + send(instrumentPreview(bill), `proposal-${slug}.txt`, 'text/plain') + toast('Downloaded. Email both files to the ICC.') +} + +function send (text, filename, type) { + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob([text], { type })) + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(a.href), 1000) +} + +async function copyHash () { + try { await navigator.clipboard.writeText($('#hash-out').textContent) } catch { /* fall through */ } + toast('Reference number copied.') +} + +function toast (message) { + const t = document.getElementById('toast') + if (!t) return + t.textContent = message + t.classList.add('is-visible') + setTimeout(() => t.classList.remove('is-visible'), 3200) +} diff --git a/src/scripts/icc.js b/src/scripts/icc.js new file mode 100644 index 0000000..c7f2c73 --- /dev/null +++ b/src/scripts/icc.js @@ -0,0 +1,602 @@ +/** + * The clerking desk behind /icc/. + * + * It reads the proposer's file, checks it the way the CLI checks it, and helps + * the coordinator write down what happened: the bill's number, the dates it + * moved on, what each of the three bodies resolved, and which signed document + * proves it. Then it hands back one file. + * + * It does not submit. It does not enact. It does not apply. Everything it + * produces is re-verified downstream by `act enact` and the CI gate — evidence + * files present on disk and matching their checksums, thresholds recomputed + * from the tallies, the bill's hash checked against every approval that cites + * it — so this page is convenience and none of it is authority. + * + * The Article 16(3) verdict is computed HERE, live, as tallies are typed. Not + * because the page decides anything, but because a coordinator who finds out at + * enactment that a body fell short has already sent the file on. + */ + +import { billToYaml, canonicalJson, substantiveSubject } from './bill-serialise.mjs' +import { REQUIRED_BODIES, tally, buildBillManifest, resolutionSentenceFor, provisionIndex } from './bill-core.mjs' +import { renderBillText, titlesFromConstitution } from '../bill-render.mjs' +import { ballotDocument, ballotGuard } from '../ballot.mjs' + +const $ = (sel, root = document) => root.querySelector(sel) +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)) +const esc = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const STORAGE_KEY = 'opencodelaw.icc.desk.v1' +const SITE = new URL('../../', import.meta.url) +const BODY_LABEL = { + board: 'The board', + 'intermediate-board': 'The intermediate board', + units: 'The units' +} + +let DOC = null +let REGISTER = { bills: [] } +let VALIDATE = null +let BILL = null // the uploaded bill, as parsed +let HASH = null // its substantive hash, computed once from the parsed file +const CLERK = { number: null, submitted: '', scheduled: '', actor: '' } +const MEETINGS = new Map() // body -> { date, mode, place, presiding, present, for, against, abstain } +const EVIDENCE = new Map() // body -> { kind, path, sha256, filename } + +if ($('#bill-file')) start().catch(err => { + const box = $('#upload-report') + if (box) { + box.className = 'banner banner--superseded' + box.innerHTML = `

    The desk could not start: ${esc(err.message)}. Everything here is also + available from the command line — see opencodelaw bill --help.

    ` + } +}) + +async function start () { + const [doc, register, validator] = await Promise.all([ + fetch(new URL('constitution.json', SITE)).then(r => r.json()), + fetch(new URL('bills.json', SITE)).then(r => r.ok ? r.json() : { bills: [] }).catch(() => ({ bills: [] })), + import(new URL('../bill-validator.mjs', import.meta.url).href) + .then(m => m.default ?? m).catch(() => null) + ]) + DOC = doc + REGISTER = register + VALIDATE = typeof validator === 'function' ? validator : null + + $('#bill-file').addEventListener('change', onUpload) + $('#clear-draft')?.addEventListener('click', clearDraft) + $('#copy-resolution')?.addEventListener('click', copyResolution) + $('#download-ballots')?.addEventListener('click', downloadBallots) + $('#icc-download')?.addEventListener('click', downloadRecord) + for (const [sel, key] of [['#bill-number', 'number'], ['#submitted-date', 'submitted'], + ['#scheduled-date', 'scheduled'], ['#actor', 'actor']]) { + $(sel)?.addEventListener('input', () => { + CLERK[key] = key === 'number' ? Number($(sel).value) || null : $(sel).value + refresh() + }) + } + offerRestore() +} + +// --------------------------------------------------------------------------- +// 1. Open the proposal +// --------------------------------------------------------------------------- + +async function onUpload (e) { + const file = e.target.files?.[0] + if (!file) return + const box = $('#upload-report') + box.className = 'banner' + box.innerHTML = '

    Reading the file…

    ' + try { + const { load, CORE_SCHEMA } = await import(new URL('../vendor/js-yaml.mjs', import.meta.url).href) + await accept(load(await file.text(), { schema: CORE_SCHEMA }), box) + } catch (err) { + box.className = 'banner banner--superseded' + box.innerHTML = `

    ${esc(err.message)}

    ` + } + e.target.value = '' +} + +async function accept (bill, box) { + const problems = checkOnArrival(bill) + const fatal = problems.filter(p => p.fatal) + + if (fatal.length) { + box.className = 'banner banner--superseded' + box.innerHTML = ` + ` + stages(false) + return + } + + BILL = bill + HASH = await substantiveHash(bill) + seedClerk(bill) + buildBodyCards() + stages(true) + + const warnings = problems.filter(p => !p.fatal) + box.className = 'banner' + box.innerHTML = ` +

    ${esc(bill.bill.short_title)}, moved by + ${esc(bill.bill.moved_by?.name ?? '—')}${bill.bill.moved_by?.membership_id + ? `, membership ${esc(bill.bill.moved_by.membership_id)}` : ''}. + ${bill.operations.length} operation${bill.operations.length === 1 ? '' : 's'}, drafted against + constitution ${esc(bill.bill.base_version)}, which is the version in force.

    + ${bill.bill.moved_by?.membership_id + ? '' + : ''} + ${warnings.length + ? `` + : ''}` + + renderReview() + refresh() +} + +/** + * The checks that happen the moment a file arrives: its shape, the provisions + * it names, and whether the constitution has moved since it was written. + * + * A stale file is refused rather than repaired. The proposer re-opens it in + * /propose/, which rebases by construction — so the fix happens where the + * person who wrote the words is looking at them. + */ +function checkOnArrival (bill) { + const out = [] + if (VALIDATE && !VALIDATE(bill)) { + for (const e of VALIDATE.errors ?? []) { + out.push({ fatal: true, message: `${e.instancePath || 'the file'} ${e.message}` }) + } + return out + } + if (!bill.operations?.length) { + out.push({ fatal: true, message: 'The bill contains no operations — it proposes nothing.' }) + } + if (bill.bill.base_version !== DOC.version) { + out.push({ + fatal: true, + html: `This was drafted against constitution version ${esc(String(bill.bill.base_version))}, + and the constitution is now at ${esc(DOC.version)}. Send it back: the proposer + re-opens it on the propose page, which re-makes every change + against the current text and tells them which ones no longer fit. Nobody should vote on a + change written against words that have moved.` + }) + } + const index = provisionIndex(DOC) + for (const op of bill.operations ?? []) { + const exists = index.has(op.target) + if (op.operation === 'insert' && exists) { + out.push({ fatal: true, message: `${op.id}: cannot insert ${op.target} — it already exists.` }) + } else if (op.operation !== 'insert' && !exists) { + out.push({ fatal: true, message: `${op.id}: ${op.target} does not exist in the constitution.` }) + } + } + if (bill.bill.number != null && bill.status === 'draft') { + out.push({ message: `The file already carries the number ${bill.bill.number} while still a draft. Numbering is yours to assign.` }) + } + if ((bill.approvals ?? []).some(a => a.for != null || a.against != null)) { + out.push({ message: 'The file already carries tallies. Check them against the minutes rather than assuming them.' }) + } + return out +} + +const stages = on => { for (const s of $$('[data-stage]')) s.hidden = !on } + +function seedClerk (bill) { + const year = bill.bill.year + const taken = (REGISTER.bills ?? []).filter(b => b.year === year && typeof b.number === 'number') + .map(b => b.number) + CLERK.number = bill.bill.number ?? (taken.length ? Math.max(...taken) : 0) + 1 + CLERK.submitted ||= today() + CLERK.actor ||= 'Internal Compliance Coordinator' + $('#bill-number').value = String(CLERK.number) + $('#submitted-date').value = CLERK.submitted + $('#actor').value = CLERK.actor +} + +const today = () => new Date().toISOString().slice(0, 10) + +// --------------------------------------------------------------------------- +// 2. Read what it does +// --------------------------------------------------------------------------- + +function renderReview () { + const manifest = buildBillManifest(BILL, DOC) + $('#manifest').innerHTML = manifest.map((m, i) => ` +
    +

    ${i + 1}. ${esc(verbFor(m))}

    + ${m.title_before !== m.title_after + ? `

    Heading: ${esc(m.title_before ?? '—')}${esc(m.title_after ?? '—')}

    ` + : ''} + ${m.unchanged ? '

    This operation would change nothing.

    ' : ''} + ${m.before != null + ? `

    It says now

    ${esc(m.before)}
    ` + : '

    This provision does not exist yet.

    '} + ${m.after == null + ? '

    It would be removed. Its number stays, and is never reused.

    ' + : `

    It would say

    ${esc(m.after)}
    `} +
    `).join('') + + $('#objects').textContent = BILL.objects_and_reasons || '(none given)' + $('#instrument').textContent = instrumentOf(withClerking()) +} + +const label = target => target === 'preamble' + ? 'the preamble' + : (() => { + const m = /^art-(\d+)(?:-s-(\d+))?$/.exec(target) + return m ? (m[2] ? `Article ${m[1]}, clause (${m[2]})` : `Article ${m[1]}`) : target + })() + +const verbFor = m => m.operation === 'insert' + ? `Insert a new ${label(m.target)}${m.title_after ? ` — ${m.title_after}` : ''}` + : m.operation === 'omit' ? `Omit ${label(m.target)}` + : m.operation === 'reserve' ? `Reserve ${label(m.target)}, keeping its number` + : m.operation === 'retitle' ? `Retitle ${label(m.target)}` + : `Substitute ${label(m.target)}` + +function instrumentOf (bill) { + try { + return renderBillText(bill, { info: DOC.info, titles: titlesFromConstitution(DOC) }) + } catch (err) { + return `The instrument could not be rendered (${err.message}).` + } +} + +// --------------------------------------------------------------------------- +// 3 & 4. Numbering, and what the three bodies resolved +// --------------------------------------------------------------------------- + +function buildBodyCards () { + const tpl = $('#body-template').content + const evTpl = $('#evidence-template').content + const bodies = $('#bodies') + const evidence = $('#evidence-list') + bodies.innerHTML = '' + evidence.innerHTML = '' + + for (const body of REQUIRED_BODIES) { + MEETINGS.set(body, MEETINGS.get(body) ?? { + date: '', mode: '', place: '', presiding: '', present: '', for: '', against: '', abstain: '' + }) + + const card = tpl.firstElementChild.cloneNode(true) + card.dataset.body = body + $('.body-card__name', card).textContent = BODY_LABEL[body] + for (const input of $$('[data-role]', card)) { + const role = input.dataset.role + if (role === 'verdict') continue + input.value = MEETINGS.get(body)[role] ?? '' + input.addEventListener('input', () => { + MEETINGS.get(body)[role] = input.value + refresh() + }) + } + bodies.appendChild(card) + + const ev = evTpl.firstElementChild.cloneNode(true) + ev.dataset.body = body + $('.body-card__name', ev).textContent = BODY_LABEL[body] + $('[data-role="file"]', ev).addEventListener('change', e => onEvidence(body, e, ev)) + $('[data-role="kind"]', ev).addEventListener('change', e => { + const rec = EVIDENCE.get(body) + if (rec) { rec.kind = e.target.value; refresh() } + }) + $('[data-role="path"]', ev).addEventListener('input', e => { + const rec = EVIDENCE.get(body) + if (rec) { rec.path = e.target.value.trim(); refresh() } + }) + evidence.appendChild(ev) + } +} + +const num = v => { + const n = Number(String(v ?? '').trim()) + return String(v ?? '').trim() === '' || !Number.isFinite(n) ? null : n +} + +/** Approvals as they currently stand, in the shape the schema and `tally` read. */ +function approvals () { + return REQUIRED_BODIES.map(body => { + const m = MEETINGS.get(body) ?? {} + const ev = EVIDENCE.get(body) + const voted = num(m.for) != null || num(m.against) != null + return { + body, + meeting: { + date: m.date || null, + ...(m.mode ? { mode: m.mode } : {}), + ...(m.place ? { place: m.place } : {}), + ...(m.presiding ? { presiding: m.presiding } : {}) + }, + present: num(m.present), + for: num(m.for), + against: num(m.against), + abstain: num(m.abstain), + // An approval binds to the text as voted. Recorded only once a vote is, + // because a null tally has resolved on nothing. + bill_sha256: voted ? HASH : null, + ...(ev ? { evidence: { kind: ev.kind, path: ev.path, sha256: ev.sha256 } } : {}), + ...(CLERK.actor ? { recorded_by: CLERK.actor } : {}) + } + }) +} + +function renderVerdicts (t) { + for (const card of $$('.body-card[data-body]', $('#bodies'))) { + const b = t.perBody.find(x => x.body === card.dataset.body) + const out = $('[data-role="verdict"]', card) + if (!b || b.voting == null) { + out.className = 'body-card__verdict' + out.textContent = 'Enter the numbers voting for and against to see where this body stands.' + continue + } + const pct = (b.ratio * 100).toFixed(1) + out.className = `body-card__verdict body-card__verdict--${b.passes ? 'pass' : 'fail'}` + out.textContent = `${b.for} of ${b.voting} present and voting — ${pct}% — ` + + `${b.passes ? 'above two thirds. This body has approved.' : 'below two thirds. This body has NOT approved.'}` + + (b.abstain ? ` ${b.abstain} abstained, and abstentions are outside the denominator.` : '') + } + + const box = $('#verdict') + if (!t.complete) { + box.className = 'banner' + box.innerHTML = `

    Waiting on ${esc(t.missingBodies.map(b => BODY_LABEL[b]).join(', ') || + 'a full tally from every body')}. Article 16(3) requires all three.

    ` + return + } + box.className = `banner ${t.passes ? '' : 'banner--superseded'}` + box.innerHTML = t.passes + ? '

    Each reached two thirds of ' + + 'those present and voting. Attach the minutes, then generate the record.

    ' + : ` +

    ${esc(t.failedBodies.map(b => BODY_LABEL[b]).join(' and '))} did not reach two thirds of + those present and voting.${t.pooled.passes + ? ' The pooled vote across all three bodies does pass — but until the board resolves by ' + + 'resolution what “collectively” means in Article 16(3), the stricter reading governs and ' + + 'each body must reach two thirds on its own.' + : ''}

    +

    Record it as it happened. A bill that failed is part of the record, and the register + publishes it.

    ` +} + +// --------------------------------------------------------------------------- +// 5. The minutes +// --------------------------------------------------------------------------- + +async function onEvidence (body, e, card) { + const file = e.target.files?.[0] + const out = $('[data-role="out"]', card) + const pathInput = $('[data-role="path"]', card) + if (!file) { EVIDENCE.delete(body); out.textContent = ''; pathInput.value = ''; refresh(); return } + + // Computed here, in this browser, over the exact bytes. The file is never + // uploaded — what travels is the fingerprint, and later the document itself, + // by hand. `act enact` re-reads the file from disk and re-computes this: if + // the two disagree, the archived record is not the document that was filed. + const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()) + const sha256 = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('') + const year = BILL.bill.year + const ext = (file.name.match(/\.[a-z0-9]+$/i) ?? ['.pdf'])[0].toLowerCase() + const suggested = `bills/${year}/evidence/bill-${CLERK.number ?? 'N'}-${year}-${body}-minutes${ext}` + const filePath = pathInput.value.trim() || suggested + pathInput.value = filePath + + EVIDENCE.set(body, { kind: $('[data-role="kind"]', card).value, path: filePath, sha256, filename: file.name }) + out.innerHTML = `${esc(file.name)} + sha256 ${esc(sha256)} + Send the file to the technical department; the record names it as + ${esc(filePath)}` + refresh() +} + +// --------------------------------------------------------------------------- +// The record +// --------------------------------------------------------------------------- + +/** + * The uploaded bill plus everything clerked so far. + * + * `enactment` is carried through untouched and never written: assent, the + * signed instrument and its checksum belong to the technical department. That + * boundary is also the icc entry in SURFACE_EXCLUSIONS, which the serialiser + * tests hold this file to. + */ +function withClerking () { + const t = tally(approvals()) + const status = !t.complete + ? (CLERK.scheduled ? 'scheduled' : 'submitted') + : (t.passes ? 'approved' : 'rejected') + + const history = [...(BILL.history ?? [])] + const add = (from, to, date, note, evidence) => { + if (!date) return + history.push({ date, from, to, actor: CLERK.actor || 'Internal Compliance Coordinator', ...(evidence ? { evidence } : {}), note }) + } + add('draft', 'submitted', CLERK.submitted, + `Numbered Bill ${CLERK.number ?? '—'} of ${BILL.bill.year} on submission.`) + add('submitted', 'scheduled', CLERK.scheduled, + 'Form review complete; circulated to the board, the intermediate board and the units.') + if (t.complete) { + const last = t.perBody.map(b => b.meeting?.date).filter(Boolean).sort().pop() + add('scheduled', status, last || CLERK.scheduled, + status === 'approved' + ? 'Approved by all three bodies under Article 16(3).' + : `Not approved: ${t.failedBodies.join(', ')} below two thirds of those present and voting.`) + } + + return { + ...BILL, + bill: { ...BILL.bill, number: CLERK.number ?? null }, + status, + history, + approvals: approvals() + } +} + +function refresh () { + if (!BILL) return + const t = tally(approvals()) + renderVerdicts(t) + + const record = withClerking() + $('#resolution').textContent = resolutionSentenceFor(record, HASH) + $('#instrument').textContent = instrumentOf(record) + + const problems = [] + if (!CLERK.number) problems.push('Give the bill a number.') + if (!CLERK.submitted) problems.push('Record the date the bill was received.') + if (!CLERK.actor.trim()) problems.push('Record who is clerking this.') + for (const b of t.perBody) { + if (!b.recorded || b.voting == null) problems.push(`${BODY_LABEL[b.body]}: no tally recorded yet.`) + else if (b.present != null && b.voting + (b.abstain ?? 0) > b.present) { + problems.push(`${BODY_LABEL[b.body]}: more votes and abstentions than members present.`) + } + if (!b.meeting?.date) problems.push(`${BODY_LABEL[b.body]}: no meeting date.`) + if (b.voting != null && !EVIDENCE.has(b.body)) { + problems.push(`${BODY_LABEL[b.body]}: a tally with no signed record behind it is an assertion. Attach the minutes.`) + } + } + if (VALIDATE && !VALIDATE(record)) { + for (const e of VALIDATE.errors ?? []) problems.push(`${e.instancePath || 'the record'} ${e.message}`) + } + + // One record legitimately proves a joint sitting. Differing dates or modes + // behind one file is also exactly what a copy-paste looks like, so it is + // surfaced without being forbidden. + const shared = new Map() + for (const [body, ev] of EVIDENCE) { + if (!shared.has(ev.sha256)) shared.set(ev.sha256, []) + shared.get(ev.sha256).push(body) + } + const notes = [] + for (const [, bodies] of shared) { + if (bodies.length < 2) continue + const dates = new Set(bodies.map(b => MEETINGS.get(b)?.date)) + const modes = new Set(bodies.map(b => MEETINGS.get(b)?.mode)) + if (dates.size > 1 || modes.size > 1) { + notes.push(`${bodies.map(b => BODY_LABEL[b]).join(', ')} share one record, but their meetings ` + + 'differ. One compiled record covering separate meetings is legitimate — check that this is ' + + 'that, and not the same file attached twice by mistake.') + } + } + + const box = $('#icc-report') + box.className = problems.length ? 'banner banner--superseded' : 'banner' + box.innerHTML = problems.length + ? `` + : `

    Ready. This record says the bill is ${esc(record.status)}.

    ` + + (notes.length ? `` : '') + $('#icc-download').disabled = problems.length > 0 + save() +} + +// --------------------------------------------------------------------------- + +function downloadBallots () { + const record = withClerking() + // The freeze point, enforced the same way the CLI enforces it: a sheet for a + // bill still under form review would carry a hash the ICC is about to change. + const blocked = ballotGuard({ ...record, status: record.status === 'submitted' ? 'scheduled' : record.status }) + if (blocked) { toast(blocked); return } + send(ballotDocument(record, { info: DOC.info, hash: HASH }), + `bill-${CLERK.number ?? 'draft'}-${BILL.bill.year}-ballots.html`, 'text/html') + toast('Three sheets downloaded. Print them and take them to the meetings.') +} + +function downloadRecord () { + const record = withClerking() + send(billToYaml(record, { header: false }), + `bill-${CLERK.number}-${BILL.bill.year}.yaml`, 'text/yaml') + toast('Record downloaded. Send it with the minutes PDFs, in one message.') +} + +async function copyResolution () { + try { await navigator.clipboard.writeText($('#resolution').textContent) } catch { /* fall through */ } + toast('Resolution sentence copied.') +} + +async function substantiveHash (bill) { + // Hash what will be parsed, never what is displayed — see bill-serialise.mjs. + const bytes = new TextEncoder().encode(canonicalJson(substantiveSubject(bill))) + const digest = await crypto.subtle.digest('SHA-256', bytes) + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('') +} + +// --------------------------------------------------------------------------- +// The copy saved in this browser — same honesty as the proposer's page. +// --------------------------------------------------------------------------- + +function save () { + try { + const savedAt = new Date().toISOString() + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + savedAt, + bill: BILL, + clerk: CLERK, + meetings: Object.fromEntries(MEETINGS), + evidence: Object.fromEntries(EVIDENCE) + })) + const n = $('#saved-at') + if (n) n.textContent = `Last saved in this browser at ${new Date(savedAt).toLocaleString()}.` + } catch { /* private mode, or full: the downloaded record is the record */ } +} + +function offerRestore () { + let saved + try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') } catch { saved = null } + if (!saved?.bill) return + + const box = $('#restore') + box.hidden = false + box.innerHTML = ` + +

    ${esc(saved.bill.bill?.short_title ?? 'A bill')}, saved + ${esc(new Date(saved.savedAt).toLocaleString())}.

    +

    +

    ` + + $('#restore-yes').addEventListener('click', async () => { + box.hidden = true + Object.assign(CLERK, saved.clerk ?? {}) + MEETINGS.clear(); EVIDENCE.clear() + for (const [k, v] of Object.entries(saved.meetings ?? {})) MEETINGS.set(k, v) + for (const [k, v] of Object.entries(saved.evidence ?? {})) EVIDENCE.set(k, v) + await accept(saved.bill, $('#upload-report')) + $('#scheduled-date').value = CLERK.scheduled ?? '' + // Re-checking a restored file rather than trusting it: the constitution may + // have moved since it was set down, which is exactly the case that must not + // pass silently. + }) + $('#restore-no').addEventListener('click', () => { box.hidden = true; clearDraft() }) +} + +function clearDraft () { + try { localStorage.removeItem(STORAGE_KEY) } catch { /* nothing to clear */ } + const n = $('#saved-at') + if (n) n.textContent = '' + toast('The copy saved in this browser has been cleared.') +} + +function send (text, filename, type) { + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob([text], { type })) + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(a.href), 1000) +} + +function toast (message) { + const t = document.getElementById('toast') + if (!t) return + t.textContent = message + t.classList.add('is-visible') + setTimeout(() => t.classList.remove('is-visible'), 4000) +} diff --git a/src/scripts/propose.js b/src/scripts/propose.js deleted file mode 100644 index 140af8a..0000000 --- a/src/scripts/propose.js +++ /dev/null @@ -1,391 +0,0 @@ -/** - * The bill builder. - * - * Two things it guarantees by construction rather than by asking: - * - * - a target is PICKED, never typed, so an operation cannot name a provision - * that does not exist; - * - the text box starts prefilled with the provision's current text, so an - * author edits a whole provision into its new form and physically cannot - * write "insert after the words…". - * - * Validation is the standalone build of the same JSON Schema the CLI uses; the - * substantive hash is computed the same way. A second implementation of either - * would be free to drift, which is the class of failure this project began with. - */ - -import { blockText, canonicalJson, substantiveSubject, billToYaml } from './bill-serialise.mjs' - -const $ = (sel, root = document) => root.querySelector(sel) -const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)) - -const form = $('#propose-form') -if (form) init().catch(err => report([{ message: `The builder could not start: ${err.message}` }])) - -let PROVISIONS = null -let VALIDATE = null -let seq = 0 - -async function init () { - const base = new URL('../', import.meta.url) - const [provRes, validator] = await Promise.all([ - fetch(new URL('provisions.json', base)).then(r => r.json()), - import(new URL('bill-validator.mjs', import.meta.url).href).then(m => m.default ?? m).catch(() => null) - ]) - PROVISIONS = provRes - VALIDATE = typeof validator === 'function' ? validator : null - - $('#op-add').addEventListener('click', () => addOperation()) - $('#download').addEventListener('click', download) - $('#hash-copy').addEventListener('click', copyHash) - form.addEventListener('input', debounce(refresh, 200)) - addOperation() - refresh() -} - -const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms) } } - -// --------------------------------------------------------------------------- -// Operations -// --------------------------------------------------------------------------- - -function addOperation () { - const node = $('#op-template').content.firstElementChild.cloneNode(true) - const n = ++seq - node.dataset.n = String(n) - $('.op__n', node).textContent = String($$('.op', $('#op-list')).length + 1) - - const kind = $('.op__kind', node) - const search = $('.op__search', node) - const results = $('.op__results', node) - - kind.addEventListener('change', () => shapeFor(node)) - search.addEventListener('input', () => showMatches(node, search.value)) - search.addEventListener('keydown', e => pickerKeys(e, node)) - search.addEventListener('blur', () => setTimeout(() => { results.innerHTML = ''; search.setAttribute('aria-expanded', 'false') }, 150)) - $('.op__remove', node).addEventListener('click', () => { - node.remove() - $$('.op', $('#op-list')).forEach((el, i) => { $('.op__n', el).textContent = String(i + 1) }) - refresh() - }) - for (const el of $$('.op__title, .op__text, .op__note, .op__number', node)) { - el.addEventListener('input', () => { renderDiff(node); refresh() }) - } - - $('#op-list').appendChild(node) - shapeFor(node) - return node -} - -/** Show only the inputs this kind of change needs. */ -function shapeFor (node) { - const kind = $('.op__kind', node).value - const isInsert = kind === 'insert' - const gone = kind === 'omit' || kind === 'reserve' - - $('.op__pick', node).hidden = isInsert - $('.op__insert', node).hidden = !isInsert - $('.op__titlebox', node).hidden = gone - $('.op__textbox', node).hidden = gone || kind === 'retitle' - $('.op__notebox', node).hidden = !gone - - if (isInsert) { - const next = PROVISIONS.next_article - const reserved = PROVISIONS.reserved ?? [] - const num = $('.op__number', node) - if (!num.value) num.value = String(next) - $('.op__insert-help', node).textContent = reserved.length - ? `The next free number is ${next}. Article ${reserved.join(', ')} ${reserved.length > 1 ? 'are' : 'is'} reserved and may be occupied instead — numbering may not otherwise skip.` - : `The next free number is ${next}. Numbering may not skip: to leave a number empty, reserve it deliberately.` - } - renderDiff(node) - refresh() -} - -function showMatches (node, q) { - const results = $('.op__results', node) - const search = $('.op__search', node) - const query = q.trim().toLowerCase() - if (!query) { results.innerHTML = ''; search.setAttribute('aria-expanded', 'false'); return } - - const kind = $('.op__kind', node).value - const pool = PROVISIONS.provisions.filter(p => { - if (kind === 'retitle') return true - if (kind === 'omit' || kind === 'reserve') return p.kind === 'article' - return true - }) - const hits = pool.filter(p => - p.id.includes(query) || - (p.title ?? '').toLowerCase().includes(query) || - (p.number != null && String(p.number) === query) - ).slice(0, 12) - - results.innerHTML = hits.map(p => { - const label = p.kind === 'preamble' ? 'Preamble' - : p.kind === 'section' ? `Article ${p.article_number}, clause ${p.number}` - : `Article ${p.number}` - return `
  • - ${escapeHtml(label)} — ${escapeHtml(p.title ?? '')} - ${escapeHtml(p.id)}
  • ` - }).join('') - search.setAttribute('aria-expanded', hits.length ? 'true' : 'false') - - for (const li of $$('li', results)) { - li.addEventListener('mousedown', e => { e.preventDefault(); choose(node, li.dataset.id) }) - } -} - -function pickerKeys (e, node) { - const items = $$('.op__results li', node) - if (!items.length) return - const active = items.findIndex(li => li.classList.contains('is-active')) - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault() - const next = e.key === 'ArrowDown' - ? Math.min(active + 1, items.length - 1) - : Math.max(active - 1, 0) - items.forEach(li => li.classList.remove('is-active')) - items[next].classList.add('is-active') - items[next].scrollIntoView({ block: 'nearest' }) - } else if (e.key === 'Enter') { - e.preventDefault() - choose(node, items[Math.max(active, 0)].dataset.id) - } else if (e.key === 'Escape') { - $('.op__results', node).innerHTML = '' - } -} - -/** Choosing a target prefills its current text. This is what makes full-text operations true. */ -function choose (node, id) { - const p = PROVISIONS.provisions.find(x => x.id === id) - if (!p) return - node.dataset.target = id - node.dataset.kindOf = p.kind - - const label = p.kind === 'preamble' ? 'Preamble' - : p.kind === 'section' ? `Article ${p.article_number}, clause ${p.number}` - : `Article ${p.number}` - $('.op__chosen', node).textContent = `Chosen: ${label} — ${p.title ?? ''} (${p.id})` - $('.op__search', node).value = '' - $('.op__results', node).innerHTML = '' - - const kind = $('.op__kind', node).value - $('.op__title', node).value = p.title ?? '' - if (kind === 'substitute') { - // Prefilled with what it says now; the author edits it into what it should say. - $('.op__text', node).value = fullTextOf(p) - } - if (kind === 'omit' || kind === 'reserve') { - $('.op__confirm', node).hidden = false - $('.op__confirm', node).innerHTML = - ` -
    ${escapeHtml(fullTextOf(p).slice(0, 800))}
    ` - } else { - $('.op__confirm', node).hidden = true - } - renderDiff(node) - refresh() -} - -const fullTextOf = p => [p.text ?? '', ...(p.sections ?? []).flatMap(s => [s.title, s.text])] - .filter(Boolean).join('\n').trim() - -function renderDiff (node) { - const body = $('.op__diff-body', node) - const id = node.dataset.target - const kind = $('.op__kind', node).value - const p = PROVISIONS.provisions.find(x => x.id === id) - - if (kind === 'insert') { - body.innerHTML = `

    New provision.

    ${escapeHtml($('.op__text', node).value)}
    ` - return - } - if (!p) { body.innerHTML = '

    Pick a provision to see the change.

    '; return } - - const before = fullTextOf(p) - const after = kind === 'retitle' ? before - : (kind === 'omit' || kind === 'reserve') ? null - : $('.op__text', node).value - const titleBefore = p.title ?? '' - const titleAfter = $('.op__title', node).value - - body.innerHTML = ` - ${titleBefore !== titleAfter ? `

    Heading: ${escapeHtml(titleBefore)}${escapeHtml(titleAfter)}

    ` : ''} -

    Before

    ${escapeHtml(before)}
    -

    After

    ${after === null - ? '

    (provision removed)

    ' - : `
    ${escapeHtml(after)}
    `}` -} - -// --------------------------------------------------------------------------- -// The bill, its hash, and the check -// --------------------------------------------------------------------------- - -function buildBill () { - const v = name => (form.elements[name]?.value ?? '').trim() - const operations = $$('.op', $('#op-list')).map((node, i) => { - const kind = $('.op__kind', node).value - const target = kind === 'insert' ? `art-${$('.op__number', node).value || PROVISIONS.next_article}` : node.dataset.target - const p = PROVISIONS.provisions.find(x => x.id === target) - const op = { - id: `op-${i + 1}`, - operation: kind, - target: target ?? '', - scope: (node.dataset.kindOf === 'section' || p?.kind === 'section') ? 'clause' : 'article' - } - const title = $('.op__title', node).value.trim() - if (kind === 'omit' || kind === 'reserve') { - op.note = $('.op__note', node).value.trim() || 'No reason recorded.' - } else { - if (title) op.title = title - if (kind !== 'retitle') op.text = blockText($('.op__text', node).value) - } - return op - }) - - const bill = { - opencodelaw_bill: '1.0', - bill: { - short_title: v('short_title') || 'An Act to …', - ...(v('also_known_as') ? { also_known_as: v('also_known_as') } : {}), - year: new Date().getFullYear(), - number: null, - type: v('type') || 'amendment', - moved_by: { - name: v('name') || '', - ...(v('role') ? { role: v('role') } : {}), - ...(v('contact') ? { contact: v('contact') } : {}) - }, - drafted: new Date().toISOString().slice(0, 10), - base_version: PROVISIONS.base_version, - version_bump: 'minor' - }, - status: 'draft', - history: [], - // Block-scalar form. A YAML `|` block always round-trips with exactly one - // trailing newline, so the object hashed here must carry it too — otherwise - // the hash shown on this page and the hash the CLI computes from the - // downloaded file disagree, and a meeting resolves on a number that does - // not match the file it is voting on. - objects_and_reasons: blockText(v('objects_and_reasons') || '(none given)'), - operations, - approvals: ['board', 'intermediate-board', 'units'].map(body => ({ - body, meeting: { date: null }, present: null, for: null, against: null, abstain: null, bill_sha256: null - })), - enactment: { act_number: null, act_year: null, assent_date: null, assented_by: null, signed_by: null, signed_pdf: null, signed_pdf_sha256: null } - } - return bill -} - - - -async function substantiveHash (bill) { - // Hash what will be parsed, never what is displayed — see bill-serialise.mjs. - const bytes = new TextEncoder().encode(canonicalJson(substantiveSubject(bill))) - const digest = await crypto.subtle.digest('SHA-256', bytes) - return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('') -} - -async function refresh () { - if (!PROVISIONS) return - const bill = buildBill() - const problems = [] - - if (!bill.bill.moved_by.name) problems.push({ message: 'Add your name — a bill records who moved it, permanently.' }) - if (!bill.operations.length) problems.push({ message: 'Add at least one change.' }) - for (const [i, op] of bill.operations.entries()) { - if (!op.target || op.target === 'art-') problems.push({ message: `Change ${i + 1}: pick a provision.` }) - if (op.operation === 'insert') { - const n = Number(op.target.replace('art-', '')) - const exists = PROVISIONS.provisions.some(p => p.id === op.target) - const allowed = n === PROVISIONS.next_article || (PROVISIONS.reserved ?? []).includes(n) - if (exists && !(PROVISIONS.reserved ?? []).includes(n)) { - problems.push({ message: `Change ${i + 1}: Article ${n} already exists. Use “replace the text” instead.` }) - } else if (!allowed) { - problems.push({ message: `Change ${i + 1}: Article ${n} would leave a gap. The next free number is ${PROVISIONS.next_article}${(PROVISIONS.reserved ?? []).length ? `, or occupy reserved Article ${PROVISIONS.reserved.join(', ')}` : ''}.` }) - } - } - if ((op.operation === 'substitute' || op.operation === 'insert') && !(op.text ?? '').trim()) { - problems.push({ message: `Change ${i + 1}: write the complete resulting text.` }) - } - } - - if (VALIDATE && !problems.length) { - if (!VALIDATE(bill)) { - for (const e of VALIDATE.errors ?? []) problems.push({ message: `${e.instancePath || 'the bill'} ${e.message}` }) - } - } - - report(problems) - $('#hash-out').textContent = await substantiveHash(bill) - $('#preview').textContent = previewOf(bill) - $('#download').disabled = problems.length > 0 -} - -function report (problems) { - const el = $('#check-report') - if (!problems.length) { - el.className = 'banner' - el.innerHTML = '

    This draft is well formed. Download it and send it to the ICC.

    ' - return - } - el.className = 'banner banner--superseded' - el.innerHTML = `` -} - -const previewOf = bill => [ - `${bill.bill.short_title}`, - `Moved by ${bill.bill.moved_by.name || '—'}${bill.bill.moved_by.role ? ', ' + bill.bill.moved_by.role : ''}`, - `Drafted against constitution ${bill.bill.base_version}`, - '', - ...bill.operations.map((op, i) => `${i + 1}. ${verbFor(op)}`), - '', - 'STATEMENT OF OBJECTS AND REASONS', - bill.objects_and_reasons || '(none given)' -].join('\n') - -const verbFor = op => op.operation === 'insert' - ? `Insertion of new Article ${op.target.replace('art-', '')}${op.title ? ` - ${op.title}` : ''}:` - : op.operation === 'omit' ? `Omission of ${op.target}:` - : op.operation === 'reserve' ? `Reservation of ${op.target}:` - : op.operation === 'retitle' ? `Amendment to ${op.target} (heading):` - : `Amendment to ${op.target}:` - -// --------------------------------------------------------------------------- - - -async function download () { - const bill = buildBill() - const slug = (bill.bill.short_title || 'draft').toLowerCase() - .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'draft' - const blob = new Blob([billToYaml(bill)], { type: 'text/yaml' }) - const a = document.createElement('a') - a.href = URL.createObjectURL(blob) - a.download = `draft-${slug}.yaml` - document.body.appendChild(a) - a.click() - a.remove() - setTimeout(() => URL.revokeObjectURL(a.href), 1000) - toast('Draft downloaded. Send it to the ICC.') -} - -async function copyHash () { - const text = $('#hash-out').textContent - try { await navigator.clipboard.writeText(text) } catch { /* fall through */ } - toast('Hash copied.') -} - -function toast (message) { - const el = document.getElementById('toast') - if (!el) return - el.textContent = message - el.classList.add('is-visible') - setTimeout(() => el.classList.remove('is-visible'), 3200) -} - -function escapeHtml (s) { - return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') -} diff --git a/src/styles/layout.css b/src/styles/layout.css index a5c1dca..bcbabcc 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1520,3 +1520,273 @@ body.is-modal-open { .bill-tally__verdict { white-space: nowrap; font-weight: 600; } .bill-tally__verdict--pass { color: var(--accent-ink); } .bill-tally__verdict--fail { color: var(--action-ink); } + + +/* ============================================================================= + /propose/ and /icc/ — the working surfaces + ============================================================================= + Phase 8's propose page shipped class attributes with no matching rules at + all: .field, .op, .hash-line and the rest existed only in the template. The + page rendered as an unstyled column of controls. Same defect as the .bill-* + rules that lived in a