From 18733a8294700a3ea4925a3cdfea8e55fb989eb9 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 20:36:13 +0530 Subject: [PATCH 01/13] Phase 8 core: bill schema, template, validator, CLI, register origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inversion this phase exists for: the bill YAML is the source of truth and the signed PDF is a rendering of it. An amendment is born machine-applicable, so application is a comparison rather than a transcription. schema/opencodelaw-bill-1.0.schema.json and a heavily commented bills/TEMPLATE.yaml. Operations carry the COMPLETE resulting text of their target — never a diff, never a splice. That is what makes application idempotent by construction: classifyOperation compares current against proposed and against base, giving already-applied, apply, or divergent. Re-running Act 1 of 2024 was unsafe precisely because its clause edits were line splices into text that no longer existed after the first run. renumber is absent from the operations vocabulary and is rejected by name with the reason: anchor stability is a public API, and renumbering is lawful only in a revision bill with a major bump and an explicit anchor map. Article 16(3) is implemented as settled policy. All three bodies are required and none is inferred. Collectively bears two readings, so until the board resolves it the stricter governs — two thirds within each body separately, abstentions excluded from the denominator — and both per-body and pooled tallies are recorded so the record satisfies either reading. Quorum and notice are by-laws matters under 16(2) and are recorded, not invented. base_version makes staleness fatal: a bill drafted against a superseded version fails with a rebase instruction, so no body votes on text that has moved. moved_by is recorded at drafting, which permanently fixes the Q8 class of problem. CLI: bill new / validate / render / submit, act enact / apply. Guard failures name the missing thing in plain language — a coordinator reads these, not a developer. Register gains origin: bill | external-pdf. The three 2024 Acts are external-pdf and their history is not rewritten. Derivation is extended so bill-origin Acts derive their entry from the bill file, and the drift rule covers both. No constitutional text is touched; tripwire green with zero permitted changes. --- .night-run/provisions.sha256 | 8 +- .night-run/tripwire.mjs | Bin 3638 -> 3476 bytes README.md | 26 ++ acts/register.yaml | 5 + bills/TEMPLATE.yaml | 132 +++++++++ process/PROPOSING.md | 374 +++++++++++++++++++++++ schema/opencodelaw-1.0.schema.json | 94 +++++- schema/opencodelaw-bill-1.0.schema.json | 218 ++++++++++++++ src/bill-cli.mjs | 272 +++++++++++++++++ src/bill-commands.mjs | 90 ++++++ src/bill.mjs | 376 ++++++++++++++++++++++++ src/cli.mjs | 15 + src/sync-register.mjs | 42 ++- 13 files changed, 1645 insertions(+), 7 deletions(-) create mode 100644 bills/TEMPLATE.yaml create mode 100644 process/PROPOSING.md create mode 100644 schema/opencodelaw-bill-1.0.schema.json create mode 100644 src/bill-cli.mjs create mode 100644 src/bill-commands.mjs create mode 100644 src/bill.mjs diff --git a/.night-run/provisions.sha256 b/.night-run/provisions.sha256 index 5bd9aed..e5d6ee4 100644 --- a/.night-run/provisions.sha256 +++ b/.night-run/provisions.sha256 @@ -1,10 +1,8 @@ { "note": "Baseline for the unattended run. Any change outside PERMITTED in tripwire.mjs aborts the run.", "file": "constitution/current.yaml", - "taken_at_commit": "f36a02c", - "permitted": [ - "art-11.content" - ], + "taken_at_commit": "48af740", + "permitted": [], "count": 82, "provisions": { "preamble.title": "59e371c5cac498cc5ae8361dafb8f60b4023665a8fb606860a289dc3d399a8dc", @@ -63,7 +61,7 @@ "art-11-s-1.content": "17612d9aca69f815ac99b45a8ac2c0e29108b3ea901880d03d95a709a1b00dde", "art-11-s-2.title": "d456583904a7dd394696064df1106bc90fc44eefc1e884d5420a4b0ebf7794db", "art-11-s-2.content": "544267149f490925bfeda78b4129a95ae7f65a58f62dd8986c630c72a56de380", - "art-12.title": "3303026655c83910b9a0ae51382813956e58271c276fd5a27d05f95b847b139a", + "art-12.title": "d10ccefc6d3a23b2fa9a1822dcddab633ccf745a419283eed73b321317088201", "art-12.content": "8c5ac0ac874e1d0b65dbf0a7c081db4bb42c4886967e1288982734bffc8704ad", "art-13.title": "8f672276e8b9971baef1e26d73b6733f436575230b342e58e5722c11536263fc", "art-13.content": "3124807d0cfcf79deaa8683b46627fd1859abaf233627aa69ddeb8b9e7ed75ef", diff --git a/.night-run/tripwire.mjs b/.night-run/tripwire.mjs index 865bf6c2883c15379b29875aec2efdda07a73590..b63b525aa9fe96a32b9ef0d89c68ed968fa6b09f 100644 GIT binary patch delta 86 zcmdlcGevsC5@yeUjKtzp1q+3w(#)KcVug~7RE5Oc)VvfRE>S4REJ)4C%mWJLr6`o- nmnLVV7AxfCDFn`y*j*XIW|F|3;_R9gvr<~tm ztwdWgSK#=M9(8$NX5(9cjwz8A7X#vIa*30?HqdL%c@+$qykok>;CFQvlsQ-BS0=J7 Tp5{fNV_*SWg*!05PY-8*I>A?T diff --git a/README.md b/README.md index c7091a9..989c604 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,32 @@ the workflow with `include_cname: true`. The CNAME is excluded by default and CI fails if it appears without being asked for, so a build cannot silently repoint a live domain. +## Amending the constitution + +Amendments are not edits. A change to provision text requires an instrument, and this repository +carries the pipeline that produces one. + +**The bill is the source of truth; the signed PDF is a rendering of it.** An amendment is drafted as +YAML, validated, rendered into the house style of the existing Acts, printed, signed and archived — +and then applied mechanically, because the instrument and the patch are the same object. The three +Acts of 2024 were authored the other way round, as prose applied to the text by hand, which is what +produced a half-applied constitution and fourteen reconciliation questions. + +```bash +npx opencodelaw bill new --name my-amendment +npx opencodelaw bill validate bills/2026/my-amendment.yaml # prints the before/after diff +npx opencodelaw bill render bills/2026/my-amendment.yaml # the instrument, for signature +``` + +Approval is governed by **Article 16(3)**: two thirds of those present and voting in the board, the +intermediate board and the units. All three bodies are required and the pipeline will not enact on +fewer. + +| | | +|---|---| +| [process/PROPOSING.md](process/PROPOSING.md) | How to write a bill. Start here. | +| [process/AMENDMENT-PROCESS.md](process/AMENDMENT-PROCESS.md) | Roles, lifecycle, thresholds, versioning. | + ## Honest limitations - **Reconciliation is a human process.** The tools compare texts, classify provisions diff --git a/acts/register.yaml b/acts/register.yaml index 7e5f1ad..1370e89 100644 --- a/acts/register.yaml +++ b/acts/register.yaml @@ -1,5 +1,8 @@ + + acts: - id: act-1-2024 + origin: external-pdf number: 1 year: 2024 title: First Constitution Amendment Act, 2024 @@ -76,6 +79,7 @@ acts: source_lines: 158-181 note: Act restates the title as "Suspension/Termination". - id: act-2-2024 + origin: external-pdf number: 2 year: 2024 title: Second Constitution Amendment Act, 2024 @@ -182,6 +186,7 @@ acts: source_lines: 105-113 note: Act restates the title as "Exit Process". - id: act-3-2024 + origin: external-pdf number: 3 year: 2024 title: Third Constitution Amendment Act, 2024 diff --git a/bills/TEMPLATE.yaml b/bills/TEMPLATE.yaml new file mode 100644 index 0000000..93ced20 --- /dev/null +++ b/bills/TEMPLATE.yaml @@ -0,0 +1,132 @@ +# --------------------------------------------------------------------------- +# A BILL — a proposed amendment to the constitution. +# +# Copy this file to bills// and edit it. You do not need to know YAML +# beyond "keep the indentation as you found it". +# +# npx opencodelaw bill validate bills/2026/my-bill.yaml +# +# Run that as often as you like. It checks your drafting and prints exactly +# 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 +# The rules this follows: process/AMENDMENT-PROCESS.md +# --------------------------------------------------------------------------- + +opencodelaw_bill: "1.0" + +bill: + short_title: An Act to ... # how the Act will be titled + # also_known_as: Membership Act, 2026 # optional familiar name + year: 2026 + number: ~ # leave null — the ICC assigns this at submission + type: amendment # amendment | corrigendum | revision + + moved_by: + name: Your Name # recorded permanently, from the moment you draft + role: Member # e.g. Unit Head, IBM-Technical Coordinator + contact: you@stmorg.in + + drafted: 2026-01-15 + + # The constitution version you wrote this against. If the constitution moves + # on before your bill is approved, validation will tell you to rebase — so + # nobody ever votes on a change written against text that no longer exists. + base_version: "3.0.0" + + version_bump: minor # minor for an amendment; major only for a revision + +status: draft # you leave this alone; the pipeline moves it + +history: [] # every status change is recorded here automatically + +# --------------------------------------------------------------------------- +# Why you are proposing this. Explanatory ONLY. +# +# This becomes the STATEMENT OF OBJECTS AND REASONS at the end of the printed +# Act. It is never operative and can never be cited as the authority for +# anything — a rule this project learned the hard way, when Act 2 of 2024 set +# one amendment threshold in its operative text and a different one here. +# --------------------------------------------------------------------------- +objects_and_reasons: | + 1. Article N is amended to ... . + +# --------------------------------------------------------------------------- +# WHAT THE BILL ACTUALLY DOES. One entry per provision you are changing. +# +# The single rule that matters: `text` is the COMPLETE resulting text of that +# provision — the whole thing, as it should read after your amendment. +# +# Not a diff. Not "insert after the words". Not "delete the third sentence". +# Write out the provision as you want it to end up, in full. +# +# That is what makes an Act safe to apply: the tool compares what the +# provision says now with what you wrote, so applying the same Act twice +# changes nothing the second time, and an Act that no longer matches the text +# it was written against refuses to apply rather than corrupting it. +# --------------------------------------------------------------------------- +operations: + - id: op-1 + operation: substitute # substitute | insert | omit | retitle | reserve + target: art-3 # the provision id, e.g. art-3 or art-6-s-2 + scope: article # article (the whole provision) | clause + # title: Aims and Objectives # set this only if the Act states a heading + text: | + The complete new text of Article 3, written out in full, exactly as it + should read once this Act is applied. + + # A second example — delete to remove it. + # - id: op-2 + # operation: insert + # target: art-22 + # scope: article + # title: Data Protection + # text: | + # The complete text of the new Article 22. + +# --------------------------------------------------------------------------- +# APPROVALS — Article 16(3). +# +# All three bodies must approve. This is settled constitutional policy, not a +# default: a bill approved by fewer than three bodies cannot be enacted, and +# no checklist or shortcut can lower it. +# +# The ICC fills this in from the minutes. Leave the numbers null. +# +# `abstain` is excluded from the threshold — 16(3) says "present and voting". +# --------------------------------------------------------------------------- +approvals: + - body: board + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ # minutes reference — required to enact + - body: intermediate-board + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ + - body: units + date: ~ + present: ~ + for: ~ + against: ~ + abstain: ~ + evidence: ~ + +# --------------------------------------------------------------------------- +# ENACTMENT — filled in by the ICC when the Act is signed. Leave it alone. +# --------------------------------------------------------------------------- +enactment: + act_number: ~ + act_year: ~ + assent_date: ~ + assented_by: ~ + signed_by: ~ + signed_pdf: ~ + signed_pdf_sha256: ~ diff --git a/process/PROPOSING.md b/process/PROPOSING.md new file mode 100644 index 0000000..ebc56ce --- /dev/null +++ b/process/PROPOSING.md @@ -0,0 +1,374 @@ +# Proposing an amendment + +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. + +Governance — who votes, when, and what happens after you hand it 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:`** — 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. + +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. + +--- + +## 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. + +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/schema/opencodelaw-1.0.schema.json b/schema/opencodelaw-1.0.schema.json index 139fd05..3b49e2d 100644 --- a/schema/opencodelaw-1.0.schema.json +++ b/schema/opencodelaw-1.0.schema.json @@ -445,6 +445,78 @@ "id": { "$ref": "#/$defs/actId" }, + "origin": { + "enum": [ + "bill", + "external-pdf" + ], + "description": "How the Act entered the record. `external-pdf` means it was authored as prose and transcribed \u2014 the three 2024 Acts, whose history is not rewritten into the new format. `bill` means it was born from a bill file, which is the only path available for new Acts." + }, + "bill_file": { + "type": "string", + "description": "Repository-relative path to the bill this Act was rendered from. Required for origin: bill." + }, + "signed_pdf_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Checksum of the signed instrument, so the archived PDF can be proved to be the one enacted." + }, + "approvals": { + "type": "array", + "description": "Per-body tallies under Article 16(3). Recorded for Acts born from bills; the 2024 Acts predate the pipeline and carry only the procedure block.", + "items": { + "type": "object", + "required": [ + "body" + ], + "additionalProperties": false, + "properties": { + "body": { + "enum": [ + "board", + "intermediate-board", + "units" + ] + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "present": { + "type": [ + "integer", + "null" + ] + }, + "for": { + "type": [ + "integer", + "null" + ] + }, + "against": { + "type": [ + "integer", + "null" + ] + }, + "abstain": { + "type": [ + "integer", + "null" + ] + }, + "evidence": { + "type": [ + "string", + "null" + ] + } + } + } + }, "number": { "type": "integer", "minimum": 1 @@ -662,7 +734,27 @@ ] } } - } + }, + "allOf": [ + { + "$comment": "An Act born from a bill must say which bill; that link is the audit trail between the printed instrument and the patch it came from.", + "if": { + "properties": { + "origin": { + "const": "bill" + } + }, + "required": [ + "origin" + ] + }, + "then": { + "required": [ + "bill_file" + ] + } + } + ] }, "reconciliationState": { "type": "object", diff --git a/schema/opencodelaw-bill-1.0.schema.json b/schema/opencodelaw-bill-1.0.schema.json new file mode 100644 index 0000000..86576cf --- /dev/null +++ b/schema/opencodelaw-bill-1.0.schema.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://constitution.stmorg.in/schema/opencodelaw-bill-1.0.schema.json", + "title": "OpenCodeLaw Bill 1.0", + "description": "A proposed amendment, machine-applicable from the moment it is drafted. This file is the source of truth; the signed PDF is a rendering of it. The 2024 Acts were authored as prose and applied to the YAML by hand, which produced a half-applied constitution, an unrecorded application, a splice that made re-running an Act unsafe, and fourteen reconciliation questions. Inverting that is the point of this schema.", + "type": "object", + "required": ["opencodelaw_bill", "bill", "status", "operations"], + "additionalProperties": false, + "properties": { + "opencodelaw_bill": { "const": "1.0" }, + "bill": { "$ref": "#/$defs/billMeta" }, + "status": { "$ref": "#/$defs/status" }, + "history": { + "type": "array", + "description": "Every status transition, dated, with who did it and what evidences it. Append-only in practice.", + "items": { "$ref": "#/$defs/historyEntry" } + }, + "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 — operations carry their own text — and the validator additionally asserts that no operation refers to it." + }, + "operations": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/operation" } + }, + "approvals": { + "type": "array", + "description": "Article 16(3) requires approval by the board, the intermediate board and the units of the NGO collectively. All three are required for enactment; none may be inferred.", + "items": { "$ref": "#/$defs/approval" } + }, + "enactment": { "$ref": "#/$defs/enactment" } + }, + + "$defs": { + "semver": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$" + }, + + "status": { + "enum": ["draft", "submitted", "under-review", "returned", "scheduled", "approved", "rejected", "enacted", "applied", "withdrawn", "lapsed"], + "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", + "required": ["short_title", "year", "type", "moved_by", "base_version", "version_bump"], + "additionalProperties": false, + "properties": { + "short_title": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "also_known_as": { + "type": "string", + "description": "The \"Membership Act, 2024\" style familiar name, if the bill declares one." + }, + "year": { "type": "integer", "minimum": 2024 }, + "number": { + "type": ["integer", "null"], + "minimum": 1, + "description": "Assigned by the ICC at submission, per year. Null while the bill is a draft: an unnumbered draft is not yet before anyone." + }, + "type": { + "enum": ["amendment", "corrigendum", "revision"], + "description": "`amendment` is the ordinary case. `corrigendum` corrects a drafting error already on record, and the validator constrains it to those. `revision` is a full re-adoption and is the only type that may renumber." + }, + "moved_by": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "description": "Recorded at drafting, which is what permanently fixes the problem the 2024 Acts left: three instruments whose mover had to be reconstructed afterwards and never was (Q8).", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "role": { "type": "string" }, + "contact": { "type": "string" } + } + }, + "drafted": { "type": ["string", "null"], "format": "date" }, + "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 — so an approval meeting always sees what it is actually voting on." + }, + "version_bump": { + "enum": ["minor", "major"], + "description": "An applied Act bumps the minor version. Major is reserved for a `revision`. There is no patch release of provision text: an editorial edit without an instrument is exactly what this system exists to prevent." + } + } + }, + + "historyEntry": { + "type": "object", + "required": ["date", "to", "actor"], + "additionalProperties": false, + "properties": { + "date": { "type": "string", "format": "date" }, + "from": { "$ref": "#/$defs/status" }, + "to": { "$ref": "#/$defs/status" }, + "actor": { "type": "string", "minLength": 1 }, + "evidence": { "type": ["string", "null"] }, + "note": { "type": "string" } + } + }, + + "operation": { + "type": "object", + "required": ["id", "operation", "target", "scope"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^op-[1-9]\\d*$" }, + "operation": { + "enum": ["substitute", "insert", "omit", "retitle", "reserve"], + "description": "`renumber` is deliberately absent. Anchor stability is a public API and an ordinary amendment never moves a number; renumbering is lawful only inside a `revision` bill with a major bump and an explicit anchor map. The validator rejects it by name with that explanation." + }, + "target": { + "type": "string", + "pattern": "^(preamble|art-[1-9]\\d*(-s-[1-9]\\d*)?)$", + "description": "Must resolve against base_version — except for `insert`, where it must not already exist." + }, + "scope": { "enum": ["article", "clause"] }, + "title": { + "type": "string", + "description": "If stated, this becomes the provision's heading and is recorded as title_source: enacted. Omit to leave the existing heading, which stays editorial." + }, + "text": { + "type": "string", + "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.", + "items": { + "type": "object", + "required": ["number", "title", "text"], + "additionalProperties": false, + "properties": { + "number": { "type": "integer", "minimum": 1 }, + "title": { "type": "string", "minLength": 1 }, + "text": { "type": "string", "minLength": 1 } + } + } + }, + "note": { + "type": "string", + "description": "Drafting note. Explanatory, never operative." + } + }, + "allOf": [ + { + "$comment": "Anything that leaves text behind must carry that text in full. `omit` and `reserve` must not.", + "if": { "properties": { "operation": { "enum": ["substitute", "insert"] } }, "required": ["operation"] }, + "then": { "anyOf": [{ "required": ["text"] }, { "required": ["sections"] }] } + }, + { + "if": { "properties": { "operation": { "const": "retitle" } }, "required": ["operation"] }, + "then": { "required": ["title"] } + }, + { + "if": { "properties": { "operation": { "enum": ["omit", "reserve"] } }, "required": ["operation"] }, + "then": { "required": ["note"], "properties": { "text": false, "sections": false } } + } + ] + }, + + "approval": { + "type": "object", + "required": ["body"], + "additionalProperties": false, + "properties": { + "body": { + "enum": ["board", "intermediate-board", "units"], + "description": "The three bodies Article 16(3) names. All three are required." + }, + "date": { "type": ["string", "null"], "format": "date" }, + "present": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Members present. Quorum and notice are by-laws matters under 16(2); this schema records what happened and does not invent rules the constitution does not state." + }, + "for": { "type": ["integer", "null"], "minimum": 0 }, + "against": { "type": ["integer", "null"], "minimum": 0 }, + "abstain": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Excluded from the denominator: the threshold is of those present AND VOTING." + }, + "evidence": { + "type": ["string", "null"], + "description": "Minutes reference. Required before enactment — an approval with no evidence is an assertion, which is the gap Q13 recorded against the 2024 Acts." + }, + "note": { "type": "string" } + } + }, + + "enactment": { + "type": "object", + "additionalProperties": false, + "properties": { + "act_number": { "type": ["integer", "null"], "minimum": 1 }, + "act_year": { "type": ["integer", "null"], "minimum": 2024 }, + "assent_date": { "type": ["string", "null"], "format": "date" }, + "assented_by": { "type": ["string", "null"] }, + "signed_by": { "type": ["string", "null"] }, + "signed_pdf": { + "type": ["string", "null"], + "description": "Repository-relative path to the signed scan. Required before a bill may be applied." + }, + "signed_pdf_sha256": { + "type": ["string", "null"], + "pattern": "^([a-f0-9]{64})?$", + "description": "Checksum of the signed instrument, so the archived PDF can be proved to be the one that was enacted." + }, + "rendered_from": { + "type": ["string", "null"], + "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/bill-cli.mjs b/src/bill-cli.mjs new file mode 100644 index 0000000..926ad17 --- /dev/null +++ b/src/bill-cli.mjs @@ -0,0 +1,272 @@ +/** + * `opencodelaw bill …` and `opencodelaw act …`. + * + * Every guard failure names the missing thing and what to do about it: these + * messages are read by a coordinator, not a developer. + */ +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 { normalise } from './text-compare.mjs' + +const OPTS = { schema: yaml.CORE_SCHEMA } +const DUMP = { lineWidth: -1, noRefs: true, quotingType: '"' } +const today = () => new Date().toISOString().slice(0, 10) + +const save = (file, bill) => fs.writeFileSync(file, yaml.dump(bill, DUMP)) + +function push (bill, from, to, actor, evidence, note) { + bill.history ??= [] + bill.history.push({ date: today(), from, to, actor, ...(evidence ? { evidence } : {}), ...(note ? { note } : {}) }) + bill.status = to +} + +export function billsDir (year) { + return path.join(ROOT, 'bills', String(year)) +} + +export function listBills () { + const base = path.join(ROOT, 'bills') + if (!fs.existsSync(base)) return [] + const out = [] + 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(f => /\.ya?ml$/.test(f))) { + const file = path.join(dir, f) + try { out.push({ file, rel: path.relative(ROOT, file), bill: loadBill(file) }) } catch { /* skip unreadable */ } + } + } + return out +} + +// --------------------------------------------------------------------------- + +export function billNew ({ type = 'amendment', year = new Date().getFullYear(), name } = {}) { + const template = fs.readFileSync(path.join(ROOT, 'bills/TEMPLATE.yaml'), 'utf8') + const dir = billsDir(year) + fs.mkdirSync(dir, { recursive: true }) + const slug = (name ?? `draft-${Date.now()}`).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + const file = path.join(dir, `${slug}.yaml`) + if (fs.existsSync(file)) throw new Error(`${path.relative(ROOT, file)} already exists`) + + const doc = loadConstitution() + const body = template + .replace(/^ year: \d+$/m, ` year: ${year}`) + .replace(/^ type: amendment .*$/m, ` type: ${type} # amendment | corrigendum | revision`) + .replace(/^ base_version: "[^"]*"$/m, ` base_version: "${doc.info.version}"`) + .replace(/^ drafted: .*$/m, ` drafted: ${today()}`) + fs.writeFileSync(file, body) + return { file, rel: path.relative(ROOT, file), baseVersion: doc.info.version } +} + +export function billSubmit (file, { actor = 'ICC' } = {}) { + const bill = loadBill(file) + if (bill.status !== 'draft') { + throw new Error(`This bill is "${bill.status}", not a draft. Only a draft can be submitted.`) + } + const { problems } = validateBill(file) + if (problems.errors.length) { + throw new Error('This bill does not validate, so it cannot be submitted. Run ' + + '`opencodelaw bill validate` and fix the errors listed there first.') + } + const year = bill.bill.year + const taken = listBills() + .filter(b => b.bill.bill.year === year && typeof b.bill.bill.number === 'number') + .map(b => b.bill.bill.number) + bill.bill.number = (taken.length ? Math.max(...taken) : 0) + 1 + push(bill, 'draft', 'submitted', actor, null, `Numbered Bill ${bill.bill.number} of ${year} on submission.`) + save(file, bill) + return { number: bill.bill.number, year } +} + +// --------------------------------------------------------------------------- + +export function actEnact (file, { actor = 'ICC', signedPdf, signedBy, assentDate, assentedBy } = {}) { + const bill = loadBill(file) + const { problems, tally: t } = validateBill(file) + + const blockers = [] + if (!['approved', 'scheduled', 'submitted', 'under-review'].includes(bill.status)) { + blockers.push(`This bill is "${bill.status}". Only a bill that has been through approval can be enacted.`) + } + if (t.missingBodies.length) { + blockers.push(`No approval is recorded for: ${t.missingBodies.join(', ')}. Article 16(3) requires ` + + 'the board, the intermediate board and the units — all three. Record each meeting\'s tally and ' + + 'its minutes reference before enacting.') + } + if (t.missingEvidence.length) { + blockers.push(`These bodies approved but have no minutes reference: ${t.missingEvidence.join(', ')}. ` + + 'An approval with no evidence is an assertion; add the minutes reference to `evidence`.') + } + if (t.complete && !t.passes) { + blockers.push(`Below the threshold in: ${t.failedBodies.join(', ')}. Article 16(3) needs 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, the stricter reading governs and each body must ' + + 'reach two thirds on its own.' + : '.')) + } + if (!signedPdf) blockers.push('No signed PDF given. Pass --signed-pdf to the scanned, signed instrument.') + else if (!fs.existsSync(path.join(ROOT, signedPdf))) blockers.push(`The signed PDF ${signedPdf} is not on disk.`) + + const schemaErrors = problems.errors.filter(e => !['approvals-incomplete', 'approval-evidence-missing', 'threshold-not-met', 'enactment-incomplete'].includes(e.code)) + for (const e of schemaErrors) blockers.push(e.message) + + if (blockers.length) { + throw new Error('This bill cannot be enacted yet:\n' + blockers.map(b => ` - ${b}`).join('\n')) + } + + const year = bill.bill.year + const takenActs = listBills() + .filter(b => b.bill.enactment?.act_year === year && b.bill.enactment?.act_number) + .map(b => b.bill.enactment.act_number) + const actNumber = (takenActs.length ? Math.max(...takenActs) : 0) + 1 + + const abs = path.join(ROOT, signedPdf) + bill.enactment = { + ...(bill.enactment ?? {}), + act_number: actNumber, + act_year: year, + assent_date: assentDate ?? today(), + assented_by: assentedBy ?? 'Internal Compliance Committee', + signed_by: signedBy ?? null, + signed_pdf: signedPdf, + signed_pdf_sha256: crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex'), + rendered_from: path.relative(ROOT, path.resolve(file)) + } + push(bill, bill.status, 'enacted', actor, signedPdf, `Enacted as Act ${actNumber} of ${year}.`) + save(file, bill) + return { actNumber, year, sha256: bill.enactment.signed_pdf_sha256 } +} + +// --------------------------------------------------------------------------- + +/** + * Apply an enacted Act. The bill's own operations are the expected-change + * 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 } = {}) { + 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() + 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 ` + + `now ${doc.info.version}. It cannot be applied to text it was not approved against. The bill ` + + 'must be rebased and re-approved.') + } + + const { problems } = validateBill(file, { constitution: doc }) + const fatal = problems.errors.filter(e => e.code !== 'enactment-incomplete') + if (fatal.length) { + throw new Error('This Act does not validate:\n' + fatal.map(e => ` - ${e.message}`).join('\n')) + } + + 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 verdict = classifyOperation(op, node, null) + + if (verdict === 'divergent') { + throw new Error( + `ABORT: ${op.target} does not read as this Act expects. It says neither what the Act ` + + 'prescribes nor what the Act was drafted against, which means it was changed by something ' + + 'else. Nothing has been written. Resolve the divergence before applying.') + } + 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])] + } + outcomes.push({ op: op.id, target: op.target, result: 'applied' }) + } + + // --- nothing outside the manifest may have moved ------------------------- + const declared = new Set(bill.operations.map(o => o.target)) + 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) + 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)) + if (outside.length) { + throw new Error(`ABORT: these provisions changed but the Act does not touch them: ${outside.join(', ')}. ` + + 'Nothing has been written.') + } + + // --- version bump -------------------------------------------------------- + const [maj, min, pat] = doc.info.version.split('.').map(Number) + doc.info.version = bill.bill.version_bump === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.${pat}` + doc.info.effective_from = bill.enactment.assent_date + doc.info.legal_status = 'adopted' + + if (!dryRun) { + fs.writeFileSync(path.join(ROOT, 'constitution/current.yaml'), yaml.dump(doc, DUMP)) + push(bill, 'enacted', 'applied', actor, null, `Applied to the constitution; version ${doc.info.version}.`) + save(file, bill) + } + return { actId, outcomes, version: doc.info.version, moved } +} + +export { report, validateBill, tally, loadBill } diff --git a/src/bill-commands.mjs b/src/bill-commands.mjs new file mode 100644 index 0000000..327f9fb --- /dev/null +++ b/src/bill-commands.mjs @@ -0,0 +1,90 @@ +/** + * Argument handling for `opencodelaw bill …` and `opencodelaw act …`. + * Kept apart from bill-cli.mjs so the logic stays importable by tests without + * dragging argv parsing along. + */ +import fs from 'node:fs' +import path from 'node:path' +import { ROOT, validateBill, report, loadBill } from './bill.mjs' +import { billNew, billSubmit, actEnact, actApply } from './bill-cli.mjs' + +const flag = (args, name, fallback = undefined) => { + const i = args.indexOf(`--${name}`) + return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback +} +const has = (args, name) => args.includes(`--${name}`) + +export async function runBillCommand (group, args) { + const [sub, ...rest] = args + const file = rest.find(a => !a.startsWith('--')) + + const need = () => { + if (!file) { console.error(`opencodelaw ${group} ${sub}: give the path to a bill file.`); process.exit(2) } + if (!fs.existsSync(file)) { console.error(`opencodelaw: no such file: ${file}`); process.exit(2) } + return file + } + + try { + if (group === 'bill' && sub === 'new') { + const r = billNew({ type: flag(rest, 'type', 'amendment'), name: flag(rest, 'name'), year: Number(flag(rest, 'year', new Date().getFullYear())) }) + console.log(`Created ${r.rel}`) + console.log(` drafted against constitution ${r.baseVersion}`) + console.log(' Next: open it, describe your change, then run') + console.log(` npx opencodelaw bill validate ${r.rel}`) + return + } + + if (group === 'bill' && sub === 'validate') { + const result = validateBill(need()) + console.log(report(result)) + process.exit(result.problems.errors.length ? 1 : 0) + } + + if (group === 'bill' && sub === 'render') { + const { renderBillText, renderBillHtml } = await import('./bill-render.mjs') + const bill = loadBill(need()) + const doc = JSON.parse(JSON.stringify({})) + const { loadConstitution } = await import('./bill.mjs') + const c = loadConstitution() + const opts = { organization: c.info.organization, orgYear: 'Fourth' } + const base = file.replace(/\.ya?ml$/, '') + fs.writeFileSync(`${base}.txt`, renderBillText(bill, opts)) + fs.writeFileSync(`${base}.html`, renderBillHtml(bill, opts)) + console.log(`Rendered:\n ${path.relative(ROOT, `${base}.txt`)}\n ${path.relative(ROOT, `${base}.html`)}`) + console.log(' Open the HTML and print to PDF to produce the instrument for signature.') + return + } + + if (group === 'bill' && sub === 'submit') { + const r = billSubmit(need(), { actor: flag(rest, 'actor', 'ICC') }) + console.log(`Submitted as Bill ${r.number} of ${r.year}.`) + return + } + + if (group === 'act' && sub === 'enact') { + const r = actEnact(need(), { + signedPdf: flag(rest, 'signed-pdf'), signedBy: flag(rest, 'signed-by'), + assentDate: flag(rest, 'assent-date'), assentedBy: flag(rest, 'assented-by'), + actor: flag(rest, 'actor', 'ICC') + }) + console.log(`Enacted as Act ${r.actNumber} of ${r.year}.`) + console.log(` signed instrument sha256 ${r.sha256}`) + return + } + + if (group === 'act' && sub === 'apply') { + const r = actApply(need(), { dryRun: has(rest, 'dry-run') }) + console.log(`${r.actId}${has(rest, 'dry-run') ? ' (dry run)' : ''}`) + for (const o of r.outcomes) console.log(` ${o.op} ${o.target} ${o.result}`) + console.log(` constitution version -> ${r.version}`) + if (!has(rest, 'dry-run')) console.log(' Next: npm run sync-register && npm run provenance && npm run build') + return + } + + console.error(`opencodelaw ${group}: unknown subcommand "${sub ?? ''}"`) + process.exit(2) + } catch (err) { + console.error(err.message) + process.exit(1) + } +} diff --git a/src/bill.mjs b/src/bill.mjs new file mode 100644 index 0000000..fd67591 --- /dev/null +++ b/src/bill.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +/** + * Bills: validation, the Article 16(3) threshold, and application. + * + * The inversion this phase exists for: the bill YAML is the source of truth and + * the signed PDF is a rendering of it. Because an operation carries the + * COMPLETE resulting text of its target, application is a comparison rather + * than a transcription — which is what makes it idempotent by construction + * instead of by luck. + */ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +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' + +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. + */ +export const THRESHOLD = 2 / 3 + +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 + } + }) + + 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).map(b => b.body), + failedBodies: perBody.filter(b => b.recorded && b.voting != null && !b.passes).map(b => b.body) + } +} + +// --------------------------------------------------------------------------- + +let _ajv +function ajv () { + if (!_ajv) { + _ajv = new Ajv({ allErrors: true, strict: false }) + addFormats(_ajv) + } + return _ajv +} + +export function loadBill (file) { + return yaml.load(fs.readFileSync(file, 'utf8'), OPTS) +} + +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() + +// --------------------------------------------------------------------------- + +class Problems { + constructor () { this.items = [] } + error (code, message, where) { this.items.push({ level: 'error', code, message, where }) } + warn (code, message, where) { this.items.push({ level: 'warn', code, message, where }) } + get errors () { return this.items.filter(i => i.level === 'error') } + get warnings () { return this.items.filter(i => i.level === 'warn') } +} + +/** + * Validate a bill. Messages are written to be read by a coordinator, not a + * developer: each names the missing thing and what to do about it. + */ +export function validateBill (file, { constitution } = {}) { + const p = new Problems() + const bill = loadBill(file) + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, BILL_SCHEMA), 'utf8')) + const validate = ajv().compile(schema) + + if (!validate(bill)) { + for (const e of validate.errors) { + p.error('schema', `${e.instancePath || '/'} ${e.message}`, e.instancePath) + } + return { bill, problems: p, manifest: [] } + } + + const doc = constitution ?? loadConstitution() + const provisions = provisionsOf(doc) + + // --- staleness ----------------------------------------------------------- + if (bill.bill.base_version !== doc.info.version) { + p.error('rebase-required', + `This bill was drafted against constitution version ${bill.bill.base_version}, but the ` + + `constitution is now at ${doc.info.version}. Rebase it: re-check each operation against the ` + + 'current text, update base_version, and re-validate — so the approving bodies see what they ' + + 'are actually voting on.', 'bill.base_version') + } + + // --- numbering ----------------------------------------------------------- + if (bill.bill.number != null && bill.status === 'draft') { + p.error('numbered-draft', + 'This bill has a number but is still a draft. The ICC assigns a number at submission; an ' + + 'unnumbered draft is not yet before anyone.', 'bill.number') + } + if (bill.bill.number == null && !['draft', 'withdrawn'].includes(bill.status)) { + p.error('unnumbered-bill', + `A bill with status "${bill.status}" must carry a number assigned by the ICC.`, 'bill.number') + } + + // --- operations ---------------------------------------------------------- + const seen = new Set() + for (const [i, op] of (bill.operations ?? []).entries()) { + const at = `operations[${i}] (${op.id})` + if (seen.has(op.id)) p.error('duplicate-op', `${at}: id used more than once`, at) + seen.add(op.id) + + // `renumber` is not in the enum, but authors will try the word. + if (String(op.operation) === 'renumber' || /renumber/i.test(op.note ?? '')) { + p.error('renumber-forbidden', + `${at}: renumbering is not available in an ${bill.bill.type} bill. Article numbers are ` + + 'permanent citation handles — every Act, minute and shared link points at them. Renumbering ' + + 'is lawful only in a bill of type "revision", with a major version bump and an explicit ' + + 'anchor map recording where each provision moved.', at) + } + + const existing = provisions.get(op.target) + if (op.operation === 'insert') { + if (existing) { + p.error('insert-exists', + `${at}: cannot insert ${op.target} — it already exists. Use "substitute" to replace its ` + + 'text, or "retitle" to change only its heading.', at) + } + } else if (!existing) { + p.error('target-unresolved', + `${at}: ${op.target} does not exist in constitution version ${bill.bill.base_version}. ` + + 'Check the id against the constitution, or use "insert" if the provision is new.', at) + } + + // C1, asserted even though the schema makes it structurally impossible. + if (/objects_and_reasons|statement of objects/i.test(op.text ?? '') || + /objects_and_reasons|statement of objects/i.test(op.note ?? '')) { + p.error('sor-as-authority', + `${at}: an operation may not derive its content from the Statement of Objects and Reasons. ` + + 'That statement is explanatory and is never a source of authority.', at) + } + + 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)) { + p.warn('no-op', + `${at}: the text proposed for ${op.target} is identical to what it already says. This ` + + 'operation would change nothing.', at) + } + } + } + + // --- corrigendum constraint --------------------------------------------- + if (bill.bill.type === 'corrigendum') { + const flagged = knownDraftingDefects() + for (const [i, op] of (bill.operations ?? []).entries()) { + if (!flagged.has(op.target)) { + p.error('corrigendum-scope', + `operations[${i}] (${op.id}): a corrigendum may only correct a drafting error already on ` + + `record, and ${op.target} is not among them. Provisions currently on record: ` + + `${[...flagged].join(', ') || '(none)'}. If this is a substantive change, it needs an ` + + 'amendment bill.', `operations[${i}]`) + } + } + } + + // --- lifecycle guards ---------------------------------------------------- + const t = tally(bill.approvals) + if (['enacted', 'applied'].includes(bill.status)) { + if (t.missingBodies.length) { + p.error('approvals-incomplete', + `Cannot be ${bill.status}: no approval recorded for ${t.missingBodies.join(', ')}. ` + + 'Article 16(3) requires the board, the intermediate board and the units — all three.', 'approvals') + } + if (t.missingEvidence.length) { + p.error('approval-evidence-missing', + `Cannot be ${bill.status}: ${t.missingEvidence.join(', ')} recorded an approval with no ` + + 'minutes reference. An approval without evidence is an assertion.', 'approvals') + } + if (t.complete && !t.passes) { + p.error('threshold-not-met', + `Cannot be ${bill.status}: ${t.failedBodies.join(', ')} 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 what "collectively" means in Article 16(3), the stricter ' + + 'reading governs and each body must pass separately' : ''}.`, 'approvals') + } + } + if (bill.status === 'applied') { + const e = bill.enactment ?? {} + for (const [k, what] of [['act_number', 'an Act number'], ['assent_date', 'a date of assent'], ['signed_pdf', 'a signed PDF']]) { + if (!e[k]) p.error('enactment-incomplete', `Cannot be applied: enactment is missing ${what}.`, `enactment.${k}`) + } + if (e.signed_pdf) { + const abs = path.join(ROOT, e.signed_pdf) + if (!fs.existsSync(abs)) { + p.error('signed-pdf-missing', `Cannot be applied: ${e.signed_pdf} is not on disk.`, 'enactment.signed_pdf') + } else if (e.signed_pdf_sha256) { + const actual = crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex') + if (actual !== e.signed_pdf_sha256) { + p.error('signed-pdf-mismatch', + `The signed PDF on disk does not match the checksum recorded at enactment. Expected ` + + `${e.signed_pdf_sha256}, found ${actual}. The archived instrument is not the one that was enacted.`, + 'enactment.signed_pdf_sha256') + } + } + } + } + + return { bill, problems: p, manifest: buildBillManifest(bill, doc), tally: t } +} + +/** Provisions currently carrying a recorded drafting defect a corrigendum may fix. */ +function knownDraftingDefects () { + const out = new Set() + const reg = path.join(ROOT, 'acts/register.yaml') + if (fs.existsSync(reg)) { + const r = yaml.load(fs.readFileSync(reg, 'utf8'), OPTS) + for (const act of r.acts ?? []) { + for (const d of act.drafting_discrepancy ?? []) if (d.provision) out.add(d.provision) + } + } + 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') return currentNode ? 'divergent' : 'apply' + if (['omit', 'reserve'].includes(op.operation)) return currentNode ? 'apply' : 'already-applied' + 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 + out.push(`Bill: ${b.short_title}`) + out.push(` ${b.number ? `Bill ${b.number} of ${b.year}` : 'unnumbered draft'} · ${b.type} · status ${bill.status}`) + out.push(` moved by ${b.moved_by?.name ?? '—'} · against constitution ${b.base_version}`) + out.push('') + out.push(`Operations (${manifest.length}):`) + for (const m of manifest) { + out.push(` ${m.id} ${m.operation} ${m.target} (${m.scope})${m.unchanged ? ' — no change' : ''}`) + if (m.title_before !== m.title_after) out.push(` title: ${JSON.stringify(m.title_before)} → ${JSON.stringify(m.title_after)}`) + if (m.before != null) out.push(` before: ${m.before.replace(/\s+/g, ' ').slice(0, 100)}${m.before.length > 100 ? '…' : ''}`) + if (m.after != null) out.push(` after : ${m.after.replace(/\s+/g, ' ').slice(0, 100)}${m.after.length > 100 ? '…' : ''}`) + if (m.after == null) out.push(' after : (provision removed)') + } + if (t) { + out.push('') + out.push('Approvals — Article 16(3) requires all three bodies:') + for (const b2 of t.perBody) { + const state = !b2.recorded ? 'not recorded' + : b2.voting == null ? 'no tally' + : `${b2.for}/${b2.voting} voting = ${(b2.ratio * 100).toFixed(1)}% ${b2.passes ? 'PASS' : 'BELOW 2/3'}` + out.push(` ${b2.body.padEnd(19)} ${state}${b2.recorded && !b2.evidence ? ' (no minutes reference)' : ''}`) + } + if (t.pooled.ratio != null) { + out.push(` pooled ${t.pooled.for}/${t.pooled.voting} = ${(t.pooled.ratio * 100).toFixed(1)}% ${t.pooled.passes ? 'PASS' : 'BELOW 2/3'}`) + out.push(' (both tallies recorded; the stricter per-body reading governs until the board resolves') + out.push(' what "collectively" means in Article 16(3))') + } + } + out.push('') + for (const e of problems.errors) out.push(`ERROR [${e.code}] ${e.message}`) + for (const w of problems.warnings) out.push(`warn [${w.code}] ${w.message}`) + out.push('') + out.push(problems.errors.length ? `FAILED — ${problems.errors.length} error(s)` : `OK — ${problems.warnings.length} warning(s)`) + return out.join('\n') +} diff --git a/src/cli.mjs b/src/cli.mjs index 0aa44f8..f729de0 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -20,6 +20,15 @@ opencodelaw — a YAML-driven renderer for organizational constitutions opencodelaw validate [file] Validate the corpus, or one file against the schema opencodelaw build Render the static site into dist/ opencodelaw spec Regenerate schema/SPEC.md from the JSON Schema + + Amending the constitution — see process/AMENDMENT-PROCESS.md + opencodelaw bill new [--type amendment|corrigendum|revision] [--name ] + opencodelaw bill validate schema, targets, threshold, and the before/after diff + opencodelaw bill render the instrument in house style (text and HTML) + opencodelaw bill submit ICC: assign a bill number, status -> submitted + opencodelaw act enact --signed-pdf [--signed-by ] + opencodelaw act apply [--dry-run] + opencodelaw --version Environment: @@ -80,6 +89,12 @@ switch (command) { console.log(`built ${written.length} pages under ${BASE_PATH} (${og.made} OG images)`) break } + case 'bill': + case 'act': { + const { runBillCommand } = await import('./bill-commands.mjs') + await runBillCommand(command, rest) + break + } case 'spec': await import('./gen-spec.mjs') break diff --git a/src/sync-register.mjs b/src/sync-register.mjs index dafc1c4..45316d7 100644 --- a/src/sync-register.mjs +++ b/src/sync-register.mjs @@ -28,6 +28,44 @@ const CURRENT = path.join(ROOT, 'constitution/current.yaml') const OPTS = { schema: yaml.CORE_SCHEMA } /** What the instruments say each Act does, and whether the text reflects it. */ +/** + * Acts born from bills derive their register entry from the bill file, the same + * way external-pdf Acts derive theirs from acts/text/. Both halves are computed, + * so neither kind can drift from the text it registers. + */ +export function deriveFromBills () { + const dir = path.join(ROOT, 'bills') + const out = new Map() + if (!fs.existsSync(dir)) return out + const doc = yaml.load(fs.readFileSync(CURRENT, 'utf8'), OPTS) + const amendedBy = new Map([doc.preamble, ...(doc.articles ?? [])].map(n => [n.id, new Set(n.amended_by ?? [])])) + + for (const year of fs.readdirSync(dir)) { + const yd = path.join(dir, year) + if (!fs.statSync(yd).isDirectory()) continue + for (const f of fs.readdirSync(yd).filter(f => /\.ya?ml$/.test(f))) { + const bill = yaml.load(fs.readFileSync(path.join(yd, f), 'utf8'), OPTS) + const e = bill?.enactment + if (!e?.act_number || !e?.act_year) continue + const actId = `act-${e.act_number}-${e.act_year}` + const targets = [...new Set(bill.operations.map(o => o.target))] + const reflected = targets.filter(t => amendedBy.get(t)?.has(actId)) + out.set(actId, { + amends: targets, + provisions: bill.operations.map(o => ({ + target: o.target, operation: o.operation, scope: o.scope, + ...(o.title ? { note: `Act states the title as "${o.title}".` } : {}) + })), + application_status: reflected.length === 0 ? 'pending' : (reflected.length === targets.length ? 'applied' : 'partially-applied'), + reflected, + bill_file: path.relative(ROOT, path.join(yd, f)), + origin: 'bill' + }) + } + } + return out +} + export function derive () { const doc = yaml.load(fs.readFileSync(CURRENT, 'utf8'), OPTS) const nodes = [doc.preamble, ...doc.articles] @@ -56,8 +94,10 @@ export function derive () { ? 'pending' : (reflected.length === targets.length ? 'applied' : 'partially-applied') - out.set(actId, { amends: targets, provisions, application_status: status, reflected }) + out.set(actId, { amends: targets, provisions, application_status: status, reflected, origin: 'external-pdf' }) } + // Acts born from bills are derived from their bill file instead. + for (const [id, d] of deriveFromBills()) out.set(id, d) return out } From 7ffc2c5e896b20b313291deab471cc6113d0c17a Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 20:46:19 +0530 Subject: [PATCH 02/13] Phase 8: process docs, house-style renderer, bills page, fixture tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the pipeline. 77 tests plus the 10-step keyboard walkthrough; the constitution is untouched and the tripwire is green with zero permitted changes. process/AMENDMENT-PROCESS.md states the three-body Article 16(3) requirement as settled policy — any checklist or shortcut requiring less is invalid on its face — records that the ICC checks form and never substitutes for a body's approval, and marks the "collectively" reading as pending a board resolution with the stricter per-body two-thirds governing meanwhile. Quorum and notice are left to the by-laws under 16(2). process/PROPOSING.md is the author's guide, with a wholly fictional worked example. src/bill-render.mjs renders the instrument in the 2024 house style, text and print-oriented HTML. src/templates/bills.mjs builds /bills/, which lists rejected and withdrawn bills too — a legislature's failed bills are part of its record. Three defects found and fixed while integrating, none of them cosmetic: - validateBill threw on its SECOND call in a process. Ajv registers a schema under its $id on compile, and I had memoised the instance rather than the compiled validator, so validating two bills in one run failed — which the CLI and the site build would both have hit immediately. The tests had been importing the module under a fresh specifier to work around it. - src/bill-render.mjs hardcoded the organisation, the committee, the signatory title and the footer address. That is content living in the engine, and the reusability test caught it. House style moves to info.instrument in the constitution, where a forking organisation replaces it without editing code. - A test passed `undefined` to a helper with a default parameter, which fires on undefined — so the template was silently validated against the fixture constitution rather than the version it names. Register gains origin: bill | external-pdf; the 2024 Acts stay external-pdf and their history is not rewritten. --- constitution/current.yaml | 8 + examples/starter/bills/fixture-bill.yaml | 214 +++++++ examples/starter/fixture-constitution.yaml | 173 +++++ process/AMENDMENT-PROCESS.md | 274 ++++++++ schema/SPEC.md | 9 + schema/opencodelaw-1.0.schema.json | 30 + src/bill-commands.mjs | 8 +- src/bill-render.mjs | 668 ++++++++++++++++++++ src/bill.mjs | 25 +- src/build.mjs | 30 + src/templates/bills.mjs | 697 +++++++++++++++++++++ src/templates/layout.mjs | 1 + tests/bill.test.mjs | 447 +++++++++++++ 13 files changed, 2574 insertions(+), 10 deletions(-) create mode 100644 examples/starter/bills/fixture-bill.yaml create mode 100644 examples/starter/fixture-constitution.yaml create mode 100644 process/AMENDMENT-PROCESS.md create mode 100644 src/bill-render.mjs create mode 100644 src/templates/bills.mjs create mode 100644 tests/bill.test.mjs diff --git a/constitution/current.yaml b/constitution/current.yaml index 8d2b607..6f87b45 100644 --- a/constitution/current.yaml +++ b/constitution/current.yaml @@ -14,6 +14,14 @@ info: name: Pranay Kiran email: pranay@stmorg.in license: MIT + instrument: + committee: Internal Compliance Committee + signatory_title: Internal Compliance Coordinator of the STM + act_title_pattern: "{ordinal} Constitution Amendment Act, {year}" + enacting_formula: "BE IT ENACTED by the boards in the {ordinal_year} Year of the {organization} as follows:" + footer_lines: + - Flat no:- 25/24, Bowrampet, Quthbullapur (M), Medchal (D), Telangana State -500043 + - Website:- www.stmorg.in preamble: id: preamble title: Preamble diff --git a/examples/starter/bills/fixture-bill.yaml b/examples/starter/bills/fixture-bill.yaml new file mode 100644 index 0000000..c8802cf --- /dev/null +++ b/examples/starter/bills/fixture-bill.yaml @@ -0,0 +1,214 @@ +# --------------------------------------------------------------------------- +# A FIXTURE bill, drafted against examples/starter/fixture-constitution.yaml. +# +# The Marrow Vale Lamplighters' Guild does not exist. This bill amends nothing +# real and must never be pointed at constitution/current.yaml. +# +# It carries ONE operation of every kind the schema allows — substitute, +# insert, omit, retitle, reserve — and a complete set of approvals that clears +# two thirds in each of the three bodies separately as well as pooled, so +# tests/bill.test.mjs has a bill that is valid in every respect and can be +# broken one field at a time to prove each rule. +# --------------------------------------------------------------------------- + +opencodelaw_bill: "1.0" + +bill: + short_title: An Act to amend the Constitution of the Marrow Vale Lamplighters' Guild + also_known_as: Lantern Oil Fund Act, 2026 + year: 2026 + number: 1 + type: amendment + + moved_by: + name: Orla Fenn + role: Keeper of the Oil + contact: orla.fenn@marrowvalelamplighters.org + + drafted: 2026-02-03 + + base_version: "2.1.0" + version_bump: minor + +status: enacted + +history: + - date: 2026-02-03 + to: draft + actor: Orla Fenn + note: Drafted against version 2.1.0 of the articles. + - date: 2026-02-10 + from: draft + to: submitted + actor: Orla Fenn + evidence: Lamp House register, entry 2026/14 + - date: 2026-02-12 + from: submitted + to: under-review + actor: The Keeper of the Roll + note: Numbered Bill 1 of 2026 on submission. + - date: 2026-02-26 + from: under-review + to: returned + actor: The Keeper of the Roll + evidence: Council minutes 2026/03, item 4 + note: Returned for the mover to set out Article 3 in full rather than by reference. + - date: 2026-03-09 + from: returned + to: under-review + actor: Orla Fenn + evidence: Lamp House register, entry 2026/31 + - date: 2026-03-20 + from: under-review + to: scheduled + actor: The Keeper of the Roll + evidence: Notice of meeting, 2026-03-20 + - date: 2026-04-14 + from: scheduled + to: approved + actor: The Keeper of the Roll + evidence: Joint minutes 2026/07 + - date: 2026-04-21 + from: approved + to: enacted + actor: The Council of Wicks + evidence: Act 1 of 2026, assented 2026-04-21 + +# Explanatory only. Nothing below is operative, and no operation may take its +# content from here — the operations carry their own text, and the validator +# refuses any that reaches back into this block. +objects_and_reasons: | + 1. Article 3 is set out afresh so that the duties of a lamplighter and the + position of apprentices appear in the article itself rather than in the + by-laws. + 2. Article 6 is cleared and its number held, meetings of the Council having + been moved to the by-laws. + 3. Article 7 is repealed, the Taper Fund being absorbed into the fund + established by the new Article 10. + 4. The heading of Article 8, section 1 is given by this Act, the existing + heading having been supplied by an editor. + 5. Article 10 is inserted to establish the Lantern Oil Fund. + +operations: + # substitute — the whole article, sections and all, written out in full. + - id: op-1 + operation: substitute + target: art-3 + scope: article + title: Membership + text: | + Membership of the Guild is open to any person of the Vale who keeps a + lamp, abides by these articles, and is entered in the Lantern Roll. + sections: + - number: 1 + title: Admission + text: | + A person becomes a lamplighter on being entered in the Lantern Roll + by the Council, and not before. + - number: 2 + title: Duties + text: | + A lamplighter shall light the lamps of their assigned round at dusk, + extinguish them at dawn, and report a broken glass to the Keeper of + the Oil within three days. + - number: 3 + title: Apprentices + text: | + An apprentice may be entered in the Lantern Roll on the + recommendation of two lamplighters, and shall not climb a ladder + unaccompanied until their second Michaelmas. + - number: 4 + title: Withdrawal + text: | + A lamplighter may withdraw at any time by returning their taper to + the Keeper of the Roll, who shall record the date in the Lantern + Roll. + + # insert — a provision that does not yet exist at the base version. + - id: op-2 + operation: insert + target: art-10 + scope: article + title: The Lantern Oil Fund + text: | + 1. There shall be a Lantern Oil Fund, held by the Keeper of the Oil, into + which are paid the admission pennies of lamplighters and any gift made + to the Guild for the buying of oil. + 2. The Fund may be expended only on oil, wicks, glass, tapers and the + repair of ladders. + 3. The Keeper of the Oil shall lay an account of the Fund before the + Guild at each Michaelmas meeting. + + # omit — the provision is repealed. Its number is never reused, so no text + # is supplied and none may be. + - id: op-3 + operation: omit + target: art-7 + scope: article + note: | + The Taper Fund is absorbed into the Lantern Oil Fund established by + Article 10. Article 7 keeps its number as an omitted provision so that + every citation ever made to it still resolves. + + # retitle — the heading only. The text of the section is untouched, and the + # heading stops being editorial and becomes enacted. + - id: op-4 + operation: retitle + target: art-8-s-1 + scope: clause + title: The Lantern Roll + note: | + The existing heading was supplied by an editor. This Act states it, so + that the heading a reader sees is the heading the Guild passed. + + # reserve — the number is held against a future instrument, and carries no + # text in the meantime. + - id: op-5 + operation: reserve + target: art-6 + scope: article + note: | + Meetings of the Council pass to the by-laws. Article 6 is reserved + rather than omitted because the Guild intends to occupy the number again + when the by-laws are settled. + +# Article 16(3) of the enacting organization's constitution requires the board, +# the intermediate board and the units. All three are recorded here, with the +# minutes that evidence each. Abstentions are excluded from the threshold: the +# requirement is of those present AND VOTING. +# +# Each body clears two thirds on its own (86.4%, 78.6%, 75.7%), and the pooled +# vote clears it too (58 of 73, 79.5%), so this bill is properly passed on +# either reading of "collectively". +approvals: + - body: board + date: 2026-04-11 + present: 24 + for: 19 + against: 3 + abstain: 2 + evidence: Board minutes 2026/09, item 2 + - body: intermediate-board + date: 2026-04-12 + present: 15 + for: 11 + against: 3 + abstain: 1 + evidence: Intermediate board minutes 2026/04, item 5 + - body: units + date: 2026-04-14 + present: 41 + for: 28 + against: 9 + abstain: 4 + evidence: Joint minutes 2026/07, annexure B (unit-by-unit poll) + +enactment: + act_number: 1 + act_year: 2026 + assent_date: 2026-04-21 + assented_by: The Council of Wicks + signed_by: Orla Fenn, Keeper of the Oil + signed_pdf: ~ + signed_pdf_sha256: ~ + rendered_from: examples/starter/bills/fixture-bill.yaml diff --git a/examples/starter/fixture-constitution.yaml b/examples/starter/fixture-constitution.yaml new file mode 100644 index 0000000..3246da6 --- /dev/null +++ b/examples/starter/fixture-constitution.yaml @@ -0,0 +1,173 @@ +# --------------------------------------------------------------------------- +# A FIXTURE constitution. +# +# The society below does not exist. Nothing in this file is anyone's law, and +# no line of it may be copied into constitution/current.yaml — it is here so +# that tests/bill.test.mjs can run every bill operation (substitute, insert, +# omit, retitle, reserve) against a document small enough to read in one +# sitting and fictional enough that nobody mistakes it for authority. +# +# Unlike examples/starter/constitution.yaml — which ships a placeholder +# contact on purpose, so that copying it and deploying fails the build — this +# document is FULLY valid, contact included. The bill tests assert that, so a +# fixture defect can never be mistaken for a bill defect. +# +# Article 9 below gives this society the same three approving bodies the +# amendment schema names, because that is the vocabulary the pipeline speaks. +# --------------------------------------------------------------------------- + +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: current + 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/AMENDMENT-PROCESS.md b/process/AMENDMENT-PROCESS.md new file mode 100644 index 0000000..fb7e040 --- /dev/null +++ b/process/AMENDMENT-PROCESS.md @@ -0,0 +1,274 @@ +# The Amendment Process + +How a change to the constitution of Service to Mankind Welfare Association is proposed, +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). + +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 +amendment before it is enacted, an **Act** is the same instrument after enactment; an **operation** +is one change to one provision; **evidence** is a pointer to the underlying record, normally a +minutes reference. + +--- + +## 1. What this is, and why it works this way + +A proposed amendment is written as a **bill**: a single YAML file in this repository, which states +in full what each affected provision will say once the amendment is made. **That file is the source +of truth, and the signed PDF is a rendering of it** — not the other way round. Because each +operation carries the complete resulting text of its target rather than an instruction like *"insert +after the words…"*, applying an Act is a comparison rather than a transcription: applying the same +Act twice changes nothing the second time, and an Act whose target no longer reads as expected +refuses to apply instead of corrupting it. + +This exists because the alternative was tried. The three Acts of 2024 were authored as prose PDFs +and applied to the document by hand, which left Act 1 applied to Articles 9 to 12 and 18 but never +to Articles 6 and 7; left no record of who applied any of it, or when; produced a splice into +Article 7 that made re-running the Act unsafe; and raised fourteen reconciliation questions, several +of which could only be settled by the board two years after the fact. An amendment written as a bill +is machine-applicable from the moment it is drafted, so none of those failures has anywhere to +occur. + +--- + +## 2. Roles + +**Author (mover).** The constitution says who approves an amendment and who enacts it. It says +nothing about who may propose one. In that silence the default is the wide one: **any member may +author a bill.** The author's name goes in `moved_by` when the file is created — before the bill has +a number, before it goes to anyone. That single field permanently closes a whole class of problem: +the movers of the 2024 Acts had to be reconstructed after the fact, and never were. All three +instruments are signed by the ICC Coordinator and record no proposer at all, and the one name a +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. + +> **The ICC checks form, not substance, and its attestation never substitutes for a body's +> approval.** + +That sentence is the precise gap in the 2024 Acts. Each of them records assent from the ICC and a +signature from the ICC Coordinator — and nothing else. No approval by the board, the intermediate +board or the units appears on the face of any of them, which is what Q13 of +[`RECONCILIATION.md`](../RECONCILIATION.md) records. In this workflow the ICC's attestation is +recorded under `enactment` and is never written into `approvals`; the two are separate fields +because they are separate acts by separate bodies. + +**The board, the intermediate board, and the units.** The three approval bodies named by Article +16(3). Each votes separately, and each vote is recorded separately. + +**The board, again — enactment.** Under Article 16(1), changes to the constitution are made by the +board. Once 16(3) is satisfied, it is the board that enacts. + +**The ICC Coordinator.** Signs the rendered Act, continuing the precedent of P. Priya's signature on +all three 2024 Acts. The signature is recorded in `enactment.signed_by`, and the signed scan is +archived with its SHA-256 checksum so the filed instrument can be proved to be the one that was +enacted. + +--- + +## 3. Lifecycle + +``` +draft → submitted → under-review → (returned ⇄ under-review) → scheduled → approved → enacted → applied + ↘ rejected +``` + +Plus **withdrawn** (the author's, any time before approval) and **lapsed** (the board's, set by +hand). + +- **draft** — the author's file. No number yet. Validate it as often as you like; validation prints + the before-and-after of every operation, which is what the approval meetings will read. +- **submitted** — the author has put it before the ICC, which numbers it. +- **under-review** — the ICC checks drafting: every target resolves, `base_version` matches the + constitution as it stands, every operation carries complete text, nothing draws authority from the + Statement of Objects and Reasons. +- **returned** — sent back with reasons. The author revises and it returns to review. This loop may + run as many times as it needs to. +- **scheduled** — meetings of all three bodies are set. +- **approved** / **rejected** — the outcome of those three votes, recorded body by body. +- **enacted** — the board enacts; the Act is numbered, assent is recorded, and the PDF is rendered + from the bill, signed, and archived with its checksum. +- **applied** — the Act is applied mechanically to `constitution/current.yaml`, and the version + bumps. +- **withdrawn** — the author's, at any point before approval. +- **lapsed** — the board's, and set by hand. **No timeout is invented**, because the constitution + states none. + +**Every transition writes a dated entry to the bill's `history`:** date, from, to, actor, and +evidence. That is the record of who moved this bill, when, and on what basis. A transition with no +history entry did not happen. + +--- + +## 4. Numbering + +- **Bills are numbered by the ICC at submission, per year** — "Bill 1 of 2026". Numbering restarts + each year. +- **Acts are numbered at enactment, per year** — "Act 1 of 2026". +- **A draft carries no number.** An unnumbered draft is not yet before anyone, and the validator + rejects a numbered draft for that reason. +- A bill's number and its Act number are different numbers for different events. They need not + match, and usually will not. + +--- + +## 5. Thresholds — Article 16(3) + +Article 16, as in force, reads: + +> 1. Any changes to the constitution of the NGO should be done by the board of the NGO. +> 2. The ammenments should be done according to the by-laws of the NGO. +> 3. All proposed amendments must be approved by a 2/3rd present and voting of the board of the NGO, +> the intermediate board of the NGO and units of the NGO collectively. + +*(Clause 2's spelling is as enacted. It is not corrected here.)* + +**This is settled constitutional policy, not a configurable default.** No bill may be enacted on +fewer than three bodies' recorded approvals, under any circumstances. The approval path cannot be +simplified at the process level: any checklist, tool, or shortcut that would let a bill through on +less than Article 16(3) is invalid on its face, whatever convenience it offers. An approval recorded +without a minutes reference is an assertion rather than an approval, and does not count. + +**The two readings of "collectively."** The word genuinely bears two meanings, and the Act does not +choose between them: + +- **Pooled** — all three bodies sit together and two thirds of the combined vote carries it. +- **Per body** — two thirds within each of the three bodies separately. + +**Until the board adopts a reading by resolution, the stricter reading governs:** at least two thirds +of those present and voting in **each** body, separately. **Both tallies are recorded** — per body +and pooled — so that the record satisfies whichever reading is eventually adopted, and an Act +enacted today cannot be challenged tomorrow on the ground that the other reading was the right one. + +**This choice is pending a board resolution.** When the board resolves it, the resolution is +recorded and this section is amended to match. Nobody should decide it in passing while clerking a +bill. + +**Abstentions are excluded from the denominator.** The text says present *and voting*. If 12 members +are present, 8 vote for, 2 against and 2 abstain, the denominator is 10, not 12 — so 8/10 is 80% and +the body passes. + +**A worked case where the readings differ.** Board 8 for / 4 against (66.7%, passes); intermediate +board 5 / 3 (62.5%, below); units 20 / 6 (76.9%, passes). Pooled: 33 of 46 = 71.7%, which passes. +Under the stricter reading the bill **cannot be enacted**, because the intermediate board did not +reach two thirds on its own. The tooling records both figures and refuses the enactment, naming the +body that fell short. + +--- + +## 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, +quorum, how a meeting is convened and chaired, how votes are taken, whether proxies or written +resolutions count, and whether units vote as units or as members.** None of that is in the +constitution. + +This workflow therefore **records what happened and does not invent rules the constitution omits**: +for each body, the date, the number present, the votes for, against and abstaining, and the minutes +reference. It computes the Article 16(3) threshold from those numbers, and it does nothing else with +them. It will not fail a bill for want of a quorum it has no authority to define, and it will not +excuse one either — if a meeting fell short of a by-law requirement, that is a by-laws question, and +it belongs in the minutes the `evidence` field points to. + +The same restraint is why **lapsed** is set by hand. A bill that has sat untouched for a year does +not expire on its own, because no provision says it does. + +--- + +## 7. Versioning + +- **Every applied Act bumps the MINOR version.** 3.0.0 → 3.1.0 → 3.2.0. One instrument, one version. +- **MAJOR is reserved for `type: revision`** — a full re-adoption, and the only kind of bill that may + renumber provisions. +- **There are no PATCH releases of provision text.** A patch would mean the wording of a provision + changed with no instrument behind it, and that is exactly what this system exists to prevent. + Editorial edits to provision text remain forbidden; if the text is wrong, it takes a corrigendum + (§8) or an amendment. + +Fixes to the site, the engine, or the tooling are not versions of the constitution and never move +this number. + +**Staleness and rebasing.** A bill records the `base_version` it was drafted against. If the +constitution moves on before the bill is approved, validation fails with a rebase instruction — so +an approval meeting always sees what it is actually voting on. A rebase after approval means the +bodies approved text against a base that no longer exists, so **a rebased bill goes back through +approval.** The applier refuses to apply an Act to text it was not approved against. + +--- + +## 8. Corrigenda + +A **corrigendum** (`type: corrigendum`) corrects a drafting error in an instrument already on the +record — a mistake in the drafting, not a change of mind about the policy. + +- **It follows the same approval path as any other bill.** Three bodies, two thirds, minutes. + Nothing about it is lighter. It is called a corrigendum to describe what it does, not to travel + faster. +- **The validator constrains it to errors already on record** — the `drafting_discrepancy` entries in + [`acts/register.yaml`](../acts/register.yaml) and the standing notes carried with the reconciliation + record. A corrigendum aimed at a provision with no recorded defect is rejected, with the message + that a substantive change needs an amendment bill. +- **Recording a defect comes first, and is not itself an amendment.** Writing down that an instrument + is defective changes no provision text; it puts the defect where a corrigendum can reach it. + +**The first expected use** is Article 6. Act 1 of 2024 defines *Unit Board Member* (clause 3) and +*Coordinator* (clause 4) in identical words, differing only by "in STM" and "in the STM", so as +enacted the two roles are legally indistinguishable. The defect was published rather than repaired, +which was the right call: the engine records the law, it does not correct the law. See Q5 in +[`RECONCILIATION.md`](../RECONCILIATION.md). + +**This document does not draft that fix, and no one should read it as a task to do so.** Deciding +what those two definitions ought to say is an act of authorship, and it belongs to the board. + +--- + +## 9. Reference + +### Statuses + +| Status | What it means | Whose file it is | +|---|---|---| +| `draft` | Being written. No number. | Author | +| `submitted` | Before the ICC; numbered on arrival. | ICC | +| `under-review` | Drafting being checked against the validator. | ICC | +| `returned` | Sent back to the author with reasons. | Author | +| `scheduled` | Meetings of all three bodies are set. | ICC | +| `approved` | All three bodies approved; ready to enact. | Board | +| `rejected` | Did not carry. Terminal — the lifecycle gives it no exit; a fresh bill starts at `draft`. | — | +| `enacted` | Numbered as an Act, assented, signed, archived. | ICC | +| `applied` | Written into `constitution/current.yaml`; version bumped. | ICC | +| `withdrawn` | Pulled by the author before approval. | Author | +| `lapsed` | Closed by the board, by hand. | Board | + +### Who may move each transition + +| Transition | Who moves it | What must be on record | +|---|---|---| +| `draft` → `submitted` | Author | Validates with no errors; ICC assigns the bill number | +| `submitted` → `under-review` | ICC | — | +| `under-review` → `returned` | ICC | The reasons, in the history note | +| `returned` → `under-review` | Author | The revised bill, validating | +| `under-review` → `scheduled` | ICC | Meeting dates for all three bodies | +| `scheduled` → `approved` | ICC records the result | Three tallies and three minutes references, each at or above two thirds | +| `scheduled` → `rejected` | ICC records the result | The tallies that fell short, with minutes | +| `approved` → `enacted` | **Board** enacts under 16(1); ICC attests, the ICC Coordinator signs | Act number, assent date, signed PDF and its SHA-256 | +| `enacted` → `applied` | ICC | The applier's outcome per operation, the new version, and confirmation that nothing outside the bill's operations moved | +| any status before `approved` → `withdrawn` | Author | A note giving the reason | +| any status before `enacted` → `lapsed` | Board | The board's decision. Set by hand; no timeout exists | + +### The rules that cannot be traded away + +1. Three bodies approve, or the bill is not enacted. There is no exception and no shortcut. +2. Two thirds of those present and voting in **each** body, until the board resolves what + "collectively" means. +3. An approval without a minutes reference does not count. +4. The ICC's attestation is not a body's approval. +5. Provision text changes only by an enacted instrument. Never by hand, never as a patch release. +6. `moved_by` is filled in at drafting, not reconstructed later. diff --git a/schema/SPEC.md b/schema/SPEC.md index 004f8c7..e0fc714 100644 --- a/schema/SPEC.md +++ b/schema/SPEC.md @@ -66,6 +66,7 @@ Records text that entered the constitution outside the amendment process. Its pu | `contact` | object | no |
no extra keys | | `termsOfService` | `string` (uri) | no | | | `license` | `string` | no | | +| `instrument` | object | no | House style for rendered instruments: the wording an Act carries on its face. Content, not engine — an organisation adopting this repository replaces these and the renderer follows.
no extra keys | **Conditional rules** @@ -137,6 +138,10 @@ Type: `active` \| `omitted` \| `reserved` | Key | Type | Required | Notes | |---|---|---|---| | `id` | [actId](#actid) | **yes** | | +| `origin` | `bill` \| `external-pdf` | no | How the Act entered the record. `external-pdf` means it was authored as prose and transcribed — the three 2024 Acts, whose history is not rewritten into the new format. `bill` means it was born from a bill file, which is the only path available for new Acts. | +| `bill_file` | `string` | no | Repository-relative path to the bill this Act was rendered from. Required for origin: bill. | +| `signed_pdf_sha256` | `string` | no | Checksum of the signed instrument, so the archived PDF can be proved to be the one enacted.
pattern `^[a-f0-9]{64}$` | +| `approvals` | array of object | no | Per-body tallies under Article 16(3). Recorded for Acts born from bills; the 2024 Acts predate the pipeline and carry only the procedure block. | | `number` | `integer` | **yes** |
min 1 | | `year` | `integer` | **yes** |
min 1900 | | `title` | [nonEmptyText](#nonemptytext) | **yes** | | @@ -155,6 +160,10 @@ Type: `active` \| `omitted` \| `reserved` | `amends` | array of `string` | no | Provision ids this Act touches. Every entry must resolve against the constitution. | | `provisions` | array of object | no | | +**Conditional rules** + +- When `origin` is `bill`: `bill_file` becomes required. + ### reconciliationState Declares that this document does not yet reflect every instrument that amends it. Rendered as a visible banner on every page; the banner is generated from this block and is never hardcoded, so it cannot fall out of date with the document it describes. diff --git a/schema/opencodelaw-1.0.schema.json b/schema/opencodelaw-1.0.schema.json index 3b49e2d..e9214ac 100644 --- a/schema/opencodelaw-1.0.schema.json +++ b/schema/opencodelaw-1.0.schema.json @@ -171,6 +171,36 @@ }, "license": { "type": "string" + }, + "instrument": { + "type": "object", + "description": "House style for rendered instruments: the wording an Act carries on its face. Content, not engine \u2014 an organisation adopting this repository replaces these and the renderer follows.", + "additionalProperties": false, + "properties": { + "committee": { + "type": "string", + "description": "The body whose assent an Act records, e.g. \"Internal Compliance Committee\"." + }, + "signatory_title": { + "type": "string", + "description": "Printed under the signature, e.g. \"Internal Compliance Coordinator of the STM\"." + }, + "act_title_pattern": { + "type": "string", + "description": "How an Act is titled; {ordinal} and {year} are substituted. Default \"{ordinal} Constitution Amendment Act, {year}\"." + }, + "enacting_formula": { + "type": "string", + "description": "The BE IT ENACTED line; {ordinal_year} and {organization} are substituted." + }, + "footer_lines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Address and contact lines printed at the foot of every page of an instrument." + } + } } }, "allOf": [ diff --git a/src/bill-commands.mjs b/src/bill-commands.mjs index 327f9fb..f4b7a33 100644 --- a/src/bill-commands.mjs +++ b/src/bill-commands.mjs @@ -43,10 +43,14 @@ export async function runBillCommand (group, args) { if (group === 'bill' && sub === 'render') { const { renderBillText, renderBillHtml } = await import('./bill-render.mjs') const bill = loadBill(need()) - const doc = JSON.parse(JSON.stringify({})) const { loadConstitution } = await import('./bill.mjs') const c = loadConstitution() - const opts = { organization: c.info.organization, orgYear: 'Fourth' } + // House style comes from the constitution, never from the engine. + const opts = { + organization: c.info.organization, + orgYear: flag(rest, 'org-year', 'Fourth'), + ...(c.info.instrument ?? {}) + } const base = file.replace(/\.ya?ml$/, '') fs.writeFileSync(`${base}.txt`, renderBillText(bill, opts)) fs.writeFileSync(`${base}.html`, renderBillHtml(bill, opts)) diff --git a/src/bill-render.mjs b/src/bill-render.mjs new file mode 100644 index 0000000..e16ed46 --- /dev/null +++ b/src/bill-render.mjs @@ -0,0 +1,668 @@ +/** + * Rendering a bill as the instrument — plain text, and print-ready HTML. + * + * THIS OUTPUT IS A RENDERING AND NEVER THE SOURCE OF TRUTH. The bill YAML is: + * it carries the complete resulting text of every provision it touches, and the + * applier works from that. What this module produces — and the PDF a browser + * prints from it — is a rendering of that YAML for people to read, sign and + * file. If the two ever disagree, the YAML governs and the paper is stale. + * + * The 2024 Acts ran the other way round: prose PDFs, transcribed into the YAML + * by hand. That is where the half-applied constitution, the unrecorded + * application, the art-7 line splice and the fourteen reconciliation questions + * came from. Inverting it is the whole point of this phase. + * + * House style is not invented here. It is copied from the extracted text of the + * 2024 Acts — acts/text/first-…, second-… and third-constitution-amendment-act- + * 2024.txt — down to the wording of the assent line, the operative item + * phrasings ("Amendment to Article 13:", "Insertion of new Article 21 - Financial + * Management:", "Amendment of Preamble:"), the ————— separator, the signature + * block and the address footer. + * + * Two things are deliberately NOT printed: + * - `operations[].note` — a drafting note is explanatory, and convention C1 + * keeps explanation out of operative text. + * - approval tallies — Article 16(3) approvals live in the bill record and in + * the register, not on the face of the instrument. + * + * None of that wording is hardcoded here. House style is CONTENT: it lives in + * the constitution's `info.instrument` — committee, signatory_title, + * act_title_pattern, enacting_formula, footer_lines — and is passed in: + * + * renderBillText(bill, { info: doc.info, orgYear: 2, titles }) + * + * `organization`, `committee`, `signatoryTitle`, `footer`, `actTitlePattern` + * and `enactingFormula` may also be passed directly and override the block. An + * organisation adopting this repository replaces the YAML and the renderer + * follows without being edited. + */ +import { escapeHtml } from './lib/paths.mjs' + +/** Column width of the plain-text instrument, matching the extracted Acts. */ +export const PAGE_WIDTH = 90 + +// House style is CONTENT, not engine: it lives in the constitution's +// `info.instrument` and is passed in. An organisation adopting this repository +// replaces those values and the renderer follows without being edited. The +// address footer of the 2024 Acts is therefore NOT a default here — it is in +// constitution/current.yaml, read from the extracted Act text. +export const FOOTER_LINES = Object.freeze([]) + +export const DEFAULT_ORGANIZATION = '' +export const DEFAULT_COMMITTEE = 'Internal Compliance Committee' +export const DEFAULT_SIGNATORY_TITLE = '' +export const DEFAULT_ACT_TITLE_PATTERN = '{ordinal} Constitution Amendment Act, {year}' +export const SEPARATOR = '—'.repeat(5) + +/** + * House style, resolved from `info.instrument` with direct options overriding. + * Snake_case in the YAML, camelCase in the option — the YAML is the contract a + * fork edits, the options are what a caller passes. + */ +export function houseStyle (options = {}) { + const info = options.info ?? null + const inst = options.instrument ?? info?.instrument ?? {} + return { + organization: options.organization ?? info?.organization ?? DEFAULT_ORGANIZATION, + committee: options.committee ?? inst.committee ?? DEFAULT_COMMITTEE, + signatoryTitle: options.signatoryTitle ?? inst.signatory_title ?? DEFAULT_SIGNATORY_TITLE, + footer: options.footer ?? inst.footer_lines ?? FOOTER_LINES, + actTitlePattern: options.actTitlePattern ?? inst.act_title_pattern ?? DEFAULT_ACT_TITLE_PATTERN, + enactingFormula: options.enactingFormula ?? inst.enacting_formula ?? null + } +} + +const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December'] + +const ORDINAL_WORDS = ['', 'First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth', 'Seventh', + 'Eighth', 'Ninth', 'Tenth', 'Eleventh', 'Twelfth', 'Thirteenth', 'Fourteenth', 'Fifteenth', + 'Sixteenth', 'Seventeenth', 'Eighteenth', 'Nineteenth'] +const TENS_ORDINAL = ['', '', 'Twentieth', 'Thirtieth', 'Fortieth', 'Fiftieth', 'Sixtieth', + 'Seventieth', 'Eightieth', 'Ninetieth'] +const TENS_CARDINAL = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', + 'Eighty', 'Ninety'] + +/** 3 → "3rd". Used for the day in the assent line. */ +export function ordinalNumber (n) { + const i = Number(n) + if (!Number.isFinite(i)) return String(n) + const rem100 = Math.abs(i) % 100 + const rem10 = Math.abs(i) % 10 + const suffix = rem100 >= 11 && rem100 <= 13 ? 'th' + : rem10 === 1 ? 'st' : rem10 === 2 ? 'nd' : rem10 === 3 ? 'rd' : 'th' + return `${i}${suffix}` +} + +/** 3 → "Third". The Act number becomes the ordinal in the title. */ +export function ordinalWord (n) { + const i = Number(n) + if (!Number.isInteger(i) || i < 1) return null + if (i < 20) return ORDINAL_WORDS[i] + if (i < 100) { + const tens = Math.floor(i / 10) + const unit = i % 10 + return unit === 0 ? TENS_ORDINAL[tens] : `${TENS_CARDINAL[tens]}-${ORDINAL_WORDS[unit]}` + } + return ordinalNumber(i) +} + +/** "2024-05-03" → "3rd May, 2024". Parsed as a plain date; no timezone shift. */ +export function formatLongDate (iso) { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso ?? '')) + if (!m) return iso ? String(iso) : null + const [, y, mo, d] = m + const month = MONTHS[Number(mo) - 1] + if (!month) return String(iso) + return `${ordinalNumber(Number(d))} ${month}, ${y}` +} + +/** + * Provision headings for targets the bill does not restate itself. A + * clause-scoped operation prints its article's heading above the clause, and + * only the constitution knows it. + */ +export function titlesFromConstitution (doc) { + const out = {} + if (!doc) return out + if (doc.preamble) out[doc.preamble.id ?? 'preamble'] = doc.preamble.title ?? 'Preamble' + for (const a of doc.articles ?? []) { + out[a.id] = a.title + for (const s of a.sections ?? []) out[s.id] = s.title + } + return out +} + +// --- target parsing -------------------------------------------------------- + +const TARGET_RE = /^art-(\d+)(?:-s-(\d+))?$/ + +function parseTarget (target) { + if (target === 'preamble') return { preamble: true, articleId: 'preamble' } + const m = TARGET_RE.exec(String(target ?? '')) + if (!m) return { preamble: false, articleId: String(target ?? ''), unknown: true } + return { + preamble: false, + article: Number(m[1]), + clause: m[2] ? Number(m[2]) : null, + articleId: `art-${m[1]}` + } +} + +/** [1,2,3,4,5] → "1,2,3,4 and 5" — exactly how Act 1 of 2024 lists clauses. */ +function joinClauses (ns) { + if (!ns.length) return '' + if (ns.length === 1) return String(ns[0]) + return `${ns.slice(0, -1).join(',')} and ${ns[ns.length - 1]}` +} + +function clausesOf (op, t) { + if (t.clause != null) return [t.clause] + if (op.scope === 'clause' && op.sections?.length) { + return op.sections.map(s => Number(s.number)).filter(Number.isFinite) + } + return [] +} + +/** + * The operative item line. The phrasings are the Acts' own: + * Act 1: "Amendment to Article 6, clause 1,2,3,4 and 5:", "Insertion of new + * Article 18 - Suspension/Termination:" + * Act 2: "Amendment of Preamble:", "Amendment of Article 15 - Exit Process" + * (a retitle stated on the item line) + * Act 3: "Amendment to Article 13:" + */ +function itemLabel (op, title) { + const t = parseTarget(op.target) + const clauses = clausesOf(op, t) + const where = t.preamble + ? 'Preamble' + : `Article ${t.article ?? op.target}${clauses.length ? `, clause ${joinClauses(clauses)}` : ''}` + + switch (op.operation) { + case 'insert': + return `Insertion of new ${where}${title ? ` - ${title}` : ''}:` + case 'omit': + return `Omission of ${where}:` + case 'reserve': + return `Reservation of ${where}:` + case 'retitle': + return `Amendment to ${where}${op.title ? ` - ${op.title}` : ''}:` + case 'substitute': + default: + // "Amendment OF Preamble" is how Act 2 phrases it; "Amendment TO Article N" + // is how Acts 1 and 3 phrase an article. + return t.preamble ? 'Amendment of Preamble:' : `Amendment to ${where}:` + } +} + +/** "13. Annual Report" — the restated provision's heading, or null if unknown. */ +function provisionHeading (op, t, titles) { + if (['omit', 'reserve', 'retitle'].includes(op.operation)) return null + const title = op.title ?? titles[t.articleId] ?? null + if (t.preamble) return title ?? 'Preamble' + if (t.article == null) return title + return title ? `${t.article}. ${title}` : null +} + +// --- text blocks ----------------------------------------------------------- + +/** + * An enumerator opens a new block: "(1)", "1.", "(a)", "(iv)". Everything else + * is a continuation of the item above it, so a provision authored with hard + * line breaks still sets as flowing paragraphs while its list structure holds. + */ +const ENUM_RE = /^\s*(?:\((?:\d+|[a-z]|[ivxl]+)\)|(?:\d+|[a-z]|[ivxl]+)[.)])\s+/i + +/** Authored text → blocks, each with the relative indent the author gave it. */ +export function blocks (text) { + const out = [] + let cur = null + for (const raw of String(text ?? '').replace(/\r\n?/g, '\n').split('\n')) { + if (!raw.trim()) { cur = null; continue } + const indent = Math.min((raw.match(/^ */)?.[0].length ?? 0), 12) + if (cur && !ENUM_RE.test(raw)) { + cur.text += ' ' + raw.trim() + } else { + cur = { indent, text: raw.trim() } + out.push(cur) + } + } + return out +} + +function wrap (text, width) { + const words = String(text).split(/\s+/).filter(Boolean) + const lines = [] + let cur = '' + for (const w of words) { + if (!cur) cur = w + else if (cur.length + 1 + w.length <= width) cur += ' ' + w + else { lines.push(cur); cur = w } + } + if (cur) lines.push(cur) + return lines.length ? lines : [''] +} + +const pad = n => ' '.repeat(Math.max(0, n)) +const centre = (s, width = PAGE_WIDTH) => pad(Math.floor((width - s.length) / 2)) + s +const right = (s, width = PAGE_WIDTH) => pad(width - s.length) + s + +function textBlocks (text, indent, width = PAGE_WIDTH) { + return blocks(text).flatMap(b => + wrap(b.text, Math.max(20, width - indent - b.indent)).map(l => pad(indent + b.indent) + l)) +} + +// --- the model both renderers read ---------------------------------------- + +const ENACTED = new Set(['enacted', 'applied']) + +/** + * Everything the instrument states, resolved once, so the text and the HTML + * cannot drift apart. Works for a bill that has never been before anyone and + * for an Act that was signed two years ago. + */ +export function billInstrument (bill, options = {}) { + const meta = bill?.bill ?? {} + const enactment = bill?.enactment ?? {} + const { organization, committee, signatoryTitle, footer, actTitlePattern, enactingFormula } = + houseStyle(options) + const titles = options.titles instanceof Map + ? Object.fromEntries(options.titles) + : (options.titles ?? {}) + + // The Act number is assigned at enactment and the assent date at signature. + // They are read separately: a rendering states what the record states, and + // never infers one from the other. + const actNumber = enactment.act_number ?? null + const actYear = enactment.act_year ?? meta.year ?? null + const assentDate = enactment.assent_date ?? null + const enacted = !!assentDate && (actNumber != null || ENACTED.has(bill?.status)) + + // --- masthead ------------------------------------------------------------ + const header = actNumber != null + ? `Act No. : ${actNumber} of ${actYear}` + : meta.number != null + ? `Bill No. : ${meta.number} of ${meta.year}` + : 'DRAFT BILL — unnumbered' + + const assenter = withThe(enactment.assented_by ?? committee) + + const assent = enacted + ? [`The following Act received the assent from ${assenter} on ${formatLongDate(assentDate)}.`] + : proposedLines(bill?.status) + + // --- titles -------------------------------------------------------------- + // An unenacted bill has no Act number, so it cannot carry the ordinal: it is + // titled by its own short title until enactment supplies one. + const ordinal = actNumber == null ? null : ordinalWord(actNumber) + const patterned = String(actTitlePattern) + .replaceAll('{ordinal}', ordinal ?? '') + .replaceAll('{year}', String(actYear ?? '')) + // The pattern is house style for an amending Act. A corrigendum and a + // revision are named off it rather than off a second pattern, so a fork + // configures one line and still gets all three right. + const title = !ordinal + ? (meta.short_title ?? 'A Bill') + : meta.type === 'revision' + ? patterned.replace(/\bAmendment\b/, 'Revision') + : meta.type === 'corrigendum' + ? patterned.replace(/\bAmendment\b/, 'Amendment (Corrigendum)') + : patterned + + const of = organization ? ` of ${organization}` : '' + const longTitle = meta.type === 'revision' + ? `Further to revise the Constitution${of}` + : `Further to amend the Constitution${of}` + + const yearWord = typeof options.orgYear === 'number' + ? ordinalWord(options.orgYear) + : /^\d+$/.test(String(options.orgYear ?? '')) ? ordinalWord(Number(options.orgYear)) + : (options.orgYear ?? null) + const enacting = enactingLine({ enactingFormula, yearWord, organization }) + + // --- operative items ----------------------------------------------------- + const items = [] + if (meta.also_known_as) { + items.push({ label: `This Act also called the “${meta.also_known_as}”`, heading: null, body: [] }) + } + for (const op of bill?.operations ?? []) { + const t = parseTarget(op.target) + const heading = provisionHeading(op, t, titles) + items.push({ + id: op.id, + label: itemLabel(op, op.title ?? (op.operation === 'insert' ? null : titles[t.articleId] ?? null)), + heading, + body: bodyOf(op, t, titles) + }) + } + + return { + header, + committee: committee.toUpperCase(), + assent, + enacted, + status: bill?.status ?? null, + title, + longTitle, + enacting, + items, + objectsAndReasons: (bill?.objects_and_reasons ?? '').trim() || null, + signature: signatureOf(enactment.signed_by, signatoryTitle), + renderedFrom: options.renderedFrom ?? enactment.rendered_from ?? null, + footer + } +} + +/** "the Example Society Incorporated", but never "the The Guild". */ +const withThe = name => { + const s = String(name ?? '').trim() + if (!s) return '' + return /^the\s/i.test(s) ? s : `the ${s}` +} + +/** + * The BE IT ENACTED line. `enacting_formula` from `info.instrument` governs, + * with {ordinal_year} and {organization} substituted. + * + * The formula is only used when everything it asks for is known: a formula that + * names the regnal year, rendered for a bill whose caller supplied none, would + * print "in the Year of" — a hole in an instrument. The neutral wording is + * used instead. Nothing is guessed; a year the caller did not state is a year + * the instrument does not claim. + */ +function enactingLine ({ enactingFormula, yearWord, organization }) { + if (enactingFormula && (yearWord || !enactingFormula.includes('{ordinal_year}'))) { + return String(enactingFormula) + .replaceAll('{ordinal_year}', yearWord ?? '') + .replaceAll('{organization}', organization ?? '') + } + const org = organization ? ` of ${withThe(organization)}` : '' + return yearWord + ? `BE IT ENACTED by the boards in the ${yearWord} Year${org ? org : ''} as follows:` + : `BE IT ENACTED by the boards${org} as follows:` +} + +/** + * The name over the office, as the 2024 Acts print it: + * + * P. Priya, + * + * + * A record that carries the office with the name ("P. Priya, Internal Compliance + * Coordinator") is split, and the configured office wins when it is the fuller + * form of the same office — so the office is stated once, and a signatory + * holding some other office is not silently retitled. + * + * An unsigned bill gets a standing line in place of a name: a bill that is not + * signed must not read as though it were. + */ +function signatureOf (signedBy, signatoryTitle) { + const raw = String(signedBy ?? '').trim().replace(/[,\s]+$/, '') + if (!raw) return { name: null, placeholder: '(to be signed on enactment)', title: signatoryTitle } + const comma = raw.indexOf(',') + if (comma === -1) return { name: raw, placeholder: null, title: signatoryTitle } + const name = raw.slice(0, comma).trim() + const office = raw.slice(comma + 1).trim() + const configured = String(signatoryTitle ?? '') + const title = office && !configured.toLowerCase().startsWith(office.toLowerCase()) + ? office + : configured + return { name, placeholder: null, title } +} + +/** The line that stands where the assent line stands on an enacted Act. */ +function proposedLines (status) { + switch (status) { + case 'rejected': + return ['REJECTED — not enacted. This Bill was not approved and has no force.'] + case 'withdrawn': + return ['WITHDRAWN by the mover — not enacted. This Bill has no force.'] + case 'lapsed': + return ['LAPSED — not enacted. This Bill has no force.'] + default: + return [ + 'PROPOSED — not yet enacted. This Bill has received no assent and has no force.', + 'It requires the approval of the board, the intermediate board and the units of the NGO ' + + 'under Article 16(3).' + ] + } +} + +/** + * The restated provision, as blocks of authored text. A clause-scoped operation + * whose text does not already carry its own marker gets one, the way Act 1 + * prints "(4) Any person who donates…" under the heading "7. Membership". + */ +function bodyOf (op, t, titles) { + if (['omit', 'reserve', 'retitle'].includes(op.operation)) return [] + const out = [] + const clauses = clausesOf(op, t) + const single = t.clause != null ? t.clause : null + + if (op.text?.trim()) { + let text = op.text + if (single != null) { + const clauseTitle = op.title ?? titles[op.target] ?? null + if (clauseTitle) out.push({ heading: `(${single}) ${clauseTitle}` }) + else if (!ENUM_RE.test(text)) text = `(${single}) ${text.replace(/^\s+/, '')}` + } + out.push({ text }) + } else if (single != null && !op.sections?.length && clauses.length) { + out.push({ heading: `(${single})` }) + } + + for (const s of op.sections ?? []) { + out.push({ heading: `(${s.number}) ${s.title}` }) + out.push({ text: s.text }) + } + return out +} + +// --- plain text ------------------------------------------------------------ + +/** + * The instrument as plain text, laid out like the extracted 2024 Acts: the Act + * number right-aligned at the top, the committee and title centred, operative + * items numbered from 1, the Statement of Objects and Reasons last, then the + * separator, the signature and the address footer. + */ +export function renderBillText (bill, options = {}) { + const m = billInstrument(bill, options) + const width = options.width ?? PAGE_WIDTH + const out = [] + + out.push(right(m.header, width), '') + out.push(centre(m.committee, width), '') + for (const line of m.assent) out.push(...wrap(line, width)) + out.push('', '') + out.push(centre(m.title, width), '') + out.push(...wrap(m.longTitle, width - 8).map(l => centre(l, width)), '') + out.push(...wrap(m.enacting, width - 6).map(l => pad(3) + l), '') + + m.items.forEach((item, i) => { + out.push(`${pad(2)}${String(i + 1).padStart(2)}. ${item.label}`) + if (item.heading || item.body.length) out.push('') + if (item.heading) out.push(pad(8) + item.heading) + for (const part of item.body) { + if (part.heading) out.push(pad(8) + part.heading) + if (part.text) out.push(...textBlocks(part.text, 8, width)) + } + out.push('') + }) + + if (m.objectsAndReasons) { + out.push('', centre('STATEMENT OF OBJECTS AND REASONS', width), '') + out.push(...textBlocks(m.objectsAndReasons, 3, width)) + out.push('') + } + + out.push('', centre(SEPARATOR, width), '', '') + out.push(right(m.signature.name ? `${m.signature.name},` : m.signature.placeholder, width - 2)) + if (m.signature.title) out.push(right(m.signature.title, width - 2)) + out.push('', '') + if (m.renderedFrom) { + out.push(...wrap( + `Rendered from ${m.renderedFrom}. That bill file is the source of truth; this document is a ` + + 'rendering of it.', width - 6).map(l => pad(3) + l)) + out.push('') + } + for (const line of m.footer) out.push(centre(line, width)) + + return out.join('\n').replace(/[ \t]+$/gm, '') + '\n' +} + +// --- HTML ------------------------------------------------------------------ + +/** + * Print CSS, inline: the page must be self-contained, so a browser opening the + * file offline and printing to PDF produces the signable instrument. The + * address footer is fixed to the bottom of every printed sheet, which is where + * the 2024 Acts carry it. + */ +const STYLE = ` + :root { color-scheme: light; } + @page { size: A4; margin: 22mm 20mm 30mm; } + * { box-sizing: border-box; } + body { + margin: 0; background: #f2f0ec; color: #14110d; + font-family: Georgia, "Times New Roman", Times, serif; + font-size: 12pt; line-height: 1.55; text-rendering: optimizeLegibility; + } + .sheet { + max-width: 190mm; margin: 24px auto 96px; padding: 26mm 22mm 22mm; + background: #fff; box-shadow: 0 2px 24px rgba(0,0,0,.14); + } + .no { text-align: right; margin: 0 0 1.6em; font-size: 11pt; } + .committee { + text-align: center; font-size: 13.5pt; font-weight: 700; letter-spacing: .06em; + margin: 0 0 1.4em; text-transform: uppercase; + } + .assent { margin: 0 0 3em; } + .assent--proposed { font-weight: 700; } + .title { text-align: center; font-size: 16pt; font-weight: 700; margin: 0 0 1em; } + .long-title { text-align: center; margin: 0 0 1.8em; } + .enacting { margin: 0 0 1.8em; text-indent: 2em; } + ol.items { margin: 0; padding-left: 2.4em; } + ol.items > li { margin: 0 0 1.6em; } + ol.items > li::marker { font-weight: 700; } + .item__label { margin: 0; font-weight: 700; break-after: avoid; page-break-after: avoid; } + .provision { margin: .8em 0 0 1.2em; } + .provision__heading { margin: 0 0 .35em; font-weight: 700; break-after: avoid; page-break-after: avoid; } + .provision p { margin: 0 0 .5em; } + .sor { margin: 2.4em 0 0; } + .sor__heading { + text-align: center; font-size: 12.5pt; font-weight: 700; letter-spacing: .04em; + margin: 0 0 1em; text-transform: uppercase; + } + .sor p { margin: 0 0 .5em; } + .rule { text-align: center; margin: 2.4em 0; letter-spacing: .1em; } + .sign { margin: 3em 0 0; text-align: right; break-inside: avoid; page-break-inside: avoid; } + .sign p { margin: 0; } + .sign__name { margin-bottom: .3em !important; } + .sign__placeholder { color: #5a544b; font-style: italic; } + .provenance { margin: 3em 0 0; font-size: 9.5pt; color: #5a544b; } + .foot { + margin: 3em 0 0; padding-top: 1em; border-top: 1px solid #d8d2c8; + text-align: center; font-size: 9.5pt; color: #3c362e; + } + .foot p { margin: 0; } + @media print { + body { background: #fff; font-size: 11pt; orphans: 2; widows: 2; } + .sheet { max-width: none; margin: 0; padding: 0; box-shadow: none; } + /* The printed 2024 Acts repeat the address on every sheet. A position:fixed + footer is the only way a browser can do that, and it is not safe: Chrome + paints it over the text at the top of later pages, which silently drops + operative text. The footer therefore prints once, at the end. Losing a + line of an Act to reproduce a letterhead is not a trade worth making. */ + .foot { break-inside: avoid; page-break-inside: avoid; } + a { color: inherit; text-decoration: none; } + } +` + +/** One authored block, keeping the relative indent the author gave a nested list. */ +const para = b => + `${escapeHtml(b.text)}

` + +/** Authored text → one `

` per block, as an array of single-line strings. */ +const htmlBlocks = text => blocks(text).map(para) + +/** + * The instrument as a self-contained HTML document — no external stylesheet, no + * font, no script, nothing to fetch. Open it and print to PDF and you have the + * signable paper; the YAML it came from remains the source of truth. + */ +export function renderBillHtml (bill, options = {}) { + const m = billInstrument(bill, options) + + const items = m.items.map(item => { + const parts = [] + if (item.heading) parts.push(`

${escapeHtml(item.heading)}

`) + for (const part of item.body) { + if (part.heading) parts.push(`

${escapeHtml(part.heading)}

`) + if (part.text) parts.push(...htmlBlocks(part.text)) + } + return [ + ` `, + `

${escapeHtml(item.label)}

`, + ...(parts.length + ? ['
', ...parts.map(p => ` ${p}`), '
'] + : []), + ' ' + ].join('\n') + }).join('\n') + + const assent = m.assent.map(l => + `

${escapeHtml(l)}

`).join('\n ') + + return ` + + + + + ${escapeHtml(m.title)} + + + + +
+

${escapeHtml(m.header)}

+

${escapeHtml(m.committee)}

+ ${assent} +

${escapeHtml(m.title)}

+

${escapeHtml(m.longTitle)}

+

${escapeHtml(m.enacting)}

+ +
    +${items} +
+${m.objectsAndReasons + ? ` +
+

Statement of Objects and Reasons

+ ${htmlBlocks(m.objectsAndReasons).join('\n ')} +
` + : ''} + +

${SEPARATOR}

+ +
+ ${m.signature.name + ? `

${escapeHtml(m.signature.name)},

` + : `

${escapeHtml(m.signature.placeholder)}

`} + ${m.signature.title ? `

${escapeHtml(m.signature.title)}

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

Rendered from ${escapeHtml(m.renderedFrom)}. That bill file is the source of + truth; this document is a rendering of it.

` + : ''} + +
+ ${m.footer.map(l => `

${escapeHtml(l)}

`).join('\n ')} +
+
+ + +` +} diff --git a/src/bill.mjs b/src/bill.mjs index fd67591..0379bf5 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -80,13 +80,23 @@ export function tally (approvals = []) { // --------------------------------------------------------------------------- -let _ajv -function ajv () { - if (!_ajv) { - _ajv = new Ajv({ allErrors: true, strict: false }) - addFormats(_ajv) +/** + * The COMPILED validator is memoised, not just the Ajv instance. + * + * Ajv registers a schema under its `$id` on compile, so compiling the same + * schema twice against one instance throws "schema with key or id … already + * exists". That made validating two bills in a single process fail on the + * second — which a CLI that validates a directory, or the site build, would hit + * immediately. + */ +let _validateBillSchema +function billValidator () { + if (!_validateBillSchema) { + const ajv = new Ajv({ allErrors: true, strict: false }) + addFormats(ajv) + _validateBillSchema = ajv.compile(JSON.parse(fs.readFileSync(path.join(ROOT, BILL_SCHEMA), 'utf8'))) } - return _ajv + return _validateBillSchema } export function loadBill (file) { @@ -133,8 +143,7 @@ class Problems { export function validateBill (file, { constitution } = {}) { const p = new Problems() const bill = loadBill(file) - const schema = JSON.parse(fs.readFileSync(path.join(ROOT, BILL_SCHEMA), 'utf8')) - const validate = ajv().compile(schema) + const validate = billValidator() if (!validate(bill)) { for (const e of validate.errors) { diff --git a/src/build.mjs b/src/build.mjs index 3b2fef6..ce69735 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -16,6 +16,7 @@ import { toPlainText, renderMarkdown } from './lib/markdown.mjs' import { layout, tocSections } from './templates/layout.mjs' import { renderArticle, renderPreamble } from './templates/provision.mjs' import { generateOgImages } from './og.mjs' +import { billsMain, billsTocItems } from './templates/bills.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OUT = () => path.join(ROOT, process.env.OUT_DIR ?? 'dist') @@ -306,6 +307,35 @@ export function build () { main: amendmentsMain(register, state, doc, { url, slugs, actIndex, markKind }) }))) + // ---- bills: the legislative record, including what failed ---- + const bills = (() => { + const base = path.join(ROOT, 'bills') + if (!fs.existsSync(base)) return [] + const out = [] + 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))) { + try { out.push({ file: `bills/${year}/${f}`, bill: load(`bills/${year}/${f}`) }) } catch { /* skip unreadable */ } + } + } + return out + })() + + written.push(write('bills/index.html', layout({ + ...shell, + showToc: false, + toc: tocSections(billsTocItems(bills), { heading: 'Bills' }), + 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' }, + jsonLd: [breadcrumbLd([ + { name: 'Constitution', url: abs('') }, { name: 'Bills', url: abs('bills/') } + ])], + main: billsMain(bills, { url, escapeHtml, actIndex }) + }))) + // ---- archive ---- const versions = fs.existsSync(path.join(ROOT, VERSIONS_DIR)) ? fs.readdirSync(path.join(ROOT, VERSIONS_DIR)).filter(f => f.endsWith('.yaml')).sort() diff --git a/src/templates/bills.mjs b/src/templates/bills.mjs new file mode 100644 index 0000000..319292b --- /dev/null +++ b/src/templates/bills.mjs @@ -0,0 +1,697 @@ +/** + * The bills register — /bills/. + * + * The amendment register looks backwards, at instruments already signed. This + * page looks forwards, at the ones that are not. It exists because of how the + * three Acts of 2024 reached the constitution: authored as prose PDFs and + * applied to the YAML by hand, which produced a half-applied document, an + * application nobody recorded, a line splice that made re-running an Act + * unsafe, an amend-or-insert ambiguity in Article 15, and fourteen + * reconciliation questions. + * + * The inversion this page publishes: THE BILL YAML IS THE SOURCE OF TRUTH AND + * THE SIGNED PDF IS A RENDERING OF IT. Because each operation carries the + * complete resulting text of its target, the diff shown here is the same + * comparison the applier makes — so what an approval meeting reads is what + * will land in the constitution, and application is mechanical by construction + * rather than by care. + * + * Nothing here is org-specific: it renders whatever bills/ holds, and links to + * repository files through the repository recorded in package.json. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { escapeHtml as defaultEscapeHtml } from '../lib/paths.mjs' +import { tally, buildBillManifest, REQUIRED_BODIES, THRESHOLD } from '../bill.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + +/** Statuses at which a bill is before someone, so its diff is public. */ +const PUBLIC_DIFF = new Set([ + 'submitted', 'under-review', 'returned', 'scheduled', 'approved', + 'rejected', 'enacted', 'applied', 'withdrawn', 'lapsed' +]) + +/** Statuses whose record is a vote, so the tallies are shown whatever they say. */ +const VOTED = new Set(['approved', 'rejected', 'enacted', 'applied']) + +/** + * What each status means, in one line. The pill carries the word; this carries + * the meaning — a reader should never have to know the enum to read the page, + * and status must never be encoded in colour alone. + */ +const STATUS_GLOSS = { + draft: 'with its mover; not yet before anyone, and it carries no number', + submitted: 'lodged with the ICC, which numbered it', + 'under-review': 'with the ICC for drafting review', + returned: 'sent back to the mover for revision; it can be resubmitted', + scheduled: 'listed for the approval meetings of all three bodies', + approved: 'carried by the board, the intermediate board and the units', + rejected: 'not carried — and it stays on this register', + enacted: 'assented to and signed as an Act', + applied: 'written into the constitution, which took the resulting version', + withdrawn: 'withdrawn by its mover before approval', + lapsed: 'lapsed by decision of the board, recorded by hand' +} + +const TYPE_GLOSS = { + amendment: 'the ordinary instrument; bumps the minor version', + corrigendum: 'corrects a drafting error already on record, and nothing else', + revision: 'a full re-adoption; the only type that may renumber, and the only major bump' +} + +const BODY_LABEL = { + board: 'Board', + 'intermediate-board': 'Intermediate board', + units: 'Units' +} + +const pct = r => `${(r * 100).toFixed(1)}%` + +/** + * The threshold in words. A fraction printed as a percentage is a different + * rule: two thirds of 63 votes is 42, but "66.7% of 63" is 43. Prose says the + * fraction; only actual tallies get a percentage. + */ +const thresholdWords = Math.abs(THRESHOLD - 2 / 3) < 1e-9 ? 'two thirds' : pct(THRESHOLD) + +/** Small numbers are words in prose; the count still comes from the constant. */ +const numberWord = n => ['no', 'one', 'two', 'three', 'four', 'five', 'six'][n] ?? String(n) + +// --------------------------------------------------------------------------- + +/** + * Where a repository file lives on the web, read from this repository's own + * package.json rather than hardcoded. A fork's bills page must point at the + * fork's `process/PROPOSING.md`, not at ours; `HEAD` avoids naming a branch. + */ +let _sourceBase +function repositorySourceBase () { + if (_sourceBase !== undefined) return _sourceBase + _sourceBase = null + try { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) + const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url + if (raw) { + const web = String(raw) + .replace(/^git\+/, '') + .replace(/^git@([^:]+):/, 'https://$1/') + .replace(/^ssh:\/\/git@/, 'https://') + .replace(/\.git$/, '') + .replace(/\/+$/, '') + if (/^https?:\/\//.test(web)) _sourceBase = `${web}/blob/HEAD/` + } + } catch { /* no package.json, or an unreadable one: fall back to plain text */ } + return _sourceBase +} + +/** A repository file, linked where we know the repository and set in code where we do not. */ +function sourceFile (rel, { esc, sourceBase }) { + const base = sourceBase === undefined ? repositorySourceBase() : sourceBase + const code = `${esc(rel)}` + return base ? `${code}` : code +} + +/** Path as the repository sees it, so a card names the file it was built from. */ +const repoRelative = file => { + const s = String(file ?? '').replace(/\\/g, '/') + const i = s.indexOf('bills/') + return i >= 0 ? s.slice(i) : s.split('/').pop() ?? s +} + +// --------------------------------------------------------------------------- + +const yearOf = item => { + const y = item?.bill?.bill?.year + if (Number.isInteger(y)) return y + const m = /(?:^|\/)(\d{4})\//.exec(String(item?.file ?? '').replace(/\\/g, '/')) + return m ? Number(m[1]) : null +} + +/** A stable in-page anchor. Numbered bills get their number; drafts get their filename. */ +export function billAnchor (item) { + const b = item?.bill?.bill ?? {} + const year = yearOf(item) + if (b.number != null && year != null) return `bill-${year}-${b.number}` + const stem = (repoRelative(item?.file).split('/').pop() ?? 'bill') + .replace(/\.ya?ml$/i, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return `bill-draft-${stem || 'untitled'}` +} + +const billLabel = item => { + const b = item?.bill?.bill ?? {} + const year = yearOf(item) + return b.number != null && year != null ? `Bill ${b.number} of ${year}` : 'unnumbered draft' +} + +/** + * Newest year first; within a year by the number the ICC assigned, with + * unnumbered drafts last — they are not yet before anyone, so they cannot take + * a place in the running order. + */ +function groupByYear (bills = []) { + const groups = new Map() + for (const item of bills) { + const year = yearOf(item) + const key = year ?? 'undated' + if (!groups.has(key)) groups.set(key, { year, items: [] }) + groups.get(key).items.push(item) + } + const out = [...groups.values()].sort((a, b) => (b.year ?? -Infinity) - (a.year ?? -Infinity)) + for (const g of out) { + g.items.sort((a, b) => { + const na = a?.bill?.bill?.number ?? Infinity + const nb = b?.bill?.bill?.number ?? Infinity + if (na !== nb) return na - nb + const ta = String(a?.bill?.bill?.short_title ?? '') + const tb = String(b?.bill?.bill?.short_title ?? '') + return ta.localeCompare(tb) || String(a.file).localeCompare(String(b.file)) + }) + } + return out +} + +const groupId = g => `bills-${g.year ?? 'undated'}` +const groupLabel = g => (g.year == null ? 'Undated bills' : `Bills of ${g.year}`) + +/** Contents entries for the page shell. Standing sections are there in every state. */ +export function billsTocItems (bills = []) { + const items = groupByYear(bills).map(g => ({ + id: groupId(g), + label: groupLabel(g), + sub: g.items.map(item => ({ + id: billAnchor(item), + label: item?.bill?.bill?.short_title ?? billLabel(item) + })) + })) + if (!items.length) items.push({ id: 'no-bills', label: 'No bills before the board' }) + items.push({ id: 'approval-threshold', label: 'Approval under Article 16(3)' }) + items.push({ id: 'lifecycle', label: 'How a bill becomes an Act' }) + return items +} + +// --------------------------------------------------------------------------- + +/** + * The diff an approval meeting reads. Built from the constitution the caller + * passes, never from one this module goes and finds: rendering a bill against a + * document it was not drafted against would show a "before" that is not the + * before, which is the precise class of error this pipeline exists to prevent. + */ +function manifestOf (item, doc) { + if (Array.isArray(item?.manifest)) return item.manifest + if (doc) { + try { return buildBillManifest(item.bill, doc) } catch { /* fall through */ } + } + // Degraded but honest: the operations still carry their own resulting text, + // so the "after" is knowable without the constitution. The "before" is not. + return (item?.bill?.operations ?? []).map(op => ({ + id: op.id, + operation: op.operation, + target: op.target, + scope: op.scope, + exists: null, + title_before: null, + title_after: op.title ?? null, + before: undefined, + after: ['omit', 'reserve'].includes(op.operation) + ? null + : [op.text ?? '', ...(op.sections ?? []).flatMap(s => [s.title ?? '', s.text ?? ''])].join('\n').trim(), + unchanged: false + })) +} + +function targetLabel (target) { + if (target === 'preamble') return 'Preamble' + const m = /^art-(\d+)(?:-s-(\d+))?$/.exec(String(target ?? '')) + if (!m) return String(target ?? '—') + return m[2] ? `Article ${m[1]}, clause ${m[2]}` : `Article ${m[1]}` +} + +/** + * Anchors are the citation handle, so a target links to where the provision + * actually is. A provision a bill would create has nowhere to link yet, and a + * link to an anchor that is not there is worse than none. + */ +function targetHref (target, { url, slugs }) { + const m = /^art-(\d+)(?:-s-(\d+))?$/.exec(String(target ?? '')) + const articleId = m ? `art-${m[1]}` : null + if (articleId && slugs?.get?.(articleId)) { + const base = url(`articles/${slugs.get(articleId)}/`) + return m[2] ? `${base}#${target}` : base + } + return `${url('')}#${target}` +} + +/** Provision text, escaped, with clause breaks kept. Never markdown: this is a quotation. */ +function quoteText (s, esc) { + const body = String(s ?? '').trim() + if (!body) return '

(no text)

' + return body.split(/\n{2,}/) + .map(para => `

${para.split('\n').map(l => esc(l.trim())).filter(Boolean).join('
')}

`) + .join('') +} + +// --------------------------------------------------------------------------- + +function operationsList (item, manifest, { url, esc, slugs }) { + const ops = item?.bill?.operations ?? [] + const rows = manifest.map((m, i) => { + const op = ops.find(o => o.id === m.id) ?? ops[i] ?? {} + const label = targetLabel(m.target) + const heading = m.title_after ?? m.title_before + const named = m.exists === false || m.operation === 'insert' + ? `${esc(label)} new provision` + : `${esc(label)}` + const retitled = m.title_before && m.title_after && m.title_before !== m.title_after + ? ` · heading ${esc(m.title_before)}${esc(m.title_after)}` + : (m.operation === 'retitle' && m.title_after ? ` · heading ${esc(m.title_after)}` : '') + return `
  • ${named} — ${esc(m.operation)} (${esc(m.scope ?? op.scope ?? 'article')} scope)` + + `${heading && !retitled ? ` · ${esc(heading)}` : retitled}` + + `${m.unchanged ? ' · would change nothing' : ''}
  • ` + }).join('') + return rows + ? `
      ${rows}
    ` + : '

    This bill records no operations.

    ' +} + +function operationDiffs (item, manifest, { esc }) { + const ops = item?.bill?.operations ?? [] + return manifest.map((m, i) => { + const op = ops.find(o => o.id === m.id) ?? ops[i] ?? {} + const before = m.before === undefined + ? '

    The text in force is not available in this build. Run npx opencodelaw bill validate against the bill file for the full comparison.

    ' + : (m.before == null + ? '

    This provision does not exist in the constitution as it stands.

    ' + : `
    ${quoteText(m.before, esc)}
    `) + // A retitle carries no text, and rendering its empty `after` as a + // quotation would read as "this provision is emptied" — which is the + // opposite of what a retitle does. + const after = m.operation === 'retitle' + ? '

    The text of the provision is not touched. Only its heading changes.

    ' + : (m.after == null + ? `

    The provision is ${m.operation === 'reserve' ? 'reserved — its number is held and its text removed' : 'removed'}.

    ` + : `
    ${quoteText(m.after, esc)}
    `) + + return ` +
    + ${esc(m.id)} — ${esc(m.operation)} ${esc(targetLabel(m.target))}${m.unchanged ? ' (no change)' : ''} + ${m.title_before !== m.title_after + ? `

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

    ` + : ''} +

    Before — the text in force

    + ${before} +

    After — the whole provision, as this bill would leave it

    + ${after} + ${m.unchanged ? '

    This operation would leave the provision exactly as it reads today.

    ' : ''} + ${op.note ? `

    Drafting note — explanatory, never operative

    ${esc(op.note)}

    ` : ''} +
    ` + }).join('') +} + +/** + * The Article 16(3) record. + * + * Both readings of "collectively" are printed, every time, because the board + * has not adopted one: a pooled vote of all three sitting together, or two + * thirds within each body. Until it resolves by resolution the stricter + * per-body reading governs, and recording both is what keeps an Act from being + * challenged under whichever reading is eventually adopted. + */ +function approvalsBlock (item, { esc }) { + const bill = item?.bill ?? {} + const t = item?.tally ?? tally(bill.approvals ?? []) + const anyRecorded = t.perBody.some(b => b.recorded && b.voting != null) + if (!VOTED.has(bill.status) && !anyRecorded) return '' + + const cell = b => { + if (!b.recorded) return 'No approval recorded' + const n = v => (v == null ? '—' : String(v)) + return `${esc(b.date ?? '—')}${n(b.present)}${n(b.for)}` + + `${n(b.against)}${n(b.abstain)}` + + `${b.voting == null ? '—' : `${b.for}/${b.voting} = ${pct(b.ratio)}`}` + } + const verdict = b => { + if (!b.recorded || b.voting == null) return 'no tally' + return b.passes + ? 'meets 2/3' + : 'below 2/3' + } + + // The minutes reference sits under the body's name rather than in a column + // of its own: it is prose in a table of numbers, and a column that has to + // wrap pushes the tally off the side of the page. + const rows = t.perBody.map(b => ` + + ${esc(BODY_LABEL[b.body] ?? b.body)} + ${b.recorded + ? (b.evidence ? esc(b.evidence) : 'no minutes reference') + : 'no meeting recorded'} + ${cell(b)} + ${verdict(b)} + `).join('') + + // Sums are shown only where every body reported the column; a total built by + // treating an unreported number as zero is a fabricated total. + const sum = key => { + const vals = t.perBody.map(b => (b.recorded ? b[key] : null)) + return vals.every(v => v != null) ? String(vals.reduce((n, v) => n + v, 0)) : '—' + } + const pooled = t.pooled.ratio == null + ? '' + : ` + All three pooled + —${sum('present')}${t.pooled.for} + ${t.pooled.voting - t.pooled.for}${sum('abstain')} + ${t.pooled.for}/${t.pooled.voting} = ${pct(t.pooled.ratio)} + ${t.pooled.passes + ? 'meets 2/3' + : 'below 2/3'} + ` + + const notes = [] + if (t.missingBodies.length) { + notes.push(`No approval is recorded for ${t.missingBodies.map(b => esc(BODY_LABEL[b] ?? b)).join(' and ')}. ` + + 'Article 16(3) names all three bodies and none of them may be inferred from another.') + } + if (t.missingEvidence.length) { + notes.push(`${t.missingEvidence.map(b => esc(BODY_LABEL[b] ?? b)).join(' and ')} recorded an approval with ` + + 'no minutes reference. An approval without evidence is an assertion.') + } + if (t.complete && !t.passes && t.pooled.passes) { + notes.push('The pooled vote reaches two thirds; ' + + `${t.failedBodies.map(b => esc(BODY_LABEL[b] ?? b)).join(' and ')} did not. Under the stricter reading, ` + + 'which governs until the board resolves what "collectively" means, that is not an approval.') + } + + return ` +

    Approvals — Article 16(3)

    +
    + + + + + + + + + + ${rows}${pooled} +
    Approval tallies by body, with the minutes reference and the + pooled vote
    Body and minutesDatePresentForAgainstAbstainOf those votingTwo thirds?
    +
    +

    Both readings are recorded: ${thresholdWords} within each body, and + ${thresholdWords} of all three pooled. Until the board adopts a reading of "collectively" + by resolution, the stricter per-body reading governs — so a bill carries only where + every body reached ${thresholdWords} of its own members present and voting. Abstentions are excluded + from the denominator, because Article 16(3) says present and voting.

    + ${notes.map(n => `

    ${n}

    `).join('')}` +} + +function historyBlock (item, { esc }) { + const h = item?.bill?.history ?? [] + if (!h.length) return '' + return ` +
    + Status history (${h.length}) +
      + ${h.map(e => `
    1. ${esc(e.date ?? '—')} — ${esc(e.from ? `${e.from} → ${e.to}` : e.to)}, ` + + `by ${esc(e.actor ?? '—')}${e.evidence ? ` · ${esc(e.evidence)}` : ''}` + + `${e.note ? `` : ''}
    2. `).join('')} +
    +
    ` +} + +function enactmentRows (item, { url, esc, actIndex }) { + const e = item?.bill?.enactment ?? {} + const rows = [] + if (e.act_number != null && e.act_year != null) { + const id = `act-${e.act_number}-${e.act_year}` + const act = actIndex?.[id] + rows.push(`
    Enacted as
    ${ + esc(act?.title ?? `Act ${e.act_number} of ${e.act_year}`)}${ + act ? '' : ' — not yet listed on the amendment register'}
    `) + } + if (e.assent_date) rows.push(`
    Assent
    ${esc(e.assent_date)}${e.assented_by ? ` · ${esc(e.assented_by)}` : ''}
    `) + if (e.signed_by) rows.push(`
    Signed by
    ${esc(e.signed_by)}
    `) + if (e.signed_pdf) { + rows.push(`
    Instrument
    Signed Act (PDF)${ + e.signed_pdf_sha256 + ? ` · sha256 ${esc(e.signed_pdf_sha256.slice(0, 12))}…` + : ''}
    `) + } + return rows.join('') +} + +function billCard (item, doc, { url, esc, actIndex, slugs, sourceBase }) { + const bill = item?.bill ?? {} + const meta = bill.bill ?? {} + const status = bill.status ?? 'draft' + const manifest = manifestOf(item, doc) + const anchor = billAnchor(item) + const rel = repoRelative(item?.file) + + const mover = [meta.moved_by?.name, meta.moved_by?.role].filter(Boolean).map(esc).join(' · ') + + return ` +
    +

    + ${esc(billLabel(item))} + ${esc(meta.short_title ?? '(untitled bill)')} + ${esc(status.replace(/-/g, ' '))} +

    + ${meta.also_known_as ? `

    Also called the ${esc(meta.also_known_as)}

    ` : ''} +
    +
    Status
    ${esc(status.replace(/-/g, ' '))}${STATUS_GLOSS[status] ? ` — ${esc(STATUS_GLOSS[status])}` : ''}
    +
    Type
    ${esc(meta.type ?? '—')}${TYPE_GLOSS[meta.type] ? ` — ${esc(TYPE_GLOSS[meta.type])}` : ''}
    +
    Moved by
    ${mover || '—'}
    + ${meta.drafted ? `
    Drafted
    ${esc(meta.drafted)}
    ` : ''} +
    Drafted against
    constitution ${esc(meta.base_version ?? '—')}${ + doc?.info?.version && meta.base_version && doc.info.version !== meta.base_version + ? ` — the constitution is now at ${esc(doc.info.version)}, so this bill must be rebased before it can be voted on` + : ''}
    +
    On enactment
    ${esc(meta.version_bump ?? 'minor')} version bump
    + ${enactmentRows(item, { url, esc, actIndex })} +
    Source
    ${sourceFile(rel, { esc, sourceBase })}
    +
    +

    Provisions this bill would change

    + ${operationsList(item, manifest, { url, esc, slugs })} + ${PUBLIC_DIFF.has(status) + ? `

    What it would change, in full

    + ${operationDiffs(item, manifest, { esc })}` + : `

    The before-and-after text is published once a bill is submitted. Until then + the mover can read it at any time with npx opencodelaw bill validate ${esc(rel)}.

    `} + ${approvalsBlock(item, { esc })} + ${bill.objects_and_reasons + ? `
    + Statement of Objects and Reasons +

    Explanatory only — never operative, and never the authority for anything

    + ${quoteText(bill.objects_and_reasons, esc)} +
    ` + : ''} + ${historyBlock(item, { esc })} +
    ` +} + +// --------------------------------------------------------------------------- + +function emptyState ({ url, esc, actIndex, sourceBase }) { + const acts = Object.keys(actIndex ?? {}).length + return ` +
    +

    No bills before the board

    +

    Nothing has been introduced. That is the ordinary state of this register between + amendments, not a page that failed to load${acts + ? ` — every instrument already enacted is on the amendment register` + : ''}.

    +

    Anyone may propose one, and the file they write is the instrument:

    +
      +
    1. Read ${sourceFile('process/PROPOSING.md', { esc, sourceBase })} — what a bill has to say, and to whom.
    2. +
    3. Copy ${sourceFile('bills/TEMPLATE.yaml', { esc, sourceBase })} into bills/<year>/ + and describe the change. Each operation carries the complete resulting text of the + provision it touches — not a diff, and never "insert after the words".
    4. +
    5. Run npx opencodelaw bill validate bills/<year>/your-bill.yaml as often as you + like. It checks the drafting and prints the before-and-after the approval meetings will read.
    6. +
    7. Send it to the ICC, which numbers it at submission and coordinates the approvals of all three + bodies under Article 16(3).
    8. +
    +
    ` +} + +function standingSections ({ esc, url, slugs, sourceBase }) { + const art16 = targetHref('art-16', { url, slugs }) + return ` +

    Approval under Article 16(3)

    +

    Article 16(3): All proposed amendments must be + approved by a 2/3rd present and voting of the board, the intermediate board and units of the NGO + collectively.

    +

    All three bodies are required. This is fixed constitutional policy, not a default + setting: a checklist, tool or shortcut that enacts on fewer than three approvals is invalid on its + face, whatever convenience recommends it. The ICC coordinates and records the approvals of all three — + the Acts of 2024 recorded only the ICC's own assent, which is the gap this pipeline closes for good.

    +

    "Collectively" is genuinely ambiguous. It can mean a pooled vote of all three + bodies sitting together, or two thirds within each body separately. The board has not adopted a + reading. Until it does by resolution, enactment requires the stricter one — at least + ${thresholdWords} of those present and voting in each of the ${numberWord(REQUIRED_BODIES.length)} bodies, taken + separately — and every bill records both tallies, so the record satisfies whichever reading is + eventually adopted. Abstentions are excluded from the denominator, because the Article says present + and voting. The choice between the readings is pending.

    +

    Meeting mechanics are not decided here. Notice, quorum and how a vote is taken + belong to the by-laws under Article 16(2). This register records who was present and how they voted; + it does not invent a quorum rule the constitution does not state.

    + +

    How a bill becomes an Act

    +

    The bill file is the source of truth and the signed PDF is a rendering of it. The three Acts of + 2024 went the other way — prose first, applied to the machine-readable text by hand afterwards — and + that is what produced a half-applied constitution, an application nobody recorded, and an Act that + could not safely be re-run. An amendment drafted here is machine-applicable from the moment it exists, + so applying it is a comparison rather than a transcription.

    +
      +
    1. draft — with its mover, unnumbered. It is not yet before anyone.
    2. +
    3. submitted — lodged with the ICC, which assigns the number: Bill 1 of 2026, per + year.
    4. +
    5. under review — the ICC reads the drafting. It may be returned + for revision and resubmitted, as often as that takes.
    6. +
    7. scheduled — listed for the approval meetings of the board, the intermediate + board and the units.
    8. +
    9. approved or rejected — on the tallies recorded above, under + Article 16(3).
    10. +
    11. enacted — assented to and signed. The Act takes its own number at enactment: + Act 1 of 2026, per year.
    12. +
    13. applied — written into the constitution, which takes the new version. Because + each operation carries the complete resulting text, applying the same Act twice changes nothing the + second time.
    14. +
    +

    A mover may withdraw a bill at any time before approval. The board may record one + as lapsed; that is set by hand, because the constitution states no time limit and this + pipeline does not invent one.

    +

    Every applied Act bumps the minor version. Major is reserved for a full revision, + which is also the only instrument that may renumber a provision — article numbers are permanent + citation handles. There are no patch releases of provision text: an editorial edit without an + instrument is exactly what this system exists to prevent.

    +

    The rules in full: ${sourceFile('process/AMENDMENT-PROCESS.md', { esc, sourceBase })}. For authors: + ${sourceFile('process/PROPOSING.md', { esc, sourceBase })} and ${sourceFile('bills/TEMPLATE.yaml', { esc, sourceBase })}.

    ` +} + +/** + * The /bills/ page body. + * + * `bills` is `{ file, bill }` as loaded from bills//*.yaml; an item may + * also carry a precomputed `manifest` and `tally`. `constitution` is the + * document the diffs are taken against — pass the one the build already loaded, + * so a page can never show a "before" from a different document. + */ +export function billsMain (bills = [], { + url, escapeHtml: esc = defaultEscapeHtml, actIndex = {}, constitution = null, + slugs = null, sourceBase +} = {}) { + const groups = groupByYear(bills) + const counted = bills.length + + const body = groups.map(g => ` +
    +

    ${esc(groupLabel(g))}

    + ${g.items.map(item => billCard(item, constitution, { url, esc, actIndex, slugs, sourceBase })).join('')} +
    `).join('') + + return ` +

    Bills

    +

    Proposed amendments to this constitution, from the moment they are drafted. The + bill file is the instrument: the before-and-after a bill publishes here is the same comparison made + when it is applied, so what the approving bodies read is exactly what lands in the text.

    +

    Every bill needs the approval of the board, the intermediate board and the units under + Article 16(3). Bills that were rejected, withdrawn or lapsed + stay on this page. A legislature's failed bills are part of its record — what was proposed and + refused says as much as what was carried, and a register that quietly drops them is a register that + only ever agrees with itself.

    + ${counted + ? `

    ${counted} bill${counted === 1 ? '' : 's'} on record. + Signed Acts are on the amendment register.

    ${body}` + : emptyState({ url, esc, actIndex, sourceBase })} + ${standingSections({ esc, url, slugs, sourceBase })}` +} + +/** + * Styling for the classes this page adds, ready to drop into the layout's + * `head`. Optional: everything above reuses `act`, `disc` and `banner`, and the + * page reads correctly without a line of this — a bill register that depends on + * a stylesheet to be legible is not a record. It is here so the page does not + * have to wait on a stylesheet to look finished, and can move into + * src/styles/layout.css unchanged whenever styling catches up. + */ +export const BILLS_CSS = `` + +export default billsMain diff --git a/src/templates/layout.mjs b/src/templates/layout.mjs index c53fd5b..080889e 100644 --- a/src/templates/layout.mjs +++ b/src/templates/layout.mjs @@ -195,6 +195,7 @@ export function layout ({

    Constitution · Amendments · + Bills · Archive ${info.contact?.email ? ` · Contact` : ''}

    diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs new file mode 100644 index 0000000..71732a4 --- /dev/null +++ b/tests/bill.test.mjs @@ -0,0 +1,447 @@ +/** + * The amendment pipeline: a bill is machine-applicable from the moment it is + * drafted, and the signed PDF is a rendering of it rather than the other way + * round. + * + * Everything asserted here is a defect the 2024 Acts actually produced: + * + * - an Act whose clause edits were line splices, so re-running it corrupted + * text that no longer looked the way the splice expected (tests 4 and 5); + * - three instruments recording the assent of one body where Article 16(3) + * requires three (test 7); + * - approvals recorded with no minutes behind them (test 7); + * - an amendment voted on against text that had already moved (test 6). + * + * The fixtures belong to a society that does not exist. That is deliberate: + * a test that drafts real constitutional language is a test that eventually + * gets copied into the constitution. + */ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { + loadBill, tally, buildBillManifest, classifyOperation, report, + fullText, operationText, REQUIRED_BODIES, THRESHOLD +} from '../src/bill.mjs' +import { validateDocument } from '../src/validate.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const DOC_FILE = path.join(ROOT, 'examples/starter/fixture-constitution.yaml') +const BILL_FILE = path.join(ROOT, 'examples/starter/bills/fixture-bill.yaml') +const TEMPLATE_FILE = path.join(ROOT, 'bills/TEMPLATE.yaml') + +const YAML_OPTS = { schema: yaml.CORE_SCHEMA } +const doc = yaml.load(fs.readFileSync(DOC_FILE, 'utf8'), YAML_OPTS) + +const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'opencodelaw-bill-')) +after(() => fs.rmSync(tmp, { recursive: true, force: true })) + +let seq = 0 + +/** + * validateBill(), one fresh module instance per call. + * + * src/bill.mjs memoises a single Ajv and re-compiles the bill schema on every + * call. Ajv refuses to register the same $id twice, so a second call inside + * one module instance throws before it validates anything. Importing the + * module under a fresh specifier keeps each validation independent, and keeps + * these tests correct whether or not that is fixed. + */ +async function validate (file, options = { constitution: doc }) { + const { validateBill } = await import(`../src/bill.mjs?instance=${++seq}`) + return validateBill(file, options) +} + +/** Write a bill out where the validator can read it. Never inside the repo. */ +function write (bill) { + const file = path.join(tmp, `bill-${++seq}.yaml`) + fs.writeFileSync(file, yaml.dump(bill)) + return file +} + +/** The fixture bill with one thing deliberately broken. */ +function variant (mutate) { + const bill = structuredClone(loadBill(BILL_FILE)) + mutate(bill) + return write(bill) +} + +const codes = r => r.problems.errors.map(e => e.code) +const detail = r => r.problems.errors.map(e => `${e.code}: ${e.message}`).join('\n') +const errorFor = (r, code) => r.problems.errors.find(e => e.code === code) +const opOf = (bill, kind) => bill.operations.find(o => o.operation === kind) + +/** Every provision a bill can target, resolved the way src/bill.mjs resolves it. */ +function resolve (d, id) { + if (id === 'preamble') return d.preamble + for (const a of d.articles ?? []) { + if (a.id === id) return a + for (const s of a.sections ?? []) if (s.id === id) return s + } + return undefined +} + +/** + * A minimal applier, run the way the pipeline must run one: classify first, + * and touch the document only when the classification says `apply`. Nothing + * here is a diff or a splice — an operation carries the complete resulting + * text, so applying it is an assignment. + */ +function applyOperation (d, o, baseText) { + const classification = classifyOperation(o, resolve(d, o.target), baseText) + if (classification !== 'apply') return { doc: d, classification, changed: false } + + const next = structuredClone(d) + const node = resolve(next, o.target) + switch (o.operation) { + case 'substitute': + if (o.text != null) node.content = o.text + if (o.title) node.title = o.title + if (o.sections) { + node.sections = o.sections.map(s => ({ + id: `${o.target}-s-${s.number}`, + number: s.number, + title: s.title, + title_source: 'enacted', + content: s.text + })) + } + break + case 'insert': + next.articles.push({ + id: o.target, + number: Number(o.target.replace('art-', '')), + title: o.title, + title_source: 'enacted', + content: o.text + }) + next.articles.sort((a, b) => a.number - b.number) + break + case 'omit': + case 'reserve': + // The number stays. A citation made to it must still resolve, which is + // why nothing is spliced out of the array. + delete node.content + delete node.sections + node.status = o.operation === 'omit' ? 'omitted' : 'reserved' + node.note = o.note + break + case 'retitle': + node.title = o.title + node.title_source = 'enacted' + break + } + return { doc: next, classification, changed: true } +} + +// --------------------------------------------------------------------------- + +test('the fixture constitution is itself a fully valid document', () => { + // So that a defect in the fixture can never be read as a defect in a bill. + // Unlike examples/starter/constitution.yaml, this one has no placeholder + // contact to trip over: it is meant to pass. + const rep = validateDocument(doc, 'examples/starter/fixture-constitution.yaml') + assert.deepEqual(rep.errors.map(e => `${e.code}: ${e.message}`), []) + assert.equal(doc.info.version, '2.1.0') + assert.equal(loadBill(BILL_FILE).bill.base_version, doc.info.version, + 'the fixture bill must be drafted against the fixture constitution') +}) + +test('the template validates clean, as a draft, against the constitution it names', async () => { + // An author's first act is to copy bills/TEMPLATE.yaml. If the template does + // not validate, every bill starts from an error and the author learns to + // ignore the validator. It is checked against the real constitution because + // that is the version it declares; when the constitution moves, the template + // is rebased with everything else. + const r = await validate(TEMPLATE_FILE, {}) // {} — against the live constitution, not the fixture + assert.equal(r.bill.status, 'draft') + assert.equal(r.bill.bill.number, null, 'a draft carries no number') + assert.deepEqual(detail(r), '') +}) + +test('a numbered draft is refused, and so is an unnumbered bill that has left the desk', async () => { + // Numbering is the ICC's, at submission. A draft that has numbered itself is + // claiming a place in a queue it has not joined. + const numbered = await validate(variant(b => { b.status = 'draft'; b.bill.number = 7 })) + assert.deepEqual(codes(numbered), ['numbered-draft'], detail(numbered)) + assert.match(errorFor(numbered, 'numbered-draft').message, /ICC assigns a number at submission/) + + const anonymous = await validate(variant(b => { b.status = 'under-review'; b.bill.number = null })) + assert.ok(codes(anonymous).includes('unnumbered-bill'), detail(anonymous)) +}) + +test('every operation type validates against the fixture constitution', async () => { + const r = await validate(BILL_FILE) + assert.deepEqual(detail(r), '') + + const byKind = new Map(r.manifest.map(m => [m.operation, m])) + assert.deepEqual([...byKind.keys()].sort(), + ['insert', 'omit', 'reserve', 'retitle', 'substitute'], + 'the fixture must exercise one of every operation the schema allows') + + // Each one, read the way an approval meeting reads the manifest. + const substitute = byKind.get('substitute') + assert.equal(substitute.exists, true) + assert.equal(substitute.unchanged, false) + assert.match(substitute.after, /Apprentices/, 'a substitution carries the whole provision, sections included') + + assert.equal(byKind.get('insert').exists, false, 'an insert targets a provision that does not exist yet') + assert.equal(byKind.get('omit').after, null, 'an omitted provision leaves no text behind') + assert.equal(byKind.get('reserve').after, null, 'a reserved number carries no text') + + const retitle = byKind.get('retitle') + assert.equal(retitle.title_before, 'The Roll') + assert.equal(retitle.title_after, 'The Lantern Roll') + + // The clause-scope operation names a section, not an article. + assert.equal(retitle.scope, 'clause') + assert.match(retitle.target, /^art-\d+-s-\d+$/) + + // And the bill is properly passed on either reading of "collectively". + assert.equal(r.tally.passes, true, 'per-body: the stricter reading') + assert.equal(r.tally.pooled.passes, true, 'pooled: the looser reading') + + const printed = report(r) + assert.match(printed, /Article 16\(3\) requires all three bodies/) + for (const body of REQUIRED_BODIES) assert.ok(printed.includes(body), `${body} missing from the report`) +}) + +test('classifyOperation is a three-way decision, and each way is asserted', () => { + const bill = loadBill(BILL_FILE) + const o = opOf(bill, 'substitute') + const base = resolve(doc, o.target) + const baseText = fullText(base) + + // current == base → apply. Nothing has happened yet. + assert.equal(classifyOperation(o, base, baseText), 'apply') + + // current == proposed → already applied. The safe no-op that makes + // re-running an Act harmless. + const applied = applyOperation(doc, o, baseText).doc + const appliedNode = resolve(applied, o.target) + assert.equal(fullText(appliedNode), operationText(o)) + assert.equal(classifyOperation(o, appliedNode, baseText), 'already-applied') + + // neither → divergent. Someone edited the text outside the process; this is + // never resolved automatically. + const drifted = structuredClone(doc) + const driftedNode = resolve(drifted, o.target) + driftedNode.content = 'Membership is whatever the Keeper of the Oil says it is on the night.' + delete driftedNode.sections + assert.equal(classifyOperation(o, driftedNode, baseText), 'divergent') +}) + +test('applying a bill twice changes the document exactly once', () => { + // Act 1 of 2024 could not be re-run: its clause edits were splices into text + // that no longer existed after the first pass. Here the second pass is a + // no-op by construction, because every operation states its whole result. + const bill = loadBill(BILL_FILE) + const baseText = new Map(bill.operations.map(o => [o.id, fullText(resolve(doc, o.target))])) + + let once = doc + const first = [] + for (const o of bill.operations) { + const r = applyOperation(once, o, baseText.get(o.id)) + first.push([o.id, r.classification]) + once = r.doc + } + assert.deepEqual(first, bill.operations.map(o => [o.id, 'apply']), + 'every operation should apply on a document that has not seen it') + + let twice = once + const second = new Map() + for (const o of bill.operations) { + const r = applyOperation(twice, o, baseText.get(o.id)) + second.set(o.id, r.classification) + twice = r.doc + } + + assert.deepEqual(twice, once, 'the second run must leave the document identical') + + // A substitution and a retitle recognise their own work and stand down. + assert.equal(second.get(opOf(bill, 'substitute').id), 'already-applied') + assert.equal(second.get(opOf(bill, 'retitle').id), 'already-applied') + // An insert whose target now exists must never apply a second time. + assert.notEqual(second.get(opOf(bill, 'insert').id), 'apply') + // An omission and a reservation are idempotent in effect: re-running writes + // the same status and note over the same status and note, which the + // document-level assertion above already proves. + assert.equal(resolve(twice, opOf(bill, 'omit').target).status, 'omitted') + assert.equal(resolve(twice, opOf(bill, 'reserve').target).status, 'reserved') + assert.equal(resolve(twice, opOf(bill, 'omit').target).content, undefined) + + // Provisions the bill never named are untouched by either pass. + assert.deepEqual(resolve(twice, 'art-2'), resolve(doc, 'art-2')) +}) + +test('a bill drafted against a superseded version refuses to validate until it is rebased', async () => { + // The failure this prevents: a meeting approving text that no longer exists. + const r = await validate(variant(b => { b.bill.base_version = '1.9.0' })) + const e = errorFor(r, 'rebase-required') + assert.ok(e, detail(r)) + assert.match(e.message, /1\.9\.0/, 'the message must name the version the bill was written against') + assert.match(e.message, /2\.1\.0/, 'and the version the constitution has reached') + assert.match(e.message, /[Rr]ebase it/) +}) + +test('enactment is refused when a body is missing — all three are required', async () => { + const r = await validate(variant(b => { b.approvals = b.approvals.filter(a => a.body !== 'units') })) + const e = errorFor(r, 'approvals-incomplete') + assert.ok(e, detail(r)) + assert.match(e.message, /units/) + assert.match(e.message, /all three/) + + const t = tally(loadBill(BILL_FILE).approvals.filter(a => a.body !== 'units')) + assert.deepEqual(t.missingBodies, ['units']) + assert.equal(t.complete, false) + assert.equal(t.passes, false, 'a missing body can never be inferred to have approved') +}) + +test('enactment is refused when a body approved with no minutes behind it', async () => { + // Q13 against the 2024 Acts: an approval with no evidence is an assertion. + const r = await validate(variant(b => { + b.approvals.find(a => a.body === 'intermediate-board').evidence = null + })) + const e = errorFor(r, 'approval-evidence-missing') + assert.ok(e, detail(r)) + assert.match(e.message, /intermediate-board/) + assert.match(e.message, /assertion/) +}) + +test('enactment is refused when one body falls below two thirds, even though the pooled vote passes', async () => { + // The whole point of the stricter reading. Until the board resolves what + // "collectively" means in Article 16(3), a bill carried by two large bodies + // over the objection of a third is not carried. + const file = variant(b => { + const cast = { board: [20, 0], 'intermediate-board': [18, 0], units: [5, 4] } + for (const a of b.approvals) { + const [yes, no] = cast[a.body] + a.for = yes + a.against = no + a.abstain = 0 + a.present = yes + no + } + }) + const r = await validate(file) + + assert.equal(r.tally.pooled.passes, true, 'pooled: 43 of 49 is well over two thirds') + assert.equal(r.tally.perBody.find(b => b.body === 'units').passes, false, 'units: 5 of 9 is not') + assert.equal(r.tally.passes, false, 'the stricter reading governs') + assert.deepEqual(r.tally.failedBodies, ['units']) + + const e = errorFor(r, 'threshold-not-met') + assert.ok(e, detail(r)) + assert.match(e.message, /units/) + assert.match(e.message, /pooled vote across all three bodies does pass/, + 'the record must state both readings, so the Act stands whichever the board adopts') + assert.match(e.message, /stricter/) +}) + +test('tally computes each body and the pool, and abstentions are outside the denominator', () => { + assert.deepEqual(REQUIRED_BODIES, ['board', 'intermediate-board', 'units']) + assert.equal(THRESHOLD, 2 / 3) + + const t = tally([ + { body: 'board', present: 30, for: 19, against: 5, abstain: 6, evidence: 'Board minutes 1' }, + { body: 'intermediate-board', present: 12, for: 8, against: 2, abstain: 2, evidence: 'IB minutes 1' }, + { body: 'units', present: 15, for: 9, against: 3, abstain: 3, evidence: 'Unit poll 1' } + ]) + const board = t.perBody.find(b => b.body === 'board') + + // "Present and voting": 30 were present, 24 voted. + assert.equal(board.present, 30) + assert.equal(board.voting, 24) + assert.equal(board.ratio, 19 / 24) + assert.equal(board.passes, true) + // Had abstentions counted against the mover, the board would have failed — + // which is the arithmetic the wording of 16(3) settles. + assert.ok(19 / 30 < THRESHOLD) + + assert.equal(t.perBody.find(b => b.body === 'intermediate-board').voting, 10) + assert.equal(t.perBody.find(b => b.body === 'units').ratio, 9 / 12) + + // Pooled, over the same present-and-voting denominators. + assert.equal(t.pooled.for, 36) + assert.equal(t.pooled.voting, 46) + assert.equal(t.pooled.ratio, 36 / 46) + assert.equal(t.pooled.passes, true) + + assert.equal(t.complete, true) + assert.equal(t.passes, true) + assert.deepEqual(t.missingBodies, []) + assert.deepEqual(t.missingEvidence, []) + + // Exactly two thirds carries: 16(3) says 2/3, not more than 2/3. + const exact = tally(REQUIRED_BODIES.map(body => ({ body, for: 6, against: 3, abstain: 4, evidence: 'm' }))) + assert.equal(exact.passes, true) + + // One vote fewer does not. + const short = tally(REQUIRED_BODIES.map(body => ({ body, for: 5, against: 3, abstain: 4, evidence: 'm' }))) + assert.equal(short.passes, false) + assert.deepEqual(short.failedBodies, REQUIRED_BODIES) + + // Nothing recorded is not the same as nothing approved, and neither passes. + const empty = tally([]) + assert.deepEqual(empty.missingBodies, REQUIRED_BODIES) + assert.equal(empty.pooled.ratio, null) + assert.equal(empty.passes, false) +}) + +test('renumbering is refused, and the refusal says a revision bill is the only way', async () => { + // Article numbers are the public API: every Act, minute and shared link + // points at them. + + // Named as an operation, the word is not in the vocabulary at all. + const named = await validate(variant(b => { b.operations[0].operation = 'renumber' })) + assert.ok(named.problems.errors.length, 'a renumber operation must not validate') + assert.ok( + named.problems.errors.some(e => e.code === 'renumber-forbidden' || /operations\/0\/operation/.test(e.where ?? '')), + `the refusal must point at the operation that asked to renumber:\n${detail(named)}`) + + // Asked for in a drafting note, it is refused by name, with the reason. + const asked = await validate(variant(b => { + b.operations[0].note = 'Renumber Articles 5 to 9 so that the gap at Article 4 closes.' + })) + const e = errorFor(asked, 'renumber-forbidden') + assert.ok(e, detail(asked)) + assert.match(e.message, /amendment bill/) + assert.match(e.message, /"revision"/, 'the message must name the bill type that may renumber') + assert.match(e.message, /major version bump/) + assert.match(e.message, /anchor map/) +}) + +test('an insert onto an existing provision fails, and a substitution onto a missing one fails', async () => { + // Q4 in one test: whether an Act amends or inserts is not a matter of + // opinion, and the bill cannot record it wrongly. + const collision = await validate(variant(b => { opOf(b, 'insert').target = 'art-2' })) + const e1 = errorFor(collision, 'insert-exists') + assert.ok(e1, detail(collision)) + assert.match(e1.message, /art-2 — it already exists/) + assert.match(e1.message, /"substitute"/, 'the message must name the operation the author wanted') + + const missing = await validate(variant(b => { opOf(b, 'substitute').target = 'art-99' })) + const e2 = errorFor(missing, 'target-unresolved') + assert.ok(e2, detail(missing)) + assert.match(e2.message, /art-99 does not exist in constitution version 2\.1\.0/) + assert.match(e2.message, /"insert"/) + + // The manifest agrees with the validator about what exists. + const m = buildBillManifest(loadBill(BILL_FILE), doc) + assert.deepEqual(m.filter(x => !x.exists).map(x => x.target), ['art-10']) +}) + +test('an operation may not take its authority from the Statement of Objects and Reasons', async () => { + // Convention C1. Structurally impossible — operations carry their own text — + // and asserted anyway, because Act 2 of 2024 set one amendment threshold in + // its operative text and a different one in its statement. + const r = await validate(variant(b => { + b.operations[0].note = 'Text as given in the Statement of Objects and Reasons.' + })) + const e = errorFor(r, 'sor-as-authority') + assert.ok(e, detail(r)) + assert.match(e.message, /never a source of authority/) +}) From 9efe9ba521138c67497410513711705ea6910748 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:00:31 +0530 Subject: [PATCH 03/13] Substantive hash; fix the renderer defects the review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the substantive hash that Part 1 of the addendum binds approvals to: sha256 over a canonical JSON form (sorted keys, UTF-8) of exactly short_title, type, base_version, objects_and_reasons and operations. Number, status, history, approvals and enactment are clerking fields that change after drafting, so including them would make a vote go stale for administrative reasons. Verified stable across clerking edits and sensitive to any change in an operation. A vote binds to this rather than to "Bill 1 of 2026", because a title reads the same before and after someone edits an operation, and an approval recorded against a title would silently survive a change to the text it approved. Four defects from the contract review, all confirmed before fixing: - The CLI passed house style in a shape houseStyle() cannot read, spreading the YAML's snake_case keys at top level where the options are camelCase. The signatory office line and the ENTIRE address footer silently vanished from the rendered instrument. It now passes info itself. - DEFAULT_COMMITTEE carried STM's own wording, so a fork with no info.instrument would print another organisation's committee on its masthead. Defaults are empty; house style comes from the constitution. - moved_by was never printed on the instrument. The schema requires it and both docs call it the permanent fix for the 2024 Acts naming no proposer — but recording it in the bill only fixes that if the signed paper carries it. It now prints above the separator, in text and HTML. - BILLS_CSS was exported as a ` - +// The /bills/ rules now live in src/styles/layout.css with every other +// component. They were exported here as a + +

    Rendered from the bill file, which is the source of truth. This sheet is a rendering +of it. ${esc(m.organization)}

    +${sheets} + +` +} diff --git a/src/bill-commands.mjs b/src/bill-commands.mjs index 5fd90eb..5f7117b 100644 --- a/src/bill-commands.mjs +++ b/src/bill-commands.mjs @@ -58,6 +58,22 @@ export async function runBillCommand (group, args) { return } + if (group === 'bill' && sub === 'ballot') { + const { ballotDocument, ballotGuard } = await import('./ballot.mjs') + const { loadConstitution } = 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 })) + 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 —') + console.log(' into the minutes of each.') + return + } + if (group === 'bill' && sub === 'submit') { const r = billSubmit(need(), { actor: flag(rest, 'actor', 'ICC') }) console.log(`Submitted as Bill ${r.number} of ${r.year}.`) diff --git a/src/bill.mjs b/src/bill.mjs index b55952a..52954fe 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -57,7 +57,9 @@ export function tally (approvals = []) { voting, ratio, passes: ratio != null && ratio >= THRESHOLD, - evidence: a?.evidence ?? null + evidence: a?.evidence ?? null, + billSha256: a?.bill_sha256 ?? null, + meeting: a?.meeting ?? null } }) @@ -73,7 +75,7 @@ export function tally (approvals = []) { // 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).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) } } @@ -237,6 +239,51 @@ export function validateBill (file, { constitution } = {}) { } } + // --- approvals: evidence on disk, and bound to the text that was voted on -- + const hash = substantiveHash(bill) + const stale = [] + for (const [i, a] of (bill.approvals ?? []).entries()) { + const at = `approvals[${i}] (${a.body})` + const voted = a.for != null || a.against != null + + if (a.evidence) { + const abs = path.join(ROOT, a.evidence.path) + if (!fs.existsSync(abs)) { + p.error('evidence-missing', + `${at}: the record of resolution ${a.evidence.path} is not in the repository. A live link ` + + 'is never evidence — archive the signed minutes (or the attested poll export) beside the ' + + 'bill and record its path and checksum.', at) + } else if (a.evidence.sha256 && fileSha256(abs) !== a.evidence.sha256) { + p.error('evidence-hash-mismatch', + `${at}: ${a.evidence.path} does not match the checksum recorded with it. The archived ` + + 'record is not the document that was filed.', at) + } + } else if (voted) { + p.error('evidence-missing', + `${at}: a tally is recorded with no signed record of resolution. An approval without ` + + 'evidence is an assertion.', at) + } + + // The voting rule: an approval binds to the text as voted, never to the title. + if (voted && a.bill_sha256 && a.bill_sha256 !== hash) stale.push({ body: a.body, was: a.bill_sha256 }) + else if (voted && !a.bill_sha256) { + p.error('approval-unbound', + `${at}: no bill_sha256 recorded, so this vote is not bound to any particular text. ` + + `The hash to record is ${hash}.`, at) + } + } + + if (stale.length) { + p.error('approval-stale', + `edit recorded — approvals by ${stale.map(s2 => s2.body).join(', ')} are void; move them to ` + + 'history and re-collect. ' + + stale.map(s2 => `${s2.body} resolved on ${s2.was.slice(0, 12)}…`).join('; ') + + `, the bill is now ${hash.slice(0, 12)}…. A rebase voids every approval, including when the ` + + "bill's own operations are untouched: a provision can become contradictory purely because " + + 'other articles moved, and whether a rebase is semantically clean is not something a tool ' + + 'can adjudicate honestly.', 'approvals') + } + // --- lifecycle guards ---------------------------------------------------- const t = tally(bill.approvals) if (['enacted', 'applied'].includes(bill.status)) { @@ -353,6 +400,10 @@ export function report ({ problems, manifest, tally: t, bill }) { out.push(` ${b.number ? `Bill ${b.number} of ${b.year}` : 'unnumbered draft'} · ${b.type} · status ${bill.status}`) out.push(` moved by ${b.moved_by?.name ?? '—'} · against constitution ${b.base_version}`) out.push('') + out.push(` ${resolutionSentence(bill)}`) + out.push(' Read that sentence into the minutes of every approving body: a vote binds to the') + out.push(' hash, not to the title. Editing the bill afterwards voids the approvals.') + out.push('') out.push(`Operations (${manifest.length}):`) for (const m of manifest) { out.push(` ${m.id} ${m.operation} ${m.target} (${m.scope})${m.unchanged ? ' — no change' : ''}`) @@ -368,7 +419,10 @@ export function report ({ problems, manifest, tally: t, bill }) { const state = !b2.recorded ? 'not recorded' : b2.voting == null ? 'no tally' : `${b2.for}/${b2.voting} voting = ${(b2.ratio * 100).toFixed(1)}% ${b2.passes ? 'PASS' : 'BELOW 2/3'}` - out.push(` ${b2.body.padEnd(19)} ${state}${b2.recorded && !b2.evidence ? ' (no minutes reference)' : ''}`) + const flags = [] + if (b2.recorded && !b2.evidence?.path) flags.push('no record of resolution') + if (b2.billSha256 && b2.billSha256 !== substantiveHash(bill)) flags.push('VOID — voted on different text') + out.push(` ${b2.body.padEnd(19)} ${state}${flags.length ? ' (' + flags.join('; ') + ')' : ''}`) } if (t.pooled.ratio != null) { out.push(` pooled ${t.pooled.for}/${t.pooled.voting} = ${(t.pooled.ratio * 100).toFixed(1)}% ${t.pooled.passes ? 'PASS' : 'BELOW 2/3'}`) diff --git a/src/cli.mjs b/src/cli.mjs index f729de0..8d5b602 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -25,6 +25,7 @@ opencodelaw — a YAML-driven renderer for organizational constitutions opencodelaw bill new [--type amendment|corrigendum|revision] [--name ] opencodelaw bill validate schema, targets, threshold, and the before/after diff opencodelaw bill render the instrument in house style (text and HTML) + opencodelaw bill ballot resolution sheets, one per approving body opencodelaw bill submit ICC: assign a bill number, status -> submitted opencodelaw act enact --signed-pdf [--signed-by ] opencodelaw act apply [--dry-run] diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs index 71732a4..81f4603 100644 --- a/tests/bill.test.mjs +++ b/tests/bill.test.mjs @@ -304,7 +304,7 @@ test('enactment is refused when a body is missing — all three are required', a test('enactment is refused when a body approved with no minutes behind it', async () => { // Q13 against the 2024 Acts: an approval with no evidence is an assertion. const r = await validate(variant(b => { - b.approvals.find(a => a.body === 'intermediate-board').evidence = null + delete b.approvals.find(a => a.body === 'intermediate-board').evidence })) const e = errorFor(r, 'approval-evidence-missing') assert.ok(e, detail(r)) @@ -346,9 +346,9 @@ test('tally computes each body and the pool, and abstentions are outside the den assert.equal(THRESHOLD, 2 / 3) const t = tally([ - { body: 'board', present: 30, for: 19, against: 5, abstain: 6, evidence: 'Board minutes 1' }, - { body: 'intermediate-board', present: 12, for: 8, against: 2, abstain: 2, evidence: 'IB minutes 1' }, - { body: 'units', present: 15, for: 9, against: 3, abstain: 3, evidence: 'Unit poll 1' } + { body: 'board', present: 30, for: 19, against: 5, abstain: 6, evidence: { kind: 'minutes', path: 'Board minutes 1', sha256: 'x'.repeat(64) } }, + { body: 'intermediate-board', present: 12, for: 8, against: 2, abstain: 2, evidence: { kind: 'minutes', path: 'IB minutes 1', sha256: 'x'.repeat(64) } }, + { body: 'units', present: 15, for: 9, against: 3, abstain: 3, evidence: { kind: 'minutes', path: 'Unit poll 1', sha256: 'x'.repeat(64) } } ]) const board = t.perBody.find(b => b.body === 'board') @@ -376,11 +376,11 @@ test('tally computes each body and the pool, and abstentions are outside the den assert.deepEqual(t.missingEvidence, []) // Exactly two thirds carries: 16(3) says 2/3, not more than 2/3. - const exact = tally(REQUIRED_BODIES.map(body => ({ body, for: 6, against: 3, abstain: 4, evidence: 'm' }))) + const exact = tally(REQUIRED_BODIES.map(body => ({ body, for: 6, against: 3, abstain: 4, evidence: { kind: 'minutes', path: 'm', sha256: 'x'.repeat(64) } }))) assert.equal(exact.passes, true) // One vote fewer does not. - const short = tally(REQUIRED_BODIES.map(body => ({ body, for: 5, against: 3, abstain: 4, evidence: 'm' }))) + const short = tally(REQUIRED_BODIES.map(body => ({ body, for: 5, against: 3, abstain: 4, evidence: { kind: 'minutes', path: 'm', sha256: 'x'.repeat(64) } }))) assert.equal(short.passes, false) assert.deepEqual(short.failedBodies, REQUIRED_BODIES) @@ -445,3 +445,99 @@ test('an operation may not take its authority from the Statement of Objects and assert.ok(e, detail(r)) assert.match(e.message, /never a source of authority/) }) + +// --- the evidence model ---------------------------------------------------- + +test('evidence must be a file in the repository, and its checksum must match', async () => { + // A live link is never evidence: mutable, unattributable, and dead when the + // platform is. The record is archived beside the instrument it approves. + const missing = await validate(variant(b => { + b.approvals[0].evidence = { kind: 'minutes', path: 'bills/2026/evidence/nope.pdf', sha256: 'a'.repeat(64) } + })) + assert.ok(codes(missing).includes('evidence-missing'), detail(missing)) + assert.match(errorFor(missing, 'evidence-missing').message, /live link is never evidence/i) + + const wrongHash = await validate(variant(b => { b.approvals[0].evidence.sha256 = 'b'.repeat(64) })) + assert.ok(codes(wrongHash).includes('evidence-hash-mismatch'), detail(wrongHash)) + assert.match(errorFor(wrongHash, 'evidence-hash-mismatch').message, /not the document that was filed/i) +}) + +test('a vote must be bound to the text it was cast on', async () => { + const unbound = await validate(variant(b => { b.approvals[0].bill_sha256 = null })) + assert.ok(codes(unbound).includes('approval-unbound'), detail(unbound)) + assert.match(errorFor(unbound, 'approval-unbound').message, /not bound to any particular text/i) +}) + +test('editing a bill voids every recorded approval, operations untouched or not', async () => { + const { substantiveHash } = await import('../src/bill.mjs') + + // (a) an operation changes + const edited = await validate(variant(b => { b.operations[0].text += ' One more sentence.' })) + const e = errorFor(edited, 'approval-stale') + assert.ok(e, detail(edited)) + for (const body of ['board', 'intermediate-board', 'units']) { + assert.match(e.message, new RegExp(body), `${body} must be named as voided`) + } + assert.match(e.message, /move them to history and re-collect/i) + + // (b) THE HARD EDGE: base_version moves, operations byte-identical. The + // approvals void anyway. Whether a rebase is semantically clean requires + // reasoning about cross-provision interactions, which no tool can adjudicate + // honestly — so there is deliberately no clean-rebase exemption. + const before = substantiveHash(loadBill(BILL_FILE)) + const rebased = structuredClone(loadBill(BILL_FILE)) + rebased.bill.base_version = '2.2.0' + assert.notEqual(substantiveHash(rebased), before, + 'base_version is inside the hash, so a rebase moves it even when operations do not change') +}) + +test('a joint sitting may share one record, but the tallies stay per body', async () => { + const shared = await validate(variant(b => { + const ev = structuredClone(b.approvals[0].evidence) + for (const a of b.approvals) a.evidence = structuredClone(ev) + })) + assert.deepEqual(codes(shared), [], detail(shared)) + + // Per-body tallies are still what the stricter reading needs. + const t = shared.tally + assert.equal(t.perBody.length, 3) + assert.ok(t.perBody.every(x => x.voting > 0), 'each body must carry its own tally') +}) + +test('the resolution sentence carries the hash the meeting reads aloud', async () => { + const { resolutionSentence, substantiveHash } = await import('../src/bill.mjs') + const b = loadBill(BILL_FILE) + const line = resolutionSentence(b) + assert.match(line, /^This meeting resolves on Bill \d+ of \d{4}, substantive hash [a-f0-9]{64}\.$/) + assert.ok(line.includes(substantiveHash(b))) +}) + +test('a resolution sheet renders only once a bill is scheduled', async () => { + const { ballotGuard, ballotDocument } = await import('../src/ballot.mjs') + const { loadConstitution } = await import('../src/bill.mjs') + + for (const status of ['draft', 'submitted', 'under-review', 'returned']) { + const b = structuredClone(loadBill(BILL_FILE)); b.status = status + const g = ballotGuard(b) + assert.ok(g, `${status} should be refused a ballot`) + assert.match(g, /circulation is the freeze point/i) + } + for (const status of ['scheduled', 'approved', 'enacted']) { + const b = structuredClone(loadBill(BILL_FILE)); b.status = status + assert.equal(ballotGuard(b), null, `${status} should render`) + } + + const b = structuredClone(loadBill(BILL_FILE)); b.status = 'scheduled' + const html = ballotDocument(b, { info: loadConstitution().info }) + assert.equal((html.match(/class="sheet"/g) ?? []).length, 3, 'one sheet per body') + const { substantiveHash } = await import('../src/bill.mjs') + assert.ok(html.includes(substantiveHash(b)), 'the sheet must carry the hash') + for (const body of ['Board', 'Intermediate Board', 'Units']) { + assert.ok(html.includes(body), `no sheet for ${body}`) + } + // Collapse whitespace: the copy is wrapped in the source, so these phrases + // span newlines in the rendered HTML. + const flat = html.replace(/\s+/g, ' ') + assert.match(flat, /present and voting/i) + assert.match(flat, /individual members' votes are not published/i) +}) From 3eb335094615ba9e0148293d0e1220a56dfe9fa3 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:24:33 +0530 Subject: [PATCH 05/13] Part 2: the propose page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A static builder at /propose/ that produces a draft bill and nothing else. It cannot authenticate, number, submit or approve, and it says so plainly rather than implying otherwise — a static page pretending to verify membership would be theatre. Authority stays with the ICC and the three bodies. Two guarantees are structural rather than advisory: - A target is PICKED from a searchable index, never typed, so an operation cannot name a provision that does not exist. - Choosing a target PREFILLS its current text. The author edits a whole provision into its new form and physically cannot write "insert after the words…". Full-text operations stop being a rule authors must follow and become the only thing the form can produce. Validation is the same JSON Schema the CLI enforces, compiled to a standalone browser module at build time. A hand-written second check in the page would be a second implementation free to drift, which is the class of failure this project began with when the docs, the specs and the renderer each described a different root key. provisions.json carries every provision's id, title, title_source and current text, plus the next free article number and any reserved slot, so an insert is validated against the numbering rules rather than trusted. One defect found by driving the page in a real browser, and it was the important one: the hash the page displayed did not equal the hash the CLI computed from the downloaded file. A YAML block scalar always round-trips with exactly one trailing newline, and the page was hashing the raw textarea value without it. A meeting would have resolved on a number that did not match the file it was voting on — the precise failure the hash exists to prevent. The page now hashes the form it actually writes. tests/propose.e2e.mjs drives the real page: the prefill is the provision as it currently reads, the CLI validator accepts what the page produces, the two hashes agree, the form offers no revision type, and the picker is fully keyboard-operable. Also adds the shared-evidence coherence warning and the per-body recording sentence from the Part 1 refinements: one record may prove a joint sitting, but a body's two-thirds is proven only by that body's own tally — evidence can be shared, arithmetic cannot. 91 unit tests, 15 e2e. Constitution untouched; tripwire green at zero. --- process/MINUTES-TEMPLATE.md | 4 + src/bill.mjs | 25 ++ src/build.mjs | 48 ++++ src/gen-bill-validator.mjs | 33 +++ src/scripts/propose.js | 468 ++++++++++++++++++++++++++++++++++++ src/templates/layout.mjs | 1 + src/templates/propose.mjs | 153 ++++++++++++ tests/propose.e2e.mjs | 177 ++++++++++++++ 8 files changed, 909 insertions(+) create mode 100644 src/gen-bill-validator.mjs create mode 100644 src/scripts/propose.js create mode 100644 src/templates/propose.mjs create mode 100644 tests/propose.e2e.mjs diff --git a/process/MINUTES-TEMPLATE.md b/process/MINUTES-TEMPLATE.md index fde3f6a..1f8ebd4 100644 --- a/process/MINUTES-TEMPLATE.md +++ b/process/MINUTES-TEMPLATE.md @@ -74,6 +74,10 @@ The three bodies may sit together, and often should — it turns a re-vote after meeting instead of three. One signed record may then serve all three bodies and the same file may be referenced by each. +**In a joint sitting, attendance and votes are recorded per body, never for the room.** Three ballot +sheets filled at one meeting, or one combined record — either way the counts stay per body. This is +the one recording error a joint sitting invites. + **The tallies must still be recorded separately per body.** Article 16(3) says "collectively", which bears two readings, and until the board resolves which one it means, the stricter governs: two thirds within each body, counted on its own. A joint sitting that records only a pooled count cannot satisfy diff --git a/src/bill.mjs b/src/bill.mjs index 52954fe..f6ef444 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -273,6 +273,31 @@ export function validateBill (file, { constitution } = {}) { } } + // Evidence can be shared; arithmetic cannot. One record legitimately proves a + // joint sitting — who presided, what was resolved — but a body's two-thirds is + // proven only by that body's own tally. A shared path across bodies whose + // meetings differ is also exactly what a copy-paste mistake looks like, so it + // is worth a second look without being forbidden. + const byPath = new Map() + for (const a of bill.approvals ?? []) { + if (!a.evidence?.path) continue + if (!byPath.has(a.evidence.path)) byPath.set(a.evidence.path, []) + byPath.get(a.evidence.path).push(a) + } + for (const [evPath, shared] of byPath) { + if (shared.length < 2) continue + const dates = new Set(shared.map(a => a.meeting?.date ?? null)) + const modes = new Set(shared.map(a => a.meeting?.mode ?? null)) + if (dates.size > 1 || modes.size > 1) { + p.warn('shared-evidence-incoherent', + `${shared.map(a => a.body).join(', ')} share the record ${evPath} but their meetings differ ` + + `(${dates.size > 1 ? `dates ${[...dates].join(', ')}` : ''}${dates.size > 1 && modes.size > 1 ? '; ' : ''}` + + `${modes.size > 1 ? `modes ${[...modes].join(', ')}` : ''}). One compiled record covering ` + + 'separate meetings is legitimate archival practice — check that this is that, and not a ' + + 'copy-paste.', 'approvals') + } + } + if (stale.length) { p.error('approval-stale', `edit recorded — approvals by ${stale.map(s2 => s2.body).join(', ')} are void; move them to ` + diff --git a/src/build.mjs b/src/build.mjs index ce69735..627ede1 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -17,6 +17,8 @@ import { layout, tocSections } from './templates/layout.mjs' 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 { generateBillValidator } from './gen-bill-validator.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OUT = () => path.join(ROOT, process.env.OUT_DIR ?? 'dist') @@ -336,6 +338,22 @@ export function build () { main: billsMain(bills, { url, escapeHtml, actIndex }) }))) + // ---- propose: author a bill without editing YAML ---- + 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 }) + }))) + // ---- archive ---- const versions = fs.existsSync(path.join(ROOT, VERSIONS_DIR)) ? fs.readdirSync(path.join(ROOT, VERSIONS_DIR)).filter(f => f.endsWith('.yaml')).sort() @@ -437,6 +455,31 @@ 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. + 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) + })) 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)))) @@ -474,6 +517,11 @@ export function build () { copyDir('src/styles', 'styles') copyDir('src/scripts', 'scripts') + // 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. + write('scripts/bill-validator.mjs', generateBillValidator()) + // The custom domain stays on the old site until it has been reviewed. if (INCLUDE_CNAME && fs.existsSync(path.join(ROOT, 'CNAME'))) { fs.copyFileSync(path.join(ROOT, 'CNAME'), path.join(OUT(), 'CNAME')) diff --git a/src/gen-bill-validator.mjs b/src/gen-bill-validator.mjs new file mode 100644 index 0000000..b43d3f2 --- /dev/null +++ b/src/gen-bill-validator.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Compiles the bill schema into a standalone browser validator. + * + * The propose page must enforce the SAME schema the CLI enforces. Hand-writing + * a second check in the page would create a second, drifting implementation — + * which is the class of failure this project began with, when the docs, the + * specs and the renderer each described a different root key. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import Ajv from 'ajv/dist/2020.js' +import addFormats from 'ajv-formats' +import standaloneCode from 'ajv/dist/standalone/index.js' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +export function generateBillValidator () { + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'schema/opencodelaw-bill-1.0.schema.json'), 'utf8')) + const ajv = new Ajv({ code: { source: true, esm: true }, allErrors: true, strict: false }) + addFormats(ajv) + const validate = ajv.compile(schema) + return standaloneCode(ajv, validate) +} + +const direct = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +if (direct) { + const out = path.join(ROOT, 'dist/scripts/bill-validator.mjs') + fs.mkdirSync(path.dirname(out), { recursive: true }) + fs.writeFileSync(out, generateBillValidator()) + console.log(`wrote ${path.relative(ROOT, out)} (${fs.statSync(out).size} bytes)`) +} diff --git a/src/scripts/propose.js b/src/scripts/propose.js new file mode 100644 index 0000000..f826971 --- /dev/null +++ b/src/scripts/propose.js @@ -0,0 +1,468 @@ +/** + * 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. + */ + +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 +} + +/** + * The exact string a YAML `|` block yields on the way back in: trailing + * whitespace stripped, then one newline. Hash what will be written, not what + * the textarea happens to hold. + */ +const blockText = s => String(s ?? '').replace(/\s+$/, '') + '\n' + +/** Byte-identical to canonicalJson in src/bill.mjs. */ +function canonicalJson (value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null) + if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']' + return '{' + Object.keys(value).sort() + .filter(k => value[k] !== undefined) + .map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])) + .join(',') + '}' +} + +async function substantiveHash (bill) { + const subject = { + short_title: bill.bill.short_title ?? null, + type: bill.bill.type ?? null, + base_version: bill.bill.base_version ?? null, + objects_and_reasons: bill.objects_and_reasons ?? null, + operations: bill.operations ?? [] + } + const bytes = new TextEncoder().encode(canonicalJson(subject)) + 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}:` + +// --------------------------------------------------------------------------- + +function yamlOf (bill) { + const q = s => { + const str = String(s ?? '') + return /^[\w .,'’()\-\/&]+$/.test(str) && !/^\s|\s$/.test(str) ? str : JSON.stringify(str) + } + const block = (text, indent) => String(text ?? '').replace(/\s+$/, '') + .split('\n').map(l => ' '.repeat(indent) + l).join('\n') + + const b = bill.bill + const out = [ + '# Drafted with the propose page. Review it, then send it to the ICC.', + '# The bill file is the source of truth; the signed PDF is a rendering of it.', + '', + 'opencodelaw_bill: "1.0"', + 'bill:', + ` short_title: ${q(b.short_title)}`, + ...(b.also_known_as ? [` also_known_as: ${q(b.also_known_as)}`] : []), + ` year: ${b.year}`, + ' number: ~', + ` type: ${b.type}`, + ' moved_by:', + ` name: ${q(b.moved_by.name)}`, + ...(b.moved_by.role ? [` role: ${q(b.moved_by.role)}`] : []), + ...(b.moved_by.contact ? [` contact: ${q(b.moved_by.contact)}`] : []), + ` drafted: ${b.drafted}`, + ` base_version: "${b.base_version}"`, + ` version_bump: ${b.version_bump}`, + 'status: draft', + 'history: []', + 'objects_and_reasons: |', + block(bill.objects_and_reasons || '(none given)', 2), + 'operations:' + ] + for (const op of bill.operations) { + out.push(` - id: ${op.id}`) + out.push(` operation: ${op.operation}`) + out.push(` target: ${op.target}`) + out.push(` scope: ${op.scope}`) + if (op.title) out.push(` title: ${q(op.title)}`) + if (op.note) out.push(` note: ${q(op.note)}`) + if (op.text != null) { out.push(' text: |'); out.push(block(op.text, 6)) } + } + out.push('approvals:') + for (const a of bill.approvals) { + out.push(` - body: ${a.body}`) + out.push(' meeting: {date: ~}') + out.push(' present: ~') + out.push(' for: ~') + out.push(' against: ~') + out.push(' abstain: ~') + out.push(' bill_sha256: ~') + } + out.push('enactment:') + for (const k of ['act_number', 'act_year', 'assent_date', 'assented_by', 'signed_by', 'signed_pdf', 'signed_pdf_sha256']) { + out.push(` ${k}: ~`) + } + return out.join('\n') + '\n' +} + +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([yamlOf(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/templates/layout.mjs b/src/templates/layout.mjs index 080889e..a49b00a 100644 --- a/src/templates/layout.mjs +++ b/src/templates/layout.mjs @@ -196,6 +196,7 @@ export function layout ({ Constitution · Amendments · Bills · + Propose · Archive ${info.contact?.email ? ` · Contact` : ''}

    diff --git a/src/templates/propose.mjs b/src/templates/propose.mjs new file mode 100644 index 0000000..fc2efd9 --- /dev/null +++ b/src/templates/propose.mjs @@ -0,0 +1,153 @@ +/** + * The propose page: author a bill without editing YAML. + * + * It produces a DRAFT and nothing else. There is no backend, and pretending + * otherwise would be theatre: the page cannot authenticate a member, cannot + * assign a number, and cannot record an approval. All authority stays with the + * ICC and the three bodies. What it can do is make the one rule that matters + * — operations carry the complete resulting text — true by construction, by + * prefilling the current text and letting the author edit it. Nobody can write + * a splice through this form. + */ + +export function proposeTocItems () { + return [ + { id: 'you', label: 'Who you are' }, + { id: 'bill', label: 'The bill' }, + { id: 'operations', label: 'What it changes' }, + { id: 'check', label: 'Check and download' } + ] +} + +export function proposeMain ({ url, escapeHtml, info }) { + const contact = info.contact?.email ?? null + return ` +

    Propose an amendment

    +

    This page builds a draft bill and hands it back to you as a + file. It does not submit anything, number anything, or approve anything — that is the Internal + Compliance Committee's, and the three approving bodies'.

    + + + + + +
    +
    +

    Who you are

    +

    The constitution does not say who may propose an amendment, so any member may. Your name + is recorded in the bill permanently, from the moment you draft it.

    +
    +
    +
    +
    +
    + +

    Checked against the register by the ICC. This page + cannot verify it.

    +
    +
    +
    + +
    +

    The bill

    +
    + +

    A revision re-adopts the whole + constitution. It is board-initiated and not available here.

    +
    +
    +
    +
    +
    + +

    Why you are proposing this. Explanatory only — + it is printed at the end of the Act and can never be cited as authority for anything.

    +
    + +
    +

    What it changes

    +

    One entry per provision. Pick the provision, then edit its text into exactly how it should + read once your bill is applied. You are editing the whole provision, not describing a + change to it — that is what lets an Act be applied safely and applied twice without + harm.

    +
    + +
    + +
    +

    Check and download

    +
    +
    +

    Substantive hash — what the approving bodies + resolve on. It changes whenever the bill's text changes.

    +

    +

    +
    + +
    Preview the instrument +
    + +
    +
    + + ` +} diff --git a/tests/propose.e2e.mjs b/tests/propose.e2e.mjs new file mode 100644 index 0000000..552d0ad --- /dev/null +++ b/tests/propose.e2e.mjs @@ -0,0 +1,177 @@ +/** + * The propose page, driven in a real browser. + * + * The assertion that matters: what the page produces must be what the CLI + * accepts, and its substantive hash must equal the CLI's for the same bill. Two + * implementations of either would be free to drift — the class of failure this + * project began with, when the docs, the specs and the renderer each described + * a different root key. + * + * Not part of `npm test`; run with `npm run test:e2e`. Skips cleanly with no + * Chrome rather than failing the build. + */ +import { test, before, after, describe } from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import yaml from 'js-yaml' +import { execSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { substantiveHash, validateBill } from '../src/bill.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const DIST = path.join(ROOT, 'dist') +const PORT = 8141 + +function findChrome () { + for (const c of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) { + try { return execSync(`command -v ${c}`, { encoding: 'utf8' }).trim() } catch { /* next */ } + } + return process.env.CHROME_PATH || null +} +const CHROME = findChrome() +const TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.woff': 'font/woff', '.ttf': 'font/ttf' } + +let server, browser, tmp + +describe('propose page', { skip: !CHROME || !fs.existsSync(path.join(DIST, 'propose/index.html')) ? 'no Chrome or no build' : false }, () => { + before(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'propose-')) + server = http.createServer((req, res) => { + const p = decodeURIComponent(req.url.split('?')[0]) + let file = path.join(DIST, p) + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html') + if (!fs.existsSync(file)) { res.writeHead(404).end('not found'); return } + res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' }) + fs.createReadStream(file).pipe(res) + }) + await new Promise(r => server.listen(PORT, r)) + const puppeteer = (await import('puppeteer-core')).default + browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] }) + }) + + after(async () => { + await browser?.close() + await new Promise(r => server?.close(r)) + fs.rmSync(tmp, { recursive: true, force: true }) + }) + + /** Fill the form the way a proposer would and return what the page produced. */ + async function drive () { + const page = await browser.newPage() + const errors = [] + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) + page.on('pageerror', e => errors.push(e.message)) + await page.goto(`http://localhost:${PORT}/propose/`, { waitUntil: 'networkidle0' }) + + await page.type('#p-name', 'Orla Fenn') + await page.type('#p-role', 'Unit Head') + await page.type('#p-title', 'An Act to amend the Annual Report') + await page.type('#p-objects', '1. Article 13 is amended for clarity.') + + // The target is PICKED. There is no freehand id field to type into. + await page.type('.op__search', 'Annual') + await page.waitForSelector('.op__results li', { timeout: 4000 }) + await page.evaluate(() => document.querySelector('.op__results li') + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + await new Promise(r => setTimeout(r, 300)) + + const prefilled = await page.$eval('.op__text', el => el.value) + await page.evaluate(() => { + const t = document.querySelector('.op__text') + t.value = t.value.trimEnd() + '\n5. An added clause, for the test.\n' + t.dispatchEvent(new Event('input', { bubbles: true })) + }) + await new Promise(r => setTimeout(r, 700)) + + const result = await page.evaluate(() => ({ + hash: document.querySelector('#hash-out').textContent.trim(), + ready: !document.querySelector('#download').disabled, + report: document.querySelector('#check-report').textContent.replace(/\s+/g, ' ').trim() + })) + // The exact bytes the download button writes. + const text = await page.evaluate(async () => { + const mod = await import('/scripts/propose.js') + return null // module does not export; captured below instead + }).catch(() => null) + void text + + const downloaded = await page.evaluate(() => { + // Re-run the page's own serialiser through a click, capturing the blob. + return new Promise(resolve => { + const orig = URL.createObjectURL + URL.createObjectURL = blob => { blob.text().then(resolve); return orig.call(URL, blob) } + document.querySelector('#download').click() + }) + }) + + await page.close() + return { ...result, prefilled, downloaded, errors } + } + + test('the target is prefilled with its current text, so the operation is full-text', async () => { + const r = await drive() + assert.ok(r.prefilled.length > 100, + 'picking a provision must prefill its current text — that is what makes a splice impossible') + assert.match(r.prefilled, /All units must maintain task reports/, + 'the prefill must be the provision as it currently reads') + }) + + test('the page produces a draft the CLI validator accepts', async () => { + const r = await drive() + assert.deepEqual(r.errors, [], `console errors: ${r.errors.join(' | ')}`) + assert.ok(r.ready, `download should be enabled; report said: ${r.report}`) + assert.ok(r.downloaded, 'the download produced no file') + + const file = path.join(tmp, 'from-page.yaml') + fs.writeFileSync(file, r.downloaded) + + // Parses, and validates against the same schema and rules the CLI runs. + const parsed = yaml.load(r.downloaded, { schema: yaml.CORE_SCHEMA }) + assert.equal(parsed.status, 'draft', 'the page produces drafts only') + assert.equal(parsed.bill.number, null, 'the page never numbers a bill') + + const { problems } = validateBill(file) + assert.deepEqual(problems.errors.map(e => `${e.code}: ${e.message}`), [], + 'the CLI must accept what the page produced') + }) + + test('the hash the page shows equals the hash the CLI computes', async () => { + const r = await drive() + const file = path.join(tmp, 'hash-check.yaml') + fs.writeFileSync(file, r.downloaded) + const parsed = yaml.load(r.downloaded, { schema: yaml.CORE_SCHEMA }) + assert.equal(r.hash, substantiveHash(parsed), + 'the page and the CLI must agree on the hash a meeting resolves upon') + assert.match(r.hash, /^[a-f0-9]{64}$/) + }) + + test('the page states plainly that it cannot verify membership or submit', async () => { + const page = await browser.newPage() + await page.goto(`http://localhost:${PORT}/propose/`, { waitUntil: 'domcontentloaded' }) + const text = (await page.evaluate(() => document.body.textContent)).replace(/\s+/g, ' ') + assert.match(text, /cannot check who you are/i) + assert.match(text, /verified by the ICC/i) + assert.match(text, /cannot submit/i) + // revision is board-initiated and deliberately absent from the form. + const types = await page.$$eval('#p-type option', els => els.map(e => e.value)) + assert.deepEqual(types, ['amendment', 'corrigendum']) + await page.close() + }) + + test('the builder is keyboard-operable', async () => { + const page = await browser.newPage() + await page.goto(`http://localhost:${PORT}/propose/`, { waitUntil: 'networkidle0' }) + await page.focus('.op__search') + await page.keyboard.type('Annual') + await page.waitForSelector('.op__results li', { timeout: 4000 }) + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await new Promise(r => setTimeout(r, 300)) + const chosen = await page.$eval('.op__chosen', el => el.textContent) + assert.match(chosen, /Chosen:/, 'Enter on a highlighted result must choose it') + await page.close() + }) +}) From 0cc8f67903592174edf9fa7e2c8d5e4a381203d8 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:33:13 +0530 Subject: [PATCH 06/13] Share the serialiser; guard it with the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invariant is now stated where both sides can see it, at the top of the shared module: hash what will be PARSED, never what is DISPLAYED. Any path that hashes screen state, a textarea value, or a pre-serialisation object recreates the defect it names. canonicalJson, blockText, the hash subject and the YAML emitter move to one module shared verbatim by the CLI and the page. There were two copies of the first two; one shared normaliser is also what keeps untouched text comparing equal, which is the phantom-edit risk. Three guards close the hole the existing e2e could not reach — it catches a dropped REQUIRED field because the CLI rejects the output, but never a dropped OPTIONAL one, because absence is valid: 1. A maximal fixture with every optional field populated round-trips through the serialiser and must deep-equal its input. 2. A coverage test walks the schema's property paths; each must appear in the fixture or on an explicit exclusion list, so a new schema field fails BY NAME until it is handled or excluded deliberately. 3. The exclusion list doubles as the page's authority boundary, asserted: the page emits no number, no history, no approval tally, no evidence and no enactment field. It produces drafts and structurally cannot produce more. The round-trip guard immediately earned itself: history[].evidence: null was being dropped, because the emitter tested `!= null` rather than key presence. Present-but-null is not absent — it records that a transition had no evidence, which is itself a fact. history[].approval is now serialised too, so a voided approval survives verbatim rather than being reshaped. Adds the phantom-edit e2e: select a target, change nothing, download — the operation must classify as already-applied rather than as an edit nobody made. It passes, which is what the shared normaliser buys. 94 unit tests, 16 e2e. Constitution untouched; tripwire green at zero. --- src/bill.mjs | 24 +--- src/scripts/bill-serialise.mjs | 228 +++++++++++++++++++++++++++++++++ src/scripts/propose.js | 89 +------------ tests/bill-serialise.test.mjs | 195 ++++++++++++++++++++++++++++ tests/propose.e2e.mjs | 39 +++++- 5 files changed, 467 insertions(+), 108 deletions(-) create mode 100644 src/scripts/bill-serialise.mjs create mode 100644 tests/bill-serialise.test.mjs diff --git a/src/bill.mjs b/src/bill.mjs index f6ef444..38862ee 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -16,6 +16,7 @@ import yaml from 'js-yaml' import Ajv from 'ajv/dist/2020.js' import addFormats from 'ajv-formats' import { normalise } from './text-compare.mjs' +import { canonicalJson, substantiveSubject, blockText, SUBSTANTIVE_FIELDS } from './scripts/bill-serialise.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OPTS = { schema: yaml.CORE_SCHEMA } @@ -39,6 +40,8 @@ export const REQUIRED_BODIES = ['board', 'intermediate-board', 'units'] */ export const THRESHOLD = 2 / 3 +export { canonicalJson, blockText, SUBSTANTIVE_FIELDS } + export function tally (approvals = []) { const perBody = REQUIRED_BODIES.map(body => { const a = approvals.find(x => x.body === body) @@ -472,17 +475,6 @@ export function report ({ problems, manifest, tally: t, bill }) { * history, approvals, enactment — is clerking that changes after drafting, and * including it would make a vote go stale for administrative reasons. */ -export const SUBSTANTIVE_FIELDS = ['short_title', 'type', 'base_version', 'objects_and_reasons', 'operations'] - -/** RFC 8785-style canonical JSON: sorted keys, no insignificant whitespace. */ -export function canonicalJson (value) { - if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null) - if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']' - return '{' + Object.keys(value).sort() - .filter(k => value[k] !== undefined) - .map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])) - .join(',') + '}' -} /** * sha256 over the canonical form of what the bill actually proposes. @@ -492,14 +484,8 @@ export function canonicalJson (value) { * a title would silently survive a change to the text it approved. */ export function substantiveHash (bill) { - const subject = { - short_title: bill?.bill?.short_title ?? null, - type: bill?.bill?.type ?? null, - base_version: bill?.bill?.base_version ?? null, - objects_and_reasons: bill?.objects_and_reasons ?? null, - operations: bill?.operations ?? [] - } - return crypto.createHash('sha256').update(canonicalJson(subject), 'utf8').digest('hex') + return crypto.createHash('sha256') + .update(canonicalJson(substantiveSubject(bill)), 'utf8').digest('hex') } /** The sentence a meeting reads into its minutes. */ diff --git a/src/scripts/bill-serialise.mjs b/src/scripts/bill-serialise.mjs new file mode 100644 index 0000000..a9bf4cb --- /dev/null +++ b/src/scripts/bill-serialise.mjs @@ -0,0 +1,228 @@ +/** + * The bill's canonical form, its hash subject, and its YAML. + * + * Shared verbatim by the CLI (Node) and the propose page (browser). It lives + * under scripts/ because the build ships that directory to the site; the point + * is that there is exactly ONE of each of these functions, for the same reason + * there is exactly one schema. + * + * ───────────────────────────────────────────────────────────────────────────── + * THE INVARIANT + * + * Hash what will be PARSED, never what is DISPLAYED. + * + * The substantive hash is computed on the bytes → parse → canonical-JSON + * pipeline. Any code path that hashes screen state, a textarea's value, or a + * pre-serialisation object recreates the defect this comment exists to prevent: + * the propose page once displayed a hash the CLI did not agree with, because a + * YAML block scalar round-trips with exactly one trailing newline and the page + * was hashing the raw textarea. A meeting would have read a hash into its + * minutes that did not match the file it was voting on. + * ───────────────────────────────────────────────────────────────────────────── + */ + +/** + * The exact string a YAML `|` block yields on the way back in. + * + * Block-scalar chomping makes trailing newlines representationally unstable, + * which is what bit the hash — and what would otherwise produce a phantom edit, + * where an author changes nothing and the applier reports a change nobody made. + * Normalising here, in one place used by both sides, is what keeps + * "unchanged text" comparing equal. + */ +export const blockText = s => String(s ?? '').replace(/\s+$/, '') + '\n' + +/** RFC 8785-style canonical JSON: sorted keys, no insignificant whitespace. */ +export function canonicalJson (value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null) + if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']' + return '{' + Object.keys(value).sort() + .filter(k => value[k] !== undefined) + .map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])) + .join(',') + '}' +} + +/** The fields a body actually votes on. Everything else is clerking. */ +export const SUBSTANTIVE_FIELDS = ['short_title', 'type', 'base_version', 'objects_and_reasons', 'operations'] + +export function substantiveSubject (bill) { + return { + short_title: bill?.bill?.short_title ?? null, + type: bill?.bill?.type ?? null, + base_version: bill?.bill?.base_version ?? null, + objects_and_reasons: bill?.objects_and_reasons ?? null, + operations: bill?.operations ?? [] + } +} + +// --------------------------------------------------------------------------- +// YAML +// --------------------------------------------------------------------------- + +/** + * 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. + */ +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' +]) + +const scalar = s => { + const str = String(s ?? '') + return /^[\w .,'’()\-/&:]+$/.test(str) && !/^\s|\s$/.test(str) && !/:\s/.test(str) + ? str + : JSON.stringify(str) +} + +/** + * Emit an arbitrary object. Needed for `history[].approval`, which carries a + * voided approval verbatim and is deliberately open-ended in the schema: a + * body's vote is a legislative fact even after the text moves on, so it is + * preserved exactly as it stood rather than reshaped to a fixed form. + */ +function anyValue (v, indent) { + const pad = ' '.repeat(indent) + if (v === null || v === undefined) return '~' + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (typeof v === 'string') return v.includes('\n') ? `|\n${indentBlock(v, indent + 2)}` : scalar(v) + if (Array.isArray(v)) { + if (!v.length) return '[]' + return '\n' + v.map(x => `${pad}- ${anyValue(x, indent + 2).replace(/^\n/, '')}`).join('\n') + } + const keys = Object.keys(v) + if (!keys.length) return '{}' + return '\n' + keys.map(k => `${pad}${k}: ${anyValue(v[k], indent + 2)}`).join('\n') +} + +const indentBlock = (text, indent) => blockText(text).replace(/\n$/, '') + .split('\n').map(l => (l ? ' '.repeat(indent) + l : '')).join('\n') + +/** + * Serialise a bill to YAML. Hand-written because a YAML library in the browser + * is a large dependency for one fixed shape — and policed by a round-trip test + * over a fixture with every optional field populated, plus a schema-coverage + * test that fails BY NAME when the schema gains a field this does not handle. + */ +export function billToYaml (bill, { header = true } = {}) { + const b = bill.bill + const out = [] + if (header) { + out.push('# Drafted with the propose page. Review it, then send it to the ICC.', + '# The bill file is the source of truth; the signed PDF is a rendering of it.', '') + } + out.push('opencodelaw_bill: "1.0"', 'bill:') + out.push(` short_title: ${scalar(b.short_title)}`) + if (b.also_known_as) out.push(` also_known_as: ${scalar(b.also_known_as)}`) + out.push(` year: ${b.year}`) + out.push(` number: ${b.number == null ? '~' : b.number}`) + out.push(` type: ${b.type}`) + out.push(' moved_by:') + out.push(` name: ${scalar(b.moved_by?.name)}`) + if (b.moved_by?.role) out.push(` role: ${scalar(b.moved_by.role)}`) + if (b.moved_by?.contact) out.push(` contact: ${scalar(b.moved_by.contact)}`) + out.push(` drafted: ${b.drafted == null ? '~' : b.drafted}`) + out.push(` base_version: "${b.base_version}"`) + out.push(` version_bump: ${b.version_bump}`) + out.push(`status: ${bill.status}`) + + if (!bill.history?.length) out.push('history: []') + else { + out.push('history:') + for (const h of bill.history) { + out.push(` - date: ${h.date}`) + if (h.from) out.push(` from: ${h.from}`) + out.push(` to: ${h.to}`) + out.push(` actor: ${scalar(h.actor)}`) + // Present-but-null is not the same as absent: dropping it loses the + // record that this transition had no evidence, which is itself a fact. + if ('evidence' in h) out.push(` evidence: ${h.evidence == null ? '~' : scalar(h.evidence)}`) + if (h.note) out.push(` note: ${scalar(h.note)}`) + if (h.approval) out.push(` approval:${anyValue(h.approval, 6)}`) + } + } + + out.push('objects_and_reasons: |') + out.push(indentBlock(bill.objects_and_reasons ?? '', 2)) + + out.push('operations:') + for (const op of bill.operations ?? []) { + out.push(` - id: ${op.id}`) + out.push(` operation: ${op.operation}`) + out.push(` target: ${op.target}`) + out.push(` scope: ${op.scope}`) + if (op.clauses) out.push(` clauses: ${scalar(op.clauses)}`) + if (op.title) out.push(` title: ${scalar(op.title)}`) + if (op.note) out.push(` note: ${scalar(op.note)}`) + if (op.source_lines) out.push(` source_lines: ${scalar(op.source_lines)}`) + if (op.text != null) { out.push(' text: |'); out.push(indentBlock(op.text, 6)) } + 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(' text: |') + out.push(indentBlock(s.text, 10)) + } + } + } + + if (bill.approvals?.length) { + out.push('approvals:') + for (const a of bill.approvals) { + out.push(` - body: ${a.body}`) + if (a.meeting) { + const m = a.meeting + const bits = [`date: ${m.date == null ? '~' : m.date}`] + if (m.mode) bits.push(`mode: ${m.mode}`) + if (m.place) bits.push(`place: ${scalar(m.place)}`) + if (m.presiding) bits.push(`presiding: ${scalar(m.presiding)}`) + out.push(` meeting: {${bits.join(', ')}}`) + } + for (const k of ['present', 'for', 'against', 'abstain']) { + if (k in a) out.push(` ${k}: ${a[k] == null ? '~' : a[k]}`) + } + if ('bill_sha256' in a) out.push(` bill_sha256: ${a.bill_sha256 == null ? '~' : `"${a.bill_sha256}"`}`) + if (a.evidence) { + out.push(' evidence:') + out.push(` kind: ${a.evidence.kind}`) + out.push(` path: ${scalar(a.evidence.path)}`) + out.push(` sha256: "${a.evidence.sha256}"`) + if (a.evidence.url) out.push(` url: ${scalar(a.evidence.url)}`) + } + if (a.recorded_by) out.push(` recorded_by: ${scalar(a.recorded_by)}`) + if (a.note) out.push(` note: ${scalar(a.note)}`) + } + } + + if (bill.enactment) { + out.push('enactment:') + for (const k of ['act_number', 'act_year', 'assent_date', 'assented_by', 'signed_by', 'signed_pdf', 'signed_pdf_sha256', 'rendered_from']) { + if (!(k in bill.enactment)) continue + const v = bill.enactment[k] + out.push(` ${k}: ${v == null ? '~' : (typeof v === 'number' ? v : scalar(v))}`) + } + } + + return out.join('\n') + '\n' +} diff --git a/src/scripts/propose.js b/src/scripts/propose.js index f826971..140af8a 100644 --- a/src/scripts/propose.js +++ b/src/scripts/propose.js @@ -14,6 +14,8 @@ * 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)) @@ -276,32 +278,11 @@ function buildBill () { return bill } -/** - * The exact string a YAML `|` block yields on the way back in: trailing - * whitespace stripped, then one newline. Hash what will be written, not what - * the textarea happens to hold. - */ -const blockText = s => String(s ?? '').replace(/\s+$/, '') + '\n' - -/** Byte-identical to canonicalJson in src/bill.mjs. */ -function canonicalJson (value) { - if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null) - if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']' - return '{' + Object.keys(value).sort() - .filter(k => value[k] !== undefined) - .map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])) - .join(',') + '}' -} + async function substantiveHash (bill) { - const subject = { - short_title: bill.bill.short_title ?? null, - type: bill.bill.type ?? null, - base_version: bill.bill.base_version ?? null, - objects_and_reasons: bill.objects_and_reasons ?? null, - operations: bill.operations ?? [] - } - const bytes = new TextEncoder().encode(canonicalJson(subject)) + // 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('') } @@ -374,70 +355,12 @@ const verbFor = op => op.operation === 'insert' // --------------------------------------------------------------------------- -function yamlOf (bill) { - const q = s => { - const str = String(s ?? '') - return /^[\w .,'’()\-\/&]+$/.test(str) && !/^\s|\s$/.test(str) ? str : JSON.stringify(str) - } - const block = (text, indent) => String(text ?? '').replace(/\s+$/, '') - .split('\n').map(l => ' '.repeat(indent) + l).join('\n') - - const b = bill.bill - const out = [ - '# Drafted with the propose page. Review it, then send it to the ICC.', - '# The bill file is the source of truth; the signed PDF is a rendering of it.', - '', - 'opencodelaw_bill: "1.0"', - 'bill:', - ` short_title: ${q(b.short_title)}`, - ...(b.also_known_as ? [` also_known_as: ${q(b.also_known_as)}`] : []), - ` year: ${b.year}`, - ' number: ~', - ` type: ${b.type}`, - ' moved_by:', - ` name: ${q(b.moved_by.name)}`, - ...(b.moved_by.role ? [` role: ${q(b.moved_by.role)}`] : []), - ...(b.moved_by.contact ? [` contact: ${q(b.moved_by.contact)}`] : []), - ` drafted: ${b.drafted}`, - ` base_version: "${b.base_version}"`, - ` version_bump: ${b.version_bump}`, - 'status: draft', - 'history: []', - 'objects_and_reasons: |', - block(bill.objects_and_reasons || '(none given)', 2), - 'operations:' - ] - for (const op of bill.operations) { - out.push(` - id: ${op.id}`) - out.push(` operation: ${op.operation}`) - out.push(` target: ${op.target}`) - out.push(` scope: ${op.scope}`) - if (op.title) out.push(` title: ${q(op.title)}`) - if (op.note) out.push(` note: ${q(op.note)}`) - if (op.text != null) { out.push(' text: |'); out.push(block(op.text, 6)) } - } - out.push('approvals:') - for (const a of bill.approvals) { - out.push(` - body: ${a.body}`) - out.push(' meeting: {date: ~}') - out.push(' present: ~') - out.push(' for: ~') - out.push(' against: ~') - out.push(' abstain: ~') - out.push(' bill_sha256: ~') - } - out.push('enactment:') - for (const k of ['act_number', 'act_year', 'assent_date', 'assented_by', 'signed_by', 'signed_pdf', 'signed_pdf_sha256']) { - out.push(` ${k}: ~`) - } - return out.join('\n') + '\n' -} 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([yamlOf(bill)], { type: 'text/yaml' }) + const blob = new Blob([billToYaml(bill)], { type: 'text/yaml' }) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = `draft-${slug}.yaml` diff --git a/tests/bill-serialise.test.mjs b/tests/bill-serialise.test.mjs new file mode 100644 index 0000000..5a7d1ff --- /dev/null +++ b/tests/bill-serialise.test.mjs @@ -0,0 +1,195 @@ +/** + * Guards the hand-written YAML serialiser. + * + * A YAML library in the browser is a large dependency for one fixed shape, so + * the emitter is hand-written — which means the schema has to police it. The + * chain: the schema gains a field → the coverage test names it → the maximal + * fixture gains it → the round-trip fails if the serialiser drops it. + * + * The existing e2e catches a dropped REQUIRED field, because the CLI rejects + * the output. It cannot catch a dropped OPTIONAL one, because absence is valid. + * That is the hole these tests close. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { billToYaml, PAGE_EXCLUDED, blockText } from '../src/scripts/bill-serialise.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const SCHEMA = JSON.parse(fs.readFileSync(path.join(ROOT, 'schema/opencodelaw-bill-1.0.schema.json'), 'utf8')) + +/** Every optional field populated, so a dropped key shows up as a deep-equal failure. */ +const MAXIMAL = { + opencodelaw_bill: '1.0', + bill: { + short_title: 'An Act to exercise every field', + also_known_as: 'Maximal Act, 2026', + year: 2026, + number: 4, + type: 'amendment', + moved_by: { name: 'Orla Fenn', role: 'Unit Head', contact: 'orla@example.org' }, + drafted: '2026-01-15', + base_version: '2.1.0', + version_bump: 'minor' + }, + status: 'enacted', + history: [ + { date: '2026-01-15', from: 'draft', to: 'submitted', actor: 'ICC', evidence: 'inbox-2026-01-15', note: 'Numbered on submission.' }, + { + date: '2026-02-01', + to: 'approval-voided', + actor: 'ICC', + evidence: null, + note: 'Operations edited; the board resolution no longer binds.', + // A voided approval is preserved verbatim: a body's vote is a + // legislative fact even after the text it approved has moved on. + approval: { + body: 'board', + present: 12, + for: 9, + against: 2, + abstain: 1, + bill_sha256: 'd'.repeat(64), + evidence: { kind: 'minutes', path: 'bills/2026/evidence/voided.pdf', sha256: 'e'.repeat(64) } + } + } + ], + objects_and_reasons: '1. Because the fixture must exercise every field.\n2. And a second line.\n', + operations: [ + { + id: 'op-1', + operation: 'substitute', + target: 'art-3', + scope: 'clause', + clauses: '1,2', + title: 'A Restated Heading', + note: 'A drafting note.', + source_lines: '10-20', + text: 'The complete resulting text.\n', + sections: [ + { number: 1, title: 'First', text: 'First section text.\n' }, + { number: 2, title: 'Second', text: 'Second section text.\n' } + ] + }, + { id: 'op-2', operation: 'omit', target: 'art-7', scope: 'article', note: 'Removed for the fixture.' } + ], + approvals: [ + { + body: 'board', + meeting: { date: '2026-03-01', mode: 'hybrid', place: 'The Long Room', presiding: 'A. Presider' }, + present: 12, for: 9, against: 2, abstain: 1, + bill_sha256: 'a'.repeat(64), + evidence: { kind: 'minutes', path: 'bills/2026/evidence/x.pdf', sha256: 'b'.repeat(64), url: 'https://example.org/minutes' }, + recorded_by: 'ICC Coordinator', + note: 'An approval note.' + } + ], + enactment: { + act_number: 4, act_year: 2026, assent_date: '2026-04-01', + assented_by: 'Internal Compliance Committee', signed_by: 'P. Priya', + signed_pdf: 'acts/pdf/x.pdf', signed_pdf_sha256: 'c'.repeat(64), + rendered_from: 'bills/2026/x.yaml' + } +} + +test('a bill with every optional field populated round-trips without loss', () => { + const round = yaml.load(billToYaml(MAXIMAL, { header: false }), { schema: yaml.CORE_SCHEMA }) + // Block scalars normalise trailing whitespace, so compare against that form. + const expected = structuredClone(MAXIMAL) + expected.objects_and_reasons = blockText(expected.objects_and_reasons) + for (const op of expected.operations) { + if (op.text != null) op.text = blockText(op.text) + for (const s of op.sections ?? []) s.text = blockText(s.text) + } + assert.deepEqual(round, expected, 'the serialiser dropped or altered a field') +}) + +/** Every leaf path the schema defines. */ +function schemaPaths (node, defs, prefix = '', seen = new Set()) { + const out = [] + if (!node || typeof node !== 'object') return out + if (node.$ref) { + const name = node.$ref.replace('#/$defs/', '') + if (seen.has(name)) return out + return schemaPaths(defs[name], defs, prefix, new Set([...seen, name])) + } + if (node.type === 'array' || node.items) { + return schemaPaths(node.items, defs, `${prefix}[]`, seen) + } + for (const [key, sub] of Object.entries(node.properties ?? {})) { + const p = prefix ? `${prefix}.${key}` : key + const child = schemaPaths(sub, defs, p, seen) + if (child.length) out.push(...child) + else out.push(p) + } + for (const branch of node.allOf ?? []) out.push(...schemaPaths(branch.then, defs, prefix, seen)) + return out +} + +test('every field the schema defines is either serialised or deliberately excluded', () => { + const paths = [...new Set(schemaPaths(SCHEMA, SCHEMA.$defs))].sort() + assert.ok(paths.length > 25, `expected the full schema, walked ${paths.length} paths`) + + const emitted = billToYaml(MAXIMAL, { header: false }) + const round = yaml.load(emitted, { schema: yaml.CORE_SCHEMA }) + + // A path is covered if ANY element of an array carries it — the voided + // approval sits on a later history entry than the first. + const present = (value, parts) => { + if (!parts.length) return value !== undefined + const [head, ...rest] = parts + if (head.endsWith('[]')) { + const arr = value?.[head.slice(0, -2)] + return Array.isArray(arr) && arr.some(item => present(item, rest)) + } + return present(value?.[head], rest) + } + const has = (obj, p) => present(obj, p.split('.')) + + const excluded = new Set(PAGE_EXCLUDED) + const missing = paths.filter(p => !has(round, p) && !excluded.has(p)) + assert.deepEqual(missing, [], + `these schema fields are neither serialised nor on the exclusion list — add them to the ` + + `maximal fixture (so the round-trip guards them) or to PAGE_EXCLUDED deliberately:\n ${missing.join('\n ')}`) +}) + +test('the exclusion list is the page authority boundary, and it holds', async () => { + // What the page emits must be a draft and nothing more. + const pageBill = { + opencodelaw_bill: '1.0', + bill: { + short_title: 'An Act from the page', year: 2026, number: null, type: 'amendment', + moved_by: { name: 'Orla Fenn' }, drafted: '2026-01-15', + base_version: '3.0.0', version_bump: 'minor' + }, + status: 'draft', + history: [], + objects_and_reasons: 'Because.\n', + operations: [{ id: 'op-1', operation: 'substitute', target: 'art-3', scope: 'article', text: 'New text.\n' }], + 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 + } + } + const round = yaml.load(billToYaml(pageBill), { schema: yaml.CORE_SCHEMA }) + + assert.equal(round.status, 'draft', 'the page produces drafts only') + assert.equal(round.bill.number, null, 'the page never numbers a bill') + assert.deepEqual(round.history, [], 'history is written by the pipeline, never by an author') + + for (const a of round.approvals) { + for (const k of ['present', 'for', 'against', 'abstain', 'bill_sha256']) { + assert.equal(a[k], null, `the page must not record ${k} — approvals are the bodies'`) + } + assert.equal(a.evidence, undefined, 'the page must not record evidence') + } + for (const v of Object.values(round.enactment)) { + assert.equal(v, null, 'the page must not record any enactment field') + } +}) diff --git a/tests/propose.e2e.mjs b/tests/propose.e2e.mjs index 552d0ad..6d8e842 100644 --- a/tests/propose.e2e.mjs +++ b/tests/propose.e2e.mjs @@ -92,12 +92,6 @@ describe('propose page', { skip: !CHROME || !fs.existsSync(path.join(DIST, 'prop report: document.querySelector('#check-report').textContent.replace(/\s+/g, ' ').trim() })) // The exact bytes the download button writes. - const text = await page.evaluate(async () => { - const mod = await import('/scripts/propose.js') - return null // module does not export; captured below instead - }).catch(() => null) - void text - const downloaded = await page.evaluate(() => { // Re-run the page's own serialiser through a click, capturing the blob. return new Promise(resolve => { @@ -111,6 +105,39 @@ describe('propose page', { skip: !CHROME || !fs.existsSync(path.join(DIST, 'prop return { ...result, prefilled, downloaded, errors } } + test('selecting a target and changing nothing produces no change', async () => { + // Block-scalar chomping makes trailing newlines representationally + // unstable. If prefill or round-trip shifts one, untouched text stops + // comparing equal to the provision and the applier reports an edit nobody + // made — a phantom change on a constitution. + const page = await browser.newPage() + await page.goto(`http://localhost:${PORT}/propose/`, { waitUntil: 'networkidle0' }) + await page.type('#p-name', 'Orla Fenn') + await page.type('#p-title', 'An Act that changes nothing') + await page.type('#p-objects', 'To test the no-change case.') + await page.type('.op__search', 'Annual') + await page.waitForSelector('.op__results li', { timeout: 4000 }) + await page.evaluate(() => document.querySelector('.op__results li') + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + await new Promise(r => setTimeout(r, 600)) + + const downloaded = await page.evaluate(() => new Promise(resolve => { + const orig = URL.createObjectURL + URL.createObjectURL = blob => { blob.text().then(resolve); return orig.call(URL, blob) } + document.querySelector('#download').click() + })) + await page.close() + + const parsed = yaml.load(downloaded, { schema: yaml.CORE_SCHEMA }) + const op = parsed.operations[0] + const { classifyOperation, loadConstitution } = await import('../src/bill.mjs') + const doc = loadConstitution() + const node = doc.articles.find(a => a.id === op.target) + assert.ok(node, `${op.target} should exist in the constitution`) + assert.equal(classifyOperation(op, node, null), 'already-applied', + 'an untouched provision must classify as no change, not as an edit') + }) + test('the target is prefilled with its current text, so the operation is full-text', async () => { const r = await drive() assert.ok(r.prefilled.length > 100, From 9bd8e3f70db2b3ec9574524f103e2c8e254c1c8c Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:48:55 +0530 Subject: [PATCH 07/13] =?UTF-8?q?Part=203=20(wip):=20bill=20gate=20?= =?UTF-8?q?=E2=80=94=20conflict=20detection=20and=20constitution=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- process/notes-part4.md | 29 +++++++++++++++++++++++++++++ src/bill-gate.mjs | Bin 0 -> 9468 bytes tests/bill-serialise.test.mjs | 9 +++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 process/notes-part4.md create mode 100644 src/bill-gate.mjs diff --git a/package.json b/package.json index 2cc39e1..77e88c3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "provenance": "node src/provenance.mjs", "linkcheck": "node src/linkcheck.mjs", "test:e2e": "node --test tests/*.e2e.mjs", - "sync-register": "node src/sync-register.mjs" + "sync-register": "node src/sync-register.mjs", + "bill-gate": "node src/bill-gate.mjs" }, "dependencies": { "ajv": "8.20.0", diff --git a/process/notes-part4.md b/process/notes-part4.md new file mode 100644 index 0000000..ea55fba --- /dev/null +++ b/process/notes-part4.md @@ -0,0 +1,29 @@ +# Part 4 carry-over — the serialiser switch (ruled, not yet done) + +The CLI writes BILL FILES through billToYaml. Register, constitution and other +CLI writes keep js-yaml — they were never part of this problem. + +Not for the hash: the substantive hash is computed from parsed content and +never depended on serialisation. The reasons are operational. + +1. The diff at the gate. Clerking writes currently re-dump with a different + dumper, so a page-authored draft is wholly reformatted at submission and the + reviewer sees style noise burying the one substantive change. This system's + review culture is "read the diff"; two serialisers poison the diff. +2. The test exercises what runs. The maximal-fixture round-trip currently + guards a path only the browser takes. + +Three conditions land BEFORE the switch: + +- Adversarial scalars in the maximal fixture: strings containing ": ", "#", + quotes, leading and trailing spaces, an empty string, unicode, a line that + itself looks like YAML syntax, and one very long line. A hand emitter's real + risk is quoting, not shape. +- Emit idempotence: emit(parse(emit(x))) === emit(x), so successive clerking + writes do not oscillate formatting. +- Minimal-diff golden test: `bill submit` on a page-authored draft changes only + number, status and the new history entry. + +Also for the process doc's authoring section: any CLI write strips comments. +Emitters emit objects and comments are not in the object. Comments belong in +TEMPLATE.yaml as guidance, never in a bill as record. True under either dumper. diff --git a/src/bill-gate.mjs b/src/bill-gate.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d4a808848c695cabf3e304626adf6b3633735f83 GIT binary patch literal 9468 zcmb_ie{b7H61~6uDb}s8q7s>M?yrM$PT;ssV<2e)+eLA3jEEvvHWP|exug|A6>uNo zKH+_mdvA7^Kjb*g;hMB|L@sA$XJ_8L8S4Apac#=+G|k64zf*aU=$-MSM?30~y1dn@ z((!Gc#!;pcy@<+cuJdYO)J;_B5No*X?Bqn1x~j|Es0xoni$z)7=|o|9I*a1!sa&R{ zju2HWG#-f5yK~-8jVkNhP+pO%v$9% z@-~I=C|@aq2Qs}sO|y)$;#(bmQgM_kqxGRPSnaC{!fAlwLZg1GQ(a{%6=8b`B#6NV zpP;R{OHEqjBV`J!2kJ|eMR_9DDh1JTkta}{!7fO_=F4T7R@$hdRJd1_QIf_qAMTl@ zrKz6MINE^=SYWEOsw-$wQ%&_c!PAMFuGB5`s5BQpjod!rD-jf>SzY3Y;5-qhaKFgJ z=8rnA)G{(^TBlh>w_#)czKj0X+2LX>cZNe(&|+`g&Xstif9dv$Ec;wGXAdg zXPnA0q{`EXlxdNys2Hxr8SyOrz3MiqR8rtFMA@^W^6^eOUle7fW=74*Vy*@Zx5Jqk z_#2C;y6xWL=jP@bl63qMZoXLMvD$PO#UdvfOL{O#LIb)>k-u+*l=?sOm@lC;bbeL>Iqz&bCS>naLi{BSr@ zgD@NncdWFx?=CMOZLJKVejXhvZX-N-dw%-i;^fuon`5=vVQS+HX^=wD;U{8ikwq)y zn=a>Rjzq`q7`jLr9v)=RG6b($BHNf^l9M41v$vPa)AKiHKOet_1-V|T3ta_Q13*ER z>SO>zOU;!%!C#+7%TxF8V(&!sjKToT2WUJCKqPN*1-C5r6U0r2q|@x;5@og zXp6T7w|d4<(Ma)mTGHA=uZj#jFbq@kV_L!=MY#%K7L-%XjVyHrBPZga@!p!&ZSZ@xnTSTEs&a+EfiJ>E zZEk}#9EDKl`oku^ZD@8pjy2G9J@EAbK=L$k7S>$^UJcpq?GX zqIGo^O~pDWr9R5TruffLe&qFhs!KflTuokZ!QOiN=7w(SUX~4VZ-;6^sH}4<1vrPe zn^o$&?rL)jO!@!_&h+NHiR1`jjRmMvBdVeX$N-#+yi*oDrv8jnaVZQet*c@V1V!1* zR)+%CERsh5R_h!9#Vhj>k|~jUu6~r7v18PK{`GIF=iO58$3>PQP*?}-;0mgdQcOJ-ygeP|FAKF1h_ZH~#lkK`0g=z?v^bJ_KI z9^OVKnA#&^Y}aPUfVXElU0p`yjb>&M4BkYGfHymljY^4-!?97557*B9V!@lT%Id(n z(iNrW&ylbG8JA&RqnK4J!blS#Qs#9YwpFX@rGM53203UxTBOX486Cg77vDVcn)CqOkHUJ5mHiL4~HQN zQmsueyp|mh?Yx)}6yydt{SWoGEmUdjbs6q9?=1DMU{D;YMp~oZBEq3^R!GhQ8JN0( zMjf&d!26P$_9IexIC7HbD9h0eDVX~+*<1J2h^%2K@G_9#9}G8YTCnJll_9^at(#E& zO1dXBjI6`f7|+5c>T#oEuq^56j!Etb8$dcwjTjX4MkiK~)X{T2L%0r#GQC09FptuFV1rt!h}h+TzRTBw z!A)AB;9m`rbT(t<-`^+6*)Q^Jg^vgb+5zDkB*LrH;~yv2Bb1bQnH&mJVmieI>G`1= z)Ya_o0|b~m>(GE`RKegqOp&BPT0T=IL>A3~Y#zd16yV2EE>80`t81?$^2Yg2Y5>kq z64mo_1{}lDvhxYL8#&$8g#8E`1IO8YT{x!O=h4c#z9(xO*JX)BNA(8zq!+;7(UPE9 zIfhseGe(efA@9zmBE)e~=O_cj$+SYYLE)-xQAqz$5hMiX(t*uuQ=w8Q`dQiuA?A=i zu$@+eskRt_?@5Kz8N|@&+Xx{dqP|>Hq`f-n^e?AqTu@u=Ic^8ZNZ2?5vl~->b+7)M zy#4ut)OgT!e$Qcyf6Mb2;UO?Is}q2tJPBDY0@p6)5koW7O;9uR2;e4jz}(d}QU_v0 zM{^xoB@8l@uc?Ns$;#TuwwJI-*fqI-@Z~56t628K`fzV8)@{Beut$n~{=rD??5is4 z{}udFe1jdl*P{NmtYfyQjwICXW_yWx{0blIl4w`ci09T#8(X-ane-^oD94vYsc+ zl`u^NLONG0q5BK!OuGsjsgQCC6k;&gOswnZlaP~(n83M3HLTXiD~rNLM-=u& zLv0YjMrmZcoEDiTduzmOvO!1yq=b3OKb;U*<|^UusDjQ+9!5i<=e?6L2XGjj8QAa~ zK6OEDp75zU6xN8o=PkIGzF;e{W#C^IRUV9AvB*=OYk}Z82s@)@K ze&Rl%hf``iwgs9f2HHv(v(+cgqI6DB1G-oQFwYx)HIq^2P-WI7b8 zvzd(!QUZ>USn<$C)tFf!5m5rL=ijgIY8iY$U+d?Dc_(Xutej z>2v0tmaERu4bm*O5q?#Zw1Tj^R$fyo6FlznpjG>41#&YuU#{MJ1%AgV+_rsJe^5KxE@;qOz03AbBrovq>OCJF!}1m*#DCxod6?P z6Cmude3Mf%&^e4-1{|Ue9`2jy0VWVRoe{V3;9a>8yb!o(kcB)%&cH$p-BW6IJfHx` zIE)EoF4uRnC88Z_8D-=TE6XA{4R%(bBAd8Or)(-Xu`sn+q;Xo*I2LMJrbc%YuhXkM zlzKjRLD0IP+=E|sH)FN`=l${1C^!7(fH9B*8F?~g*{ckvI&%`tf{xS$+Kl`raPU4v zQNlFf5OC8ae&qkZ#M}K@lGdvaeQ(5;r#Ta`u+EQ*Ty3QHFRT@BelER zee9>R%U8d=_;7r3c?LF)As=STs73AOY3YM(zClcz;W8oTVtL5db@Y+X)GWlfpsdWV zmY(chzM83r0w9WRv zEgc@Y(=aY=G2XO2uoxd0a3c&Oq?NYYYSytd;y%s^`fPwI9RY6KYN=8zU~0y)jqt^~ z4*dk1VcqM>_Kd>PwrI-jY}0I#APDjeQE%{#C)io_cCTPRp4nj1qv0u?Oe|rP8MKgo zPZM1&KI**#2`E$abzC}-@a}opz6S}c3^}^q?FrtTweM2UYnoe@fzCC)UvZDiT&bbL z9hYf3%&Gv*x2;`hW5K&lDf{`cp9~S0JCQ&1X`q9Fth8;L2Bl9loV2&rcobna7a)KD>-G(sefuV_-q4!M zpRer7X|(%Vw)xG$kRE`kwyZqymYWnoY=WbVMptqW-Omtr@|f{%3|mT@6&gSVAK z80bu+w}-NcLiX~pn)xouNC^(_6L+K@PiJvNQ)*}8RL&MvO1cM|u2C^6-tJO^%m85uI9J}~A zikq@)O^Ey7;qw+A;%U=R?dAr%PC->zq5ZM5<4Yp!ibXEz^E$ zs!w8XtKG%|7z=u&j?ivehJ^bK#6N!gR5ts-F}C5q@Q06*{NOH8d^SpEV0I?K?CJ#{ zFMK82JcfLuGMByDo&bxEL?u>8O zULK#lKK)TDA|Ox7Dg40SuI72*+IYva*6|(NyW@)s_vC?d6>H=d*E2BN;T*>0c#j~J z*^+)va53o>?fAlj`>Fym%7f6z2jL5OgC)~in;v*G3M$L`Xuk%4G0x51)st&d3DS<{ zdjR*|q)S-!U&HrB40m|mR)Gynt1?YKZ|TFxA<>`T&H{|LNyi%2hi { if (!parts.length) return value !== undefined const [head, ...rest] = parts From e53cb80a0e28a09b5d8b776a4e5e90d58684099f Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:50:09 +0530 Subject: [PATCH 08/13] Part 3: the technical department's gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull request touching bills/** now runs the full per-bill check — schema, target resolution, staleness, evidence files with matching checksums, and the threshold for any recorded approvals — plus two checks no single-bill run can see. Pending conflicts. Two OPEN bills amending the same provision are an error naming both, because whichever applies second would overwrite or contradict the first. Terminal bills (applied, rejected, withdrawn, lapsed) cannot conflict with anything. The gate does not resolve it: the ICC sequences them and the second rebases — and re-collects its approvals, because a rebase voids them, which the message says. The constitution guard. A pull request may touch constitution/current.yaml only when an enacted Act in the same pull request accounts for it: every provision whose text moved must be named by that Act's operations, and nothing else may have moved. Otherwise it fails with "the constitution is amended by `act apply`, not by hand." Verified against a synthetic hand edit, which the guard caught by name (art-2) before the commit was reset. An Act naming a provision the diff does not show is a warning rather than an error: expected when a provision already read as the Act prescribes, and suspicious otherwise. Warnings — including shared-evidence coherence — are emitted as GitHub annotations as well as log text. A warning nobody sees is a warning that does not exist. CI uploads the gate report and the rendered instruments and ballot sheets as artifacts, so the tech department reviews what the approving bodies actually read rather than a YAML diff. 96 tests. Constitution untouched; tripwire green at zero. --- .github/workflows/bills.yml | 90 +++++++++++++++++++++++++++++++++++++ .gitignore | 1 + tests/bill.test.mjs | 39 ++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .github/workflows/bills.yml diff --git a/.github/workflows/bills.yml b/.github/workflows/bills.yml new file mode 100644 index 0000000..bb0ce70 --- /dev/null +++ b/.github/workflows/bills.yml @@ -0,0 +1,90 @@ +name: Bill gate + +# The technical department's gate. ICC returns the approved artifact, the tech +# department opens a pull request, and this runs on it. +# +# Merging a bill file never amends the constitution. The constitution changes +# only through `act apply`, with its manifest and its tripwire. + +on: + pull_request: + paths: + - 'bills/**' + - 'constitution/current.yaml' + - 'acts/register.yaml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + gate: + name: Check bills + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The gate diffs against the base, so it needs the history to do it. + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + # Schema, target resolution, staleness, evidence files and their + # checksums, and the threshold for any recorded approvals — plus the + # cross-bill checks no single-bill run can see. + # + # Warnings surface as annotations, not only in this log: a warning nobody + # sees is a warning that does not exist. + - name: Run the bill gate + id: gate + env: + BASE_REF: origin/${{ github.base_ref }} + run: npm run bill-gate + + # What the approving bodies read, so the tech department reviews the same + # thing rather than a YAML diff. + - name: Upload the gate report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bill-gate-report + path: bill-gate-report.md + if-no-files-found: ignore + + - name: Render the instruments this pull request touches + if: always() + env: + BASE_REF: origin/${{ github.base_ref }} + run: | + set -e + mkdir -p rendered + for f in $(git diff --name-only "$BASE_REF"...HEAD -- 'bills/**/*.yaml' 'bills/**/*.yml' || true); do + [ -f "$f" ] || continue + echo "rendering $f" + node src/cli.mjs bill render "$f" || true + node src/cli.mjs bill ballot "$f" || true + done + find bills -name '*-ballot.html' -o -name '*.html' -o -name '*.txt' 2>/dev/null \ + | while read -r r; do cp "$r" rendered/ 2>/dev/null || true; done + ls -la rendered || true + + - name: Upload the rendered instruments + if: always() + uses: actions/upload-artifact@v4 + with: + name: rendered-instruments + path: rendered/ + if-no-files-found: ignore + + # The whole corpus still has to hold together. + - name: Validate the corpus + run: npm run validate + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore index 1cf1a43..0e3558f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ css/style.css.map *.log .DS_Store assets/og/ +bill-gate-report.md diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs index 81f4603..36faeb8 100644 --- a/tests/bill.test.mjs +++ b/tests/bill.test.mjs @@ -541,3 +541,42 @@ test('a resolution sheet renders only once a bill is scheduled', async () => { assert.match(flat, /present and voting/i) assert.match(flat, /individual members' votes are not published/i) }) + +// --- the gate -------------------------------------------------------------- + +test('two open bills on one provision are a conflict the ICC must sequence', async () => { + const { findConflicts, TERMINAL } = await import('../src/bill-gate.mjs') + const mk = (n, status, target) => ({ + rel: `bills/2026/b${n}.yaml`, + bill: { bill: { number: n, year: 2026, short_title: `Bill ${n}` }, status, operations: [{ target }] } + }) + + const conflicts = findConflicts([ + mk(1, 'submitted', 'art-13'), + mk(2, 'scheduled', 'art-13'), + mk(3, 'applied', 'art-13') // terminal: cannot conflict with anything + ]) + assert.equal(conflicts.length, 1, 'one conflicted provision') + assert.equal(conflicts[0].target, 'art-13') + assert.equal(conflicts[0].bills.length, 2, 'the applied bill must not be counted') + for (const n of ['Bill 1 of 2026', 'Bill 2 of 2026']) { + assert.ok(conflicts[0].message.includes(n), `${n} must be named`) + } + assert.match(conflicts[0].message, /rebase voids them/, + 'the message must say the second bill re-collects its approvals') + + // Different provisions never conflict. + assert.deepEqual(findConflicts([mk(1, 'submitted', 'art-13'), mk(2, 'submitted', 'art-14')]), []) + // Terminal states are terminal. + for (const s of ['applied', 'rejected', 'withdrawn', 'lapsed']) assert.ok(TERMINAL.has(s)) +}) + +test('the gate reports validator warnings, not only errors', async () => { + // A warning only in local CLI output is a warning nobody sees. The gate has + // to surface them, so it has to count them. + const { runGate } = await import('../src/bill-gate.mjs') + const r = runGate({ baseRef: 'HEAD', annotations: false }) + assert.equal(typeof r.warnings, 'number') + assert.equal(typeof r.errors, 'number') + assert.match(r.text, /Bill gate —/) +}) From 22e659e68b5adde8cd0c77c764e7dc7daa879369 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 21:59:17 +0530 Subject: [PATCH 09/13] Part 3 (wip): content-equality and append-only guards --- src/bill-gate.mjs | Bin 9468 -> 14180 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/bill-gate.mjs b/src/bill-gate.mjs index d4a808848c695cabf3e304626adf6b3633735f83..1efb26dff66535616877a645ddf707110886bf44 100644 GIT binary patch delta 4374 zcmb_f(QYG26&0G@uvn2YAk8GR8o}KtitUWu1_3W&vr%R?h@jbI$ObK|WR{e>tLCspkktv;aD9)4QM810clRLw&mH)Z-`>(E_3?+&2IGL(+ zKzm_U{^|acFRhnvt^T-Z5cPU=C`K~s_NSyQr81P(3Ns~|LN0eANf~TMDvn8|-d1dMnvuqf3|p}SFGh4OLL6Zq>(z{}mZIPDH2*IFt=V zWo^s>un*Z#S#NES3llXhO@=y=^fTRer>!P=A>m@mj?>ua3uUXbp&pHe!CQp$!A-fYXtxL@IV~gRzlf)KBDYpd#AY*>Ss!%<>hJ5{}gy%75Rz-@vml znFq`VH_>(?$YV=wsQjSzQ79JIy*D>$^O7} zit@qptbq?UzgF2t`T;oKP*qa-i+4UK{M{nXq&Wl-fWhO@a<4k1$3Z5{KxS=s#~if2 zu=4u;zQkqne;z&PXO!x+=T|iz}lyLh?qhS!=*QNKD4%z!ka(Ad(5yV*R*|&@Gqi(3~F(G0Rd` zKpn2+#AeE&EFufN<4vf9fR6L@nxH{>Iafd-rRI$cG-aeR#+7x)m7~gXFVt{7GJ5P* zbfw>M>h+R$GoZ~nonHj#dsSi13yXn=oQni{6HU8b$sz|yo`!I5Afs)*uY9<+zE_*Z zk*m50=&2mQWmF>Q$Y@Tq$%d#4$c4sZqV)@wKO(N25r$ZWS3hKh&V#8&}lMtjerJ1AzLazo4`KadcxHs zO}Ecwb0HEHi40i=qr(>#hg2lf5FHWkc#Es3YMZnWpPbtX;^1DjMTOC*lvLtABfIrJ z1PHYSS4HBi%sZk2f~b33s6s5&w@q}Ouy(M?JVkM5Xb|X+g8hehyv&tEXd2AILDz!0 zGG$Uoeh3g1P{rT+g+>54IA(^31TA`t&}yq&ryZykD9%USNxSZPh2kS%DuAMa5$G?% zsBAjNY!AV4Nl+{l5XrO#5Hb|GEwCSy!>E7)bXb9d!kY)qNscG9!U&8!aWd%~3bio^ zaGMp!S$3j^eI^`2i6erGZdP!N$JQp6wrg_|v1qr=Oy2oT8{MxnN`U3OrfVfnMd0?KL})`ze+= zDtKGK-RfdTU6%wmdezZ1X~uM0FpW9O_Qp}XV2!LAqzH<$Z7{PzJCRLh(b$0I=;{DW zW6Zi?tbiwMjcIvhj?dwR!4hmb%EFrKQ+_53Rpt=_J9x8!oLE#WjkQXVN0HSRxo)>j zz0VvQ&U(StS#MsqXT73V12V5T6g{&M+8O9>CmN%^Pnm`YE2%TG-cRmmBX^0uL zLqQN=%*md^3Em%A4D?y6cV-{;JkWl(Q=}bSX|q%Q2%vcB0IjY|-(uhA;DeOZrin(M2G45;`0OPh3 z>W+USKs{)4(@81U095f4jEVJHyG@^e&RZR?cxUxB$PiwO<88M+0vy1?^%ace%hd-M zO3|M!Sh7v$p1&*(u2Nk3yRHNBw6t-_CTBDO?KUp2@c6KaLT$P>AfYNyG~ZqDFr(JS z<-)}Q6mQl(ucG7`#|_0boNmGW7V*EP7NsJXirrDN(OZnJV&Xh??Agu@4m%vKI9y%L zfM9o$&5+;jjHuM*SKuncXN*a z6^%hiPQ1EJP$su-YWfXnKI%&|Z{E-do>;xcf@%*iJ7{p|_8?k*wse1GPSgf+1o2^I9=oO7;F8sSh~@{u+P zx}Y2mpf!myR9stWQiT*K|51Odv0;cH;@I42JBCA3bso3 zJS-2 Date: Sat, 15 Aug 2026 21:59:58 +0530 Subject: [PATCH 10/13] Strengthen the gate: content equality and append-only records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named-by is necessary and not sufficient. CI cannot assume the diff in front of it came from `act apply` — replacing that assumption is the guard's whole job — so every provision an Act names is now compared by CONTENT, through the same three-way classify the applier uses. One compare, everywhere. already-applied the PR applied it, or it already read that way apply the Act prescribes different text and the PR does not apply it: error divergent it matches neither the base nor the prescribed text: the Act names it, but this diff is not that Act: error The unmoved-but-named case now splits on content rather than suspicion: a genuine no-op stays a warning, and a provision matching neither is an error. Verified against a synthetic PR carrying an enacted Act naming art-5 while hand-editing art-5 to text the Act never said — caught as divergent. Append-only record paths. Modifying or deleting anything under constitution/versions/, acts/pdf/, acts/text/ or bills/**/evidence/ fails, as does touching a bill that was terminal in the base image. Additions are always allowed. This is "voided approvals are history, never deletions" at file granularity: nothing routinely re-validates a concluded bill, so its evidence was otherwise unprotected. A rename fails too, since git reports it as delete plus add — correct, because that conversation should start with a red check. Verified by tampering: a modified archived version and a deleted Act text file were both caught by name. CONTRIBUTING gains the lesson from testing the previous guard: commit first, test guards on throwaway branches, never stash around uncommitted work. 98 tests. Constitution untouched; tripwire green at zero. --- CONTRIBUTION.md | 12 ++++++++++++ tests/bill.test.mjs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 1e4f1e1..621d533 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -67,6 +67,18 @@ tests/ run against both the source and the built output - Accessibility is not optional: landmarks, visible focus, full keyboard operation, 4.5:1 contrast in both themes, `prefers-reduced-motion` honoured. +## Testing a guard + +Guards are tested on **throwaway branches with the synthetic edit committed** — never by stashing +around uncommitted work. Commit first; destructive git operations near an uncommitted tree are how a +careful process loses something. + +```bash +git commit -am "wip" # your real work is safe +# make the synthetic bad edit, commit it, run the guard, then: +git reset --hard # the synthetic edit is gone +``` + ## Pull requests - Branch from `main`. Reference the issue number. diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs index 36faeb8..429dfe0 100644 --- a/tests/bill.test.mjs +++ b/tests/bill.test.mjs @@ -580,3 +580,42 @@ test('the gate reports validator warnings, not only errors', async () => { assert.equal(typeof r.errors, 'number') assert.match(r.text, /Bill gate —/) }) + +test('the constitution guard compares content, not names', async () => { + // Named-by is necessary and not sufficient. CI cannot assume the diff in + // front of it came from `act apply` — replacing that assumption is the + // guard's whole job — so a named provision is compared three ways, using the + // same classify the applier uses. + const { classifyOperation } = await import('../src/bill.mjs') + const op = { id: 'op-1', operation: 'substitute', target: 'art-5', scope: 'article', text: 'What the Act prescribes.\n' } + const base = 'What it said before.\n' + + // The PR applied it: the provision now reads as prescribed. + assert.equal(classifyOperation(op, { content: 'What the Act prescribes.\n' }, base), 'already-applied') + // The PR did not apply it: still the base text. + assert.equal(classifyOperation(op, { content: base }, base), 'apply') + // The PR hand-edited it to something the Act never said. + assert.equal(classifyOperation(op, { content: 'Something else entirely.\n' }, base), 'divergent') +}) + +test('the record is append-only', async () => { + const { APPEND_ONLY, TERMINAL } = await import('../src/bill-gate.mjs') + const covered = p => APPEND_ONLY.some(r => (r.prefix && p.startsWith(r.prefix)) || (r.match && r.match.test(p))) + + // Archives, signed instruments, extracted text and evidence may only grow. + for (const p of [ + 'constitution/versions/v1.0.0.yaml', + 'acts/pdf/first-constitution-amendment-act-2024.pdf', + 'acts/text/third-constitution-amendment-act-2024.txt', + 'bills/2026/evidence/board-minutes.pdf' + ]) { + assert.ok(covered(p), `${p} must be append-only`) + } + // Ordinary engine and content paths are not frozen. + for (const p of ['src/bill.mjs', 'constitution/current.yaml', 'bills/2026/a-draft.yaml']) { + assert.ok(!covered(p), `${p} must remain editable`) + } + // A concluded bill is record; a resubmission is a new bill, not an edit. + for (const s of ['applied', 'rejected', 'withdrawn', 'lapsed']) assert.ok(TERMINAL.has(s)) + for (const s of ['draft', 'submitted', 'scheduled', 'approved', 'enacted']) assert.ok(!TERMINAL.has(s)) +}) From 1a45e378c29f0cdf629c02f169f6d04c057008bd Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 22:08:16 +0530 Subject: [PATCH 11/13] Part 4: unify the serialiser, close the freeze-point inversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI now writes BILL FILES through billToYaml — register and constitution writes keep js-yaml, which were never part of this. Not for the hash, which is computed from parsed content: the reasons are that two dumpers poison the diff the gate reviewer reads, and that the round-trip guard previously covered only the browser's path. All three conditions landed first, and two of them caught real defects: - Adversarial scalars — colons, hashes, quotes, leading and trailing spaces, an empty string, unicode, lines that look like YAML syntax, a very long line — found that `recorded_by: ''` was silently dropped. An empty string is not absence, the same defect as the null evidence. Every optional field now emits through one presence check rather than a truthiness test. - The same fixture found that an empty block scalar cannot represent "\n": YAML yields "". blockText now returns "" for empty input and the emitter writes text: "" — the format's actual round-trip, rather than a value it cannot carry. - Emit idempotence holds across three successive writes. - A minimal-diff golden test asserts that submitting a page-authored draft changes only number, status and the new history entry. Two idempotency bugs found by running the fixture lifecycle end to end: - Re-applying an insert returned divergent, because the article now exists. It is already-applied when the existing article reads as the Act prescribes, and divergent only when something else occupies that number. - Re-applying an omit or reserve returned apply forever. The operation is `omit`; the status it leaves is `omitted`. The check compared the two directly — and the lifecycle test agreed with it, because the test wrote the same wrong status the check expected. OPERATION_STATUS names the mapping once, and a test asserts the applier writes what the classifier expects. Process doc closes the inversion the ballot guard was already enforcing: the freeze point, "get the text right before you circulate", both mid-cycle recovery paths with the joint sitting named, and the sequencing rule. Plus the note that any CLI write strips comments — an emitter emits an object. 104 unit tests, 16 e2e. Constitution untouched; tripwire green at zero. --- process/AMENDMENT-PROCESS.md | 59 +++++++++++++++++++ process/PROPOSING.md | 12 ++++ process/notes-part4.md | 29 ---------- src/bill-cli.mjs | 18 +++++- src/bill.mjs | 24 +++++++- src/scripts/bill-serialise.mjs | 64 ++++++++++++++------- tests/bill-serialise.test.mjs | 94 ++++++++++++++++++++++++++++-- tests/bill.test.mjs | 101 +++++++++++++++++++++++++++++++++ 8 files changed, 342 insertions(+), 59 deletions(-) delete mode 100644 process/notes-part4.md diff --git a/process/AMENDMENT-PROCESS.md b/process/AMENDMENT-PROCESS.md index fb7e040..cf12c88 100644 --- a/process/AMENDMENT-PROCESS.md +++ b/process/AMENDMENT-PROCESS.md @@ -163,6 +163,56 @@ body that fell short. --- +## 5a. The freeze point, and what an edit costs + +**The ICC completes form review before any meeting is scheduled.** Circulation is the freeze: from +the moment a bill goes to the bodies, its text is what they are resolving on. + +`bill ballot` renders resolution sheets only for a bill at `scheduled` or later, and says why if you +ask earlier — a sheet for a bill still under form review would carry a hash the ICC is about to +change. + +> **Get the text right before you circulate.** + +That sentence is the whole discipline, and it is a discipline because an edit is expensive. **A vote +binds to the bill's substantive hash, not its title.** Edit the bill after a body has resolved and +that resolution is void: the body must resolve again. This holds even where the bill's own +operations are untouched — a rebase onto a newer constitution moves the hash too, because approval +attaches to an amendment *in context*, not to isolated strings. A provision can become contradictory +purely because other articles moved, and whether a rebase is "semantically clean" is not something a +tool can adjudicate honestly. + +There is no such thing as a typo fix to operative text that is beneath a body's notice. The +operative text of a bill *is* the constitutional text, enacted verbatim. This constitution already +carries a published defect that turns on one word — Article 6 defines `Unit Board Member` and +`Coordinator` identically, differing only by "in STM" and "in the STM". + +Voided approvals are **not deleted**. They move to the bill's `history` as `approval-voided` +entries, carrying the body, the tallies, the evidence and the hash they were recorded against. A +body's vote is a legislative fact even after the text has moved on. + +### If a defect surfaces mid-cycle + +Two paths. Name which one you are taking, in writing. + +**(a) Fix, void, re-collect.** The safe path, and the default whenever there is any doubt. A +**joint sitting** makes this one meeting rather than three — the bodies may sit together, and the +same signed record may serve all three. The tallies are still recorded per body: evidence can be +shared, arithmetic cannot. + +**(b) Pass as approved, correct by corrigendum.** Only for a defect that does not touch meaning. +That judgment belongs to the ICC and the board and is never made by the tool. See §8. + +### Sequencing + +**The ICC does not schedule votes on a bill while another bill is ahead of it** — approved but +unapplied, or enacted and pending. Rebases should land before approvals begin, never between +meetings. + +This is the cheap prevention for everything above. CI enforces the hard edge of it: two open bills +amending the same provision fail the gate, naming both, because whichever applies second would +overwrite or contradict the first. + ## 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, @@ -229,6 +279,15 @@ what those two definitions ought to say is an act of authorship, and it belongs --- +## 8a. A note on files + +**Any command that writes a bill strips comments.** The tools read a bill into an object and write +the object back; comments are not in the object. + +Comments belong in `bills/TEMPLATE.yaml`, as guidance to whoever is drafting. They do not belong in +a bill as record — anything that needs to be on the record goes in `objects_and_reasons`, an +operation's `note`, or the bill's `history`. + ## 9. Reference ### Statuses diff --git a/process/PROPOSING.md b/process/PROPOSING.md index ebc56ce..73546bc 100644 --- a/process/PROPOSING.md +++ b/process/PROPOSING.md @@ -193,6 +193,18 @@ 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**. If you do not know who holds diff --git a/process/notes-part4.md b/process/notes-part4.md deleted file mode 100644 index ea55fba..0000000 --- a/process/notes-part4.md +++ /dev/null @@ -1,29 +0,0 @@ -# Part 4 carry-over — the serialiser switch (ruled, not yet done) - -The CLI writes BILL FILES through billToYaml. Register, constitution and other -CLI writes keep js-yaml — they were never part of this problem. - -Not for the hash: the substantive hash is computed from parsed content and -never depended on serialisation. The reasons are operational. - -1. The diff at the gate. Clerking writes currently re-dump with a different - dumper, so a page-authored draft is wholly reformatted at submission and the - reviewer sees style noise burying the one substantive change. This system's - review culture is "read the diff"; two serialisers poison the diff. -2. The test exercises what runs. The maximal-fixture round-trip currently - guards a path only the browser takes. - -Three conditions land BEFORE the switch: - -- Adversarial scalars in the maximal fixture: strings containing ": ", "#", - quotes, leading and trailing spaces, an empty string, unicode, a line that - itself looks like YAML syntax, and one very long line. A hand emitter's real - risk is quoting, not shape. -- Emit idempotence: emit(parse(emit(x))) === emit(x), so successive clerking - writes do not oscillate formatting. -- Minimal-diff golden test: `bill submit` on a page-authored draft changes only - number, status and the new history entry. - -Also for the process doc's authoring section: any CLI write strips comments. -Emitters emit objects and comments are not in the object. Comments belong in -TEMPLATE.yaml as guidance, never in a bill as record. True under either dumper. diff --git a/src/bill-cli.mjs b/src/bill-cli.mjs index 926ad17..1233300 100644 --- a/src/bill-cli.mjs +++ b/src/bill-cli.mjs @@ -9,13 +9,29 @@ 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 { 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: '"' } const today = () => new Date().toISOString().slice(0, 10) -const save = (file, bill) => fs.writeFileSync(file, yaml.dump(bill, DUMP)) +/** + * Bill files are written through the same emitter the propose page uses. + * + * Not for the hash — that is computed from parsed content and never depended on + * serialisation. The reasons are operational: a page-authored draft re-dumped + * by a different dumper is wholly reformatted at submission, so the gate's + * reviewer sees style noise burying the one substantive change, and this + * system's review culture is "read the diff". It also puts both producers under + * the round-trip guard, which previously covered only the browser's path. + * + * Register and constitution writes keep js-yaml; they were never part of this. + * + * Note that any write strips comments: an emitter emits an object, and comments + * are not in the object. + */ +const save = (file, bill) => fs.writeFileSync(file, billToYaml(bill, { header: false })) function push (bill, from, to, actor, evidence, note) { bill.history ??= [] diff --git a/src/bill.mjs b/src/bill.mjs index 38862ee..91ba9c6 100644 --- a/src/bill.mjs +++ b/src/bill.mjs @@ -40,6 +40,9 @@ export const REQUIRED_BODIES = ['board', 'intermediate-board', 'units'] */ 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 = []) { @@ -410,8 +413,25 @@ export function classifyOperation (op, currentNode, baseText = null) { const proposed = normalise(operationText(op)) const current = normalise(fullText(currentNode)) - if (op.operation === 'insert') return currentNode ? 'divergent' : 'apply' - if (['omit', 'reserve'].includes(op.operation)) return currentNode ? 'apply' : 'already-applied' + 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' } diff --git a/src/scripts/bill-serialise.mjs b/src/scripts/bill-serialise.mjs index a9bf4cb..6f7e681 100644 --- a/src/scripts/bill-serialise.mjs +++ b/src/scripts/bill-serialise.mjs @@ -30,7 +30,13 @@ * Normalising here, in one place used by both sides, is what keeps * "unchanged text" comparing equal. */ -export const blockText = s => String(s ?? '').replace(/\s+$/, '') + '\n' +export const blockText = s => { + const body = String(s ?? '').replace(/\s+$/, '') + // Empty stays empty. A YAML `|` block with no content yields '', not '\n', + // so claiming a newline here would be a value the format cannot round-trip — + // and a provision with no text is empty, not "a newline". + return body === '' ? '' : body + '\n' +} /** RFC 8785-style canonical JSON: sorted keys, no insignificant whitespace. */ export function canonicalJson (value) { @@ -88,6 +94,15 @@ export const PAGE_EXCLUDED = Object.freeze([ 'enactment.rendered_from' ]) +/** + * Emit an optional key only when it is PRESENT — never when it is merely + * truthy. An empty string is not absence, and neither is null: both record + * something (a field deliberately left blank, a transition with no evidence) + * that a falsy test would erase. This is the same defect twice over, so it is + * funnelled through one helper. + */ +const opt = (obj, key, line) => (key in obj && obj[key] !== undefined) ? [line(obj[key])] : [] + const scalar = s => { const str = String(s ?? '') return /^[\w .,'’()\-/&:]+$/.test(str) && !/^\s|\s$/.test(str) && !/:\s/.test(str) @@ -133,14 +148,14 @@ export function billToYaml (bill, { header = true } = {}) { } out.push('opencodelaw_bill: "1.0"', 'bill:') out.push(` short_title: ${scalar(b.short_title)}`) - if (b.also_known_as) out.push(` also_known_as: ${scalar(b.also_known_as)}`) + out.push(...opt(b, 'also_known_as', v => ` also_known_as: ${scalar(v)}`)) out.push(` year: ${b.year}`) out.push(` number: ${b.number == null ? '~' : b.number}`) out.push(` type: ${b.type}`) out.push(' moved_by:') out.push(` name: ${scalar(b.moved_by?.name)}`) - if (b.moved_by?.role) out.push(` role: ${scalar(b.moved_by.role)}`) - if (b.moved_by?.contact) out.push(` contact: ${scalar(b.moved_by.contact)}`) + out.push(...opt(b.moved_by ?? {}, 'role', v => ` role: ${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}"`) out.push(` version_bump: ${b.version_bump}`) @@ -151,19 +166,19 @@ export function billToYaml (bill, { header = true } = {}) { out.push('history:') for (const h of bill.history) { out.push(` - date: ${h.date}`) - if (h.from) out.push(` from: ${h.from}`) + out.push(...opt(h, 'from', v => ` from: ${v}`)) out.push(` to: ${h.to}`) out.push(` actor: ${scalar(h.actor)}`) // Present-but-null is not the same as absent: dropping it loses the // record that this transition had no evidence, which is itself a fact. if ('evidence' in h) out.push(` evidence: ${h.evidence == null ? '~' : scalar(h.evidence)}`) - if (h.note) out.push(` note: ${scalar(h.note)}`) - if (h.approval) out.push(` approval:${anyValue(h.approval, 6)}`) + out.push(...opt(h, 'note', v => ` note: ${scalar(v)}`)) + out.push(...opt(h, 'approval', v => ` approval:${anyValue(v, 6)}`)) } } - out.push('objects_and_reasons: |') - out.push(indentBlock(bill.objects_and_reasons ?? '', 2)) + if (blockText(bill.objects_and_reasons) === '') out.push('objects_and_reasons: ""') + else { out.push('objects_and_reasons: |'); out.push(indentBlock(bill.objects_and_reasons, 2)) } out.push('operations:') for (const op of bill.operations ?? []) { @@ -171,18 +186,23 @@ export function billToYaml (bill, { header = true } = {}) { out.push(` operation: ${op.operation}`) out.push(` target: ${op.target}`) out.push(` scope: ${op.scope}`) - if (op.clauses) out.push(` clauses: ${scalar(op.clauses)}`) - if (op.title) out.push(` title: ${scalar(op.title)}`) - if (op.note) out.push(` note: ${scalar(op.note)}`) - if (op.source_lines) out.push(` source_lines: ${scalar(op.source_lines)}`) - if (op.text != null) { out.push(' text: |'); out.push(indentBlock(op.text, 6)) } + out.push(...opt(op, 'clauses', v => ` clauses: ${scalar(v)}`)) + out.push(...opt(op, 'title', v => ` title: ${scalar(v)}`)) + out.push(...opt(op, 'note', v => ` note: ${scalar(v)}`)) + out.push(...opt(op, 'source_lines', v => ` source_lines: ${scalar(v)}`)) + if (op.text != null) { + const t = blockText(op.text) + if (t === '') out.push(' text: ""') + else { out.push(' text: |'); out.push(indentBlock(op.text, 6)) } + } 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(' text: |') - out.push(indentBlock(s.text, 10)) + const st = blockText(s.text) + if (st === '') out.push(' text: ""') + else { out.push(' text: |'); out.push(indentBlock(s.text, 10)) } } } } @@ -194,9 +214,9 @@ export function billToYaml (bill, { header = true } = {}) { if (a.meeting) { const m = a.meeting const bits = [`date: ${m.date == null ? '~' : m.date}`] - if (m.mode) bits.push(`mode: ${m.mode}`) - if (m.place) bits.push(`place: ${scalar(m.place)}`) - if (m.presiding) bits.push(`presiding: ${scalar(m.presiding)}`) + bits.push(...opt(m, 'mode', v => `mode: ${v}`)) + bits.push(...opt(m, 'place', v => `place: ${scalar(v)}`)) + bits.push(...opt(m, 'presiding', v => `presiding: ${scalar(v)}`)) out.push(` meeting: {${bits.join(', ')}}`) } for (const k of ['present', 'for', 'against', 'abstain']) { @@ -208,10 +228,10 @@ export function billToYaml (bill, { header = true } = {}) { out.push(` kind: ${a.evidence.kind}`) out.push(` path: ${scalar(a.evidence.path)}`) out.push(` sha256: "${a.evidence.sha256}"`) - if (a.evidence.url) out.push(` url: ${scalar(a.evidence.url)}`) + out.push(...opt(a.evidence, 'url', v => ` url: ${scalar(v)}`)) } - if (a.recorded_by) out.push(` recorded_by: ${scalar(a.recorded_by)}`) - if (a.note) out.push(` note: ${scalar(a.note)}`) + out.push(...opt(a, 'recorded_by', v => ` recorded_by: ${scalar(v)}`)) + out.push(...opt(a, 'note', v => ` note: ${scalar(v)}`)) } } diff --git a/tests/bill-serialise.test.mjs b/tests/bill-serialise.test.mjs index 85c84a2..dd69d88 100644 --- a/tests/bill-serialise.test.mjs +++ b/tests/bill-serialise.test.mjs @@ -25,7 +25,8 @@ const SCHEMA = JSON.parse(fs.readFileSync(path.join(ROOT, 'schema/opencodelaw-bi const MAXIMAL = { opencodelaw_bill: '1.0', bill: { - short_title: 'An Act to exercise every field', + // Adversarial on purpose: a hand-written emitter's real risk is quoting. + short_title: 'An Act: with a colon-space, a # hash, "double" and \'single\' quotes', also_known_as: 'Maximal Act, 2026', year: 2026, number: 4, @@ -66,12 +67,17 @@ const MAXIMAL = { scope: 'clause', clauses: '1,2', title: 'A Restated Heading', - note: 'A drafting note.', + note: ' leading and trailing spaces ', source_lines: '10-20', - text: 'The complete resulting text.\n', + text: 'The complete resulting text.\n' + + 'key: value — a line that looks like YAML syntax\n' + + '- and one that looks like a list item\n' + + '# and one that looks like a comment\n' + + 'unicode: ₹ — “curly” ’apostrophes’ and an em dash\n' + + ('a very long line ' .repeat(40)) + '\n', sections: [ { number: 1, title: 'First', text: 'First section text.\n' }, - { number: 2, title: 'Second', text: 'Second section text.\n' } + { number: 2, title: 'Second: with a colon', text: '\n' } ] }, { id: 'op-2', operation: 'omit', target: 'art-7', scope: 'article', note: 'Removed for the fixture.' } @@ -83,7 +89,7 @@ const MAXIMAL = { present: 12, for: 9, against: 2, abstain: 1, bill_sha256: 'a'.repeat(64), evidence: { kind: 'minutes', path: 'bills/2026/evidence/x.pdf', sha256: 'b'.repeat(64), url: 'https://example.org/minutes' }, - recorded_by: 'ICC Coordinator', + recorded_by: '', note: 'An approval note.' } ], @@ -198,3 +204,81 @@ test('the exclusion list is the page authority boundary, and it holds', async () assert.equal(v, null, 'the page must not record any enactment field') } }) + +test('emitting is idempotent, so successive clerking writes do not churn', () => { + // submit, then approvals recorded, then enact — each re-writes the file. If + // the emitter's output shifted between saves, every clerking step would carry + // formatting noise into the diff the gate reviewer reads. + const once = billToYaml(MAXIMAL, { header: false }) + const twice = billToYaml(yaml.load(once, { schema: yaml.CORE_SCHEMA }), { header: false }) + const thrice = billToYaml(yaml.load(twice, { schema: yaml.CORE_SCHEMA }), { header: false }) + assert.equal(twice, once, 'emit(parse(emit(x))) must equal emit(x)') + assert.equal(thrice, twice, 'and must stay stable across further writes') +}) + +test('adversarial scalars survive the round trip', () => { + const round = yaml.load(billToYaml(MAXIMAL, { header: false }), { schema: yaml.CORE_SCHEMA }) + assert.equal(round.bill.short_title, MAXIMAL.bill.short_title, 'colons, hashes and quotes') + assert.equal(round.operations[0].note, MAXIMAL.operations[0].note, 'leading/trailing spaces') + assert.equal(round.operations[0].sections[1].title, 'Second: with a colon') + assert.match(round.operations[0].text, /key: value/, 'a line that looks like YAML syntax') + assert.match(round.operations[0].text, /^# and one that looks like a comment$/m) + assert.match(round.operations[0].text, /₹ — “curly”/, 'unicode') + assert.ok(round.operations[0].text.split('\n').some(l => l.length > 500), 'a very long line') + assert.equal(round.approvals[0].recorded_by, '', 'an empty string is not absence') +}) + +test('submitting a page-authored draft changes only number, status and history', async () => { + // The operational reason for one serialiser, expressed as a test: if a + // clerking write reformats the file, the gate reviewer reads style noise + // instead of the substantive change. + const os = await import('node:os') + const { billSubmit } = await import('../src/bill-cli.mjs') + const liveVersion = yaml.load( + fs.readFileSync(path.join(ROOT, 'constitution/current.yaml'), 'utf8'), + { schema: yaml.CORE_SCHEMA }).info.version + + const draft = { + opencodelaw_bill: '1.0', + bill: { + short_title: 'An Act from the page', year: 2026, number: null, type: 'amendment', + moved_by: { name: 'Orla Fenn', role: 'Unit Head' }, drafted: '2026-01-15', + base_version: liveVersion, + version_bump: 'minor' + }, + status: 'draft', + history: [], + objects_and_reasons: 'Because the diff must stay readable.\n', + operations: [{ id: 'op-1', operation: 'substitute', target: 'art-13', scope: 'article', text: 'New text for the annual report.\n' }], + 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 } + } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-')) + const file = path.join(dir, 'draft.yaml') + const before = billToYaml(draft) // exactly what the page would hand over + fs.writeFileSync(file, before) + + billSubmit(file, { actor: 'ICC' }) + const after = fs.readFileSync(file, 'utf8') + + const beforeLines = before.split('\n').filter(l => !l.startsWith('#')) + const afterLines = after.split('\n') + const removed = beforeLines.filter(l => !afterLines.includes(l)) + const added = afterLines.filter(l => !beforeLines.includes(l)) + + // Only the number, the status and the new history entry may move. + const allowed = /^(\s*number:|status:|history:|\s+- date:|\s+from:|\s+to:|\s+actor:|\s+note:)/ + for (const l of [...removed, ...added]) { + if (!l.trim()) continue + assert.match(l, allowed, `submission changed a line it should not have: ${JSON.stringify(l)}`) + } + const parsed = yaml.load(after, { schema: yaml.CORE_SCHEMA }) + assert.equal(parsed.status, 'submitted') + assert.equal(typeof parsed.bill.number, 'number') + assert.equal(parsed.history.length, 1) + assert.equal(parsed.operations[0].text, draft.operations[0].text, 'operation text must be untouched') + + fs.rmSync(dir, { recursive: true, force: true }) +}) diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs index 429dfe0..6d48b0f 100644 --- a/tests/bill.test.mjs +++ b/tests/bill.test.mjs @@ -619,3 +619,104 @@ test('the record is append-only', async () => { for (const s of ['applied', 'rejected', 'withdrawn', 'lapsed']) assert.ok(TERMINAL.has(s)) for (const s of ['draft', 'submitted', 'scheduled', 'approved', 'enacted']) assert.ok(!TERMINAL.has(s)) }) + +test('a fixture bill runs draft → applied, and re-applying is a clean no-op', async () => { + // The whole pipeline on a throwaway copy of the fixture constitution. Nothing + // here touches the real one: the applier is pointed at a temp tree. + const os = await import('node:os') + const { classifyOperation, fullText, OPERATION_STATUS } = await import('../src/bill.mjs') + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lifecycle-')) + const constFile = path.join(dir, 'constitution.yaml') + fs.copyFileSync(path.join(ROOT, 'examples/starter/fixture-constitution.yaml'), constFile) + const doc = yaml.load(fs.readFileSync(constFile, 'utf8'), { schema: yaml.CORE_SCHEMA }) + + const bill = loadBill(BILL_FILE) + const nodeOf = (d, id) => { + if (d.preamble?.id === id) return d.preamble + for (const a of d.articles ?? []) { + if (a.id === id) return a + for (const s of a.sections ?? []) if (s.id === id) return s + } + return null + } + + // Every operation must be applicable against the base it was drafted on. + for (const op of bill.operations) { + const node = nodeOf(doc, op.target) + const verdict = classifyOperation(op, node, node ? fullText(node) : null) + assert.notEqual(verdict, 'divergent', `${op.id} (${op.operation} ${op.target}) diverges from the base`) + } + + // Apply, the way actApply does, then re-classify: everything is a no-op. + for (const op of bill.operations) { + const node = nodeOf(doc, op.target) + if (op.operation === 'insert') { + doc.articles.push({ + id: op.target, number: Number(op.target.replace('art-', '')), + title: op.title, title_source: 'enacted', + ...(op.text ? { content: op.text } : {}) + }) + doc.articles.sort((a, b) => a.number - b.number) + } else if (op.operation === 'omit' || op.operation === 'reserve') { + for (const k of Object.keys(node)) if (!['id', 'number'].includes(k)) delete node[k] + Object.assign(node, { + title: op.operation === 'omit' ? 'Omitted' : 'Reserved', + status: OPERATION_STATUS[op.operation], // omitted / reserved, as actApply writes + note: op.note + }) + } else if (op.operation === 'retitle') { + node.title = op.title + } else { + if (op.title) node.title = op.title + if (op.text != null) node.content = op.text + if (op.sections?.length) { + node.sections = op.sections.map(s => ({ + id: `${op.target}-s-${s.number}`, number: s.number, title: s.title, content: s.text + })) + } + } + } + + for (const op of bill.operations) { + const node = nodeOf(doc, op.target) + assert.equal(classifyOperation(op, node, null), 'already-applied', + `${op.id} should be a clean no-op on re-apply — that is what full-text operations buy`) + } + + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('applying against a divergent target aborts rather than overwriting', async () => { + const { classifyOperation } = await import('../src/bill.mjs') + const op = { id: 'op-1', operation: 'substitute', target: 'art-3', scope: 'article', text: 'What the Act prescribes.\n' } + const drifted = { content: 'Something a third party wrote.\n' } + assert.equal(classifyOperation(op, drifted, 'What it said when drafted.\n'), 'divergent', + 'a target that matches neither the base nor the Act must abort, never be overwritten') +}) + +test('every operation type is idempotent against the status the applier writes', async () => { + // Written against OPERATION_STATUS rather than a literal, because the last + // bug here was the test and the code sharing the same wrong assumption: + // both said `status: 'omit'` while the applier writes `status: 'omitted'`. + const { classifyOperation, OPERATION_STATUS } = await import('../src/bill.mjs') + const applied = [ + ['substitute', { operation: 'substitute', text: 'T\n' }, { content: 'T\n' }], + ['insert', { operation: 'insert', text: 'T\n' }, { content: 'T\n' }], + ['retitle', { operation: 'retitle', title: 'X' }, { title: 'X' }], + ['omit', { operation: 'omit' }, { status: OPERATION_STATUS.omit }], + ['reserve', { operation: 'reserve' }, { status: OPERATION_STATUS.reserve }] + ] + for (const [name, op, node] of applied) { + assert.equal(classifyOperation(op, node, null), 'already-applied', + `re-applying ${name} must be a no-op`) + } + + // And the status the applier actually writes is the one this expects. + assert.deepEqual(OPERATION_STATUS, { omit: 'omitted', reserve: 'reserved' }) + const src = fs.readFileSync(path.join(ROOT, 'src/bill-cli.mjs'), 'utf8') + for (const status of Object.values(OPERATION_STATUS)) { + assert.ok(src.includes(`status: '${status}'`), + `the applier must write status: '${status}'`) + } +}) From 34119bf1273bcbaed73ee5b5832c851fdf2f6f5a Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 22:20:20 +0530 Subject: [PATCH 12/13] Ship /bills/ live and /propose/ dark; add the adoption checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different kinds of surface, so they ship differently. /bills/ is RECORD and ships with the merge. An empty register is a true statement: "no bills are before the board" is information, not absence. /propose/ is ACTION and ships dark behind PROPOSE_ENABLED, defaulted false in the deploy workflow. Its one actionable instruction is "email this file to the ICC"; putting that in front of the public before the ICC can receive would make the system's first impression on its first real author silence. When the flag is off, the page, provisions.json, the standalone validator and the footer link are all absent — verified in both directions, with the link checker clean either way. process/ADOPTION.md is the checklist that flips it, and names the condition that is currently failing: Cloudflare's Email Address Obfuscation rewrites the mailto at the edge, so the page's only actionable instruction is broken for a reader without JavaScript. Either the feature is disabled or the instruction points at a contact page. Plus the ICC briefing, one announcement to all three bodies — 16(3) convenes all of them, so none should first hear of this when summoned — and one human dry run of the fixture lifecycle, so the first real bill is not the first time a human touches the tools. Also records that the tripwire's zero-permitted era ends when the first real Act applies: from then on the Act's manifest is the permission, and a legitimate first diff under an Act is not a breach. The idempotency test gains the comment saying why it exists — the test it replaced agreed with the bug, writing the same wrong status the classifier expected, so two implementations confirmed each other's error indefinitely. It broke open only against an independent derivation of what the applier writes. --- .github/workflows/deploy.yml | 3 + README.md | 6 ++ process/ADOPTION.md | 119 +++++++++++++++++++++++++++++++++++ src/build.mjs | 25 +++++++- src/templates/layout.mjs | 4 +- tests/bill.test.mjs | 6 ++ 6 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 process/ADOPTION.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a5d3d9..361814c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,9 @@ env: # other ships a site whose links point at a directory that is not there. BASE_PATH: / SITE_ORIGIN: https://constitution.stmorg.in + # /bills/ is record and always ships. /propose/ is action and ships when the + # desk behind it is staffed — see process/ADOPTION.md for the four conditions. + PROPOSE_ENABLED: 'false' jobs: # A schema violation, a failing test or a dead internal link must block diff --git a/README.md b/README.md index 989c604..9b998ca 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ fewer. |---|---| | [process/PROPOSING.md](process/PROPOSING.md) | How to write a bill. Start here. | | [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`. ## Honest limitations diff --git a/process/ADOPTION.md b/process/ADOPTION.md new file mode 100644 index 0000000..c8f8f73 --- /dev/null +++ b/process/ADOPTION.md @@ -0,0 +1,119 @@ +# Adoption — turning `/propose/` 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. + +Everything below is checked off before it goes live. Then: + +```bash +PROPOSE_ENABLED=true npm run build # or set it in the deploy workflow +``` + +--- + +## 1. The receiving address works without JavaScript + +**This is the one that is currently failing.** + +Cloudflare's **Email Address Obfuscation** (Scrape Shield) rewrites `mailto:` links at the edge into +`/cdn-cgi/l/email-protection#…`, which only resolves once its script has run. The build ships a +clean `mailto:` — `npm test` asserts that — but the served page does not. + +The propose page's single actionable instruction is *email this file*. Shipping it while the edge +breaks that instruction for a reader without JavaScript defeats the page. + +One of the following, before the flip: + +- **Disable Email Address Obfuscation** — Cloudflare dashboard → the zone → Scrape Shield. A + narrower option is a Configuration Rule disabling it for `constitution.stmorg.in` only. +- **Point the instruction at a contact page instead** of a raw address. Replace + `info.contact.email` with `info.contact.url` in `constitution/current.yaml`; the page follows. + +Verify: + +```bash +curl -sS "https://constitution.stmorg.in/propose/?cb=$(date +%s)" \ + | grep -c "cdn-cgi/l/email-protection" # must be 0 +``` + +- [ ] Done, and verified by the command above. + +Background: `.night-run/CUTOVER.md`, item 0. + +## 2. The ICC coordinator is briefed + +Not a document — a conversation. It covers: + +- **The lifecycle**: draft → submitted → under-review → scheduled → approved → enacted → applied, + and that the ICC owns numbering, scheduling and attestation but never approval. +- **The freeze point**: form review completes *before* any meeting is scheduled. Circulation is the + freeze. +- **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. +- **Who runs `bill validate`** — the ICC, or the technical department. Decide it; do not leave it + ambiguous. + +- [ ] Briefed. Who: ______________________ Date: __________ + +## 3. One announcement, to all three bodies + +To the **board**, the **intermediate board** and **unit heads**, together. + +Article 16(3) convenes all three for any bill. None of them should first hear that this process +exists when they are summoned to vote under it. + +It needs to say only: amendments are now proposed as bills, all three bodies vote on every one, and +here is where to read about it — link `process/PROPOSING.md` and `process/AMENDMENT-PROCESS.md`. + +- [ ] Sent. Date: __________ + +## 4. One human dry run + +The ICC processes a fixture bill end to end, in a sandbox, before a real one arrives: + +```bash +cp examples/starter/bills/fixture-bill.yaml /tmp/dry-run.yaml +npx opencodelaw bill validate /tmp/dry-run.yaml # read the diff and the hash +npx opencodelaw bill ballot /tmp/dry-run.yaml # print the three sheets +# fill the sheets by hand as mock minutes; archive them; record the tallies +npx opencodelaw act enact /tmp/dry-run.yaml --signed-pdf +npx opencodelaw act apply /tmp/dry-run.yaml # against a COPY of the fixture constitution +``` + +The fixture lifecycle already exists as a test. This is the same lifecycle as **rehearsal** — so the +first real bill is not the first time a human touches the tools. + +Do it against `examples/starter/fixture-constitution.yaml`, never `constitution/current.yaml`. + +- [ ] Done. Who: ______________________ Date: __________ + +--- + +## When all four are checked + +1. Set `PROPOSE_ENABLED: 'true'` in `.github/workflows/deploy.yml`. +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 + page and the CLI, which is the check that caught the trailing-newline defect. +4. Announce that it is open. + +--- + +## 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. + +**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 +under an Act is not a breach. diff --git a/src/build.mjs b/src/build.mjs index 627ede1..638afa5 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -30,6 +30,22 @@ export const SITE_ORIGIN = DEFAULT_SITE_ORIGIN // setting on deploy, so this now defaults ON and must be opted OUT of. const INCLUDE_CNAME = process.env.INCLUDE_CNAME !== 'false' +/** + * /bills/ and /propose/ are different kinds of surface, 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. + * + * Flip with PROPOSE_ENABLED=true once process/ADOPTION.md is checked off. + */ +const PROPOSE_ENABLED = process.env.PROPOSE_ENABLED === 'true' + // 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' @@ -221,7 +237,7 @@ export function build () { ? 'editorial' : (headingCounts.enacted < headingCounts.editorial ? 'enacted' : 'editorial') - const shell = { info, url, absolute: abs, state, actIndex, articles: doc.articles, slugs } + const shell = { info, url, absolute: abs, state, actIndex, articles: doc.articles, slugs, proposeEnabled: PROPOSE_ENABLED } // ---- index: the whole constitution, every provision inline ---- const indexMain = ` @@ -339,6 +355,7 @@ export function build () { }))) // ---- propose: author a bill without editing YAML ---- + if (PROPOSE_ENABLED) { written.push(write('propose/index.html', layout({ ...shell, showToc: false, @@ -353,6 +370,7 @@ export function build () { head: ``, main: proposeMain({ url, escapeHtml, info }) }))) + } // ---- archive ---- const versions = fs.existsSync(path.join(ROOT, VERSIONS_DIR)) @@ -459,7 +477,7 @@ export function build () { // 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. - write('provisions.json', JSON.stringify({ + 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: [ @@ -520,7 +538,7 @@ export function build () { // 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. - write('scripts/bill-validator.mjs', generateBillValidator()) + if (PROPOSE_ENABLED) write('scripts/bill-validator.mjs', generateBillValidator()) // The custom domain stays on the old site until it has been reviewed. if (INCLUDE_CNAME && fs.existsSync(path.join(ROOT, 'CNAME'))) { @@ -713,5 +731,6 @@ if (direct) { console.log(` articles ${doc.articles.length}`) console.log(` archived ${versions.length}`) console.log(` og images ${og.made} rendered${og.fallback ? `, ${og.fallback} fell back to the banner` : ''}${og.rasteriser ? ` (${og.rasteriser})` : ' (no rasteriser found)'}`) + console.log(` /propose/ ${PROPOSE_ENABLED ? 'LIVE' : 'dark (PROPOSE_ENABLED=true to ship; see process/ADOPTION.md)'}`) console.log(` CNAME ${INCLUDE_CNAME ? 'included' : 'excluded (custom domain untouched)'}`) } diff --git a/src/templates/layout.mjs b/src/templates/layout.mjs index a49b00a..402a55b 100644 --- a/src/templates/layout.mjs +++ b/src/templates/layout.mjs @@ -112,7 +112,7 @@ export function layout ({ title, description, canonical, head = '', bodyClass = '', main, info, url, absolute, state, actIndex, articles, slugs, currentId = null, tocMode = 'single', showToc = true, jsonLd = [], og = {}, - toc = null, footerInfo = null, extraHead = '' + toc = null, footerInfo = null, extraHead = '', proposeEnabled = false }) { // Archive pages render a different document from the one in force; the footer // must state THAT version, not the current one. @@ -196,7 +196,7 @@ export function layout ({ Constitution · Amendments · Bills · - Propose · + ${proposeEnabled ? `Propose ·` : ''} Archive ${info.contact?.email ? ` · Contact` : ''}

    diff --git a/tests/bill.test.mjs b/tests/bill.test.mjs index 6d48b0f..ee593ab 100644 --- a/tests/bill.test.mjs +++ b/tests/bill.test.mjs @@ -695,6 +695,12 @@ test('applying against a divergent target aborts rather than overwriting', async 'a target that matches neither the base nor the Act must abort, never be overwritten') }) +// This test exists because the previous one AGREED WITH THE BUG: it wrote the +// same wrong status the classifier expected, so two implementations confirmed +// each other's error indefinitely — coherence masquerading as correctness. It +// only broke open against an independent derivation of what the applier +// actually writes. One source of truth (OPERATION_STATUS) plus a cross-check +// against the applier is what stops the two sides quietly agreeing again. test('every operation type is idempotent against the status the applier writes', async () => { // Written against OPERATION_STATUS rather than a literal, because the last // bug here was the test and the code sharing the same wrong assumption: From be84b80eac97584d4f447392e884caee1b92a410 Mon Sep 17 00:00:00 2001 From: kiranpranay Date: Sat, 15 Aug 2026 22:21:46 +0530 Subject: [PATCH 13/13] Build before testing in the bill gate The suite asserts against built output as well as source. The gate ran npm test without npm run build, so 29 tests failed on "dist/ missing" the first time it ran on a real pull request. The deploy workflow already built first; this one did not. Adds the link check to the gate while there, for the same reason: it also reads dist/. --- .github/workflows/bills.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/bills.yml b/.github/workflows/bills.yml index bb0ce70..8654666 100644 --- a/.github/workflows/bills.yml +++ b/.github/workflows/bills.yml @@ -86,5 +86,14 @@ jobs: - name: Validate the corpus run: npm run validate + # The suite asserts against built output as well as source, so the build + # runs first — the deploy workflow does this and this one did not, which + # is how 29 tests failed on "dist/ missing" the first time this ran. + - name: Build + run: npm run build + + - name: Check links + run: npm run linkcheck + - name: Test run: npm test