diff --git a/.github/workflows/bills.yml b/.github/workflows/bills.yml new file mode 100644 index 0000000..8654666 --- /dev/null +++ b/.github/workflows/bills.yml @@ -0,0 +1,99 @@ +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 + + # 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 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/.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/.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 865bf6c..b63b525 100644 Binary files a/.night-run/tripwire.mjs and b/.night-run/tripwire.mjs differ 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/README.md b/README.md index c7091a9..9b998ca 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,38 @@ 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. | +| [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 - **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..235988d --- /dev/null +++ b/bills/TEMPLATE.yaml @@ -0,0 +1,165 @@ +# --------------------------------------------------------------------------- +# 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: + # One entry per body. The ICC fills these in from the signed record. + # + # `bill_sha256` is the substantive hash of the bill AS VOTED. `bill validate` + # prints it; the presiding officer reads it into the minutes. If the bill is + # edited afterwards the hash moves, the approval is void, and that body must + # resolve again — including when your own operations were untouched, because + # a provision can become contradictory purely because other articles moved. + # + # Get the text right before you circulate. + # + # `evidence.path` is a file in this repository. A live link is never evidence: + # a URL is mutable, unattributable, and dies with the platform. + - body: board + meeting: + date: ~ + mode: in-person # in-person | online | hybrid + place: "" + presiding: "" + present: ~ + for: ~ + against: ~ + abstain: ~ # excluded from the threshold + bill_sha256: ~ + # Added by the ICC once the record exists. Left out of a draft entirely: + # an empty path and an empty checksum are not an unfilled form, they are a + # claim that a record exists at "" — which is why the validator rejects it. + # + # evidence: + # kind: minutes # minutes | ballot-tally | poll-export + # path: bills/2026/evidence/bill-1-2026-board-minutes.pdf + # sha256: <64 hex characters — `bill validate` will tell you if it is wrong> + recorded_by: "" + - body: intermediate-board + meeting: {date: ~, mode: in-person, place: "", presiding: ""} + present: ~ + for: ~ + against: ~ + abstain: ~ + bill_sha256: ~ + # evidence: {kind: minutes, path: ..., sha256: ...} + recorded_by: "" + - body: units + meeting: {date: ~, mode: online, place: "", presiding: ""} + present: ~ + for: ~ + against: ~ + abstain: ~ + bill_sha256: ~ + # Units vote online where the by-laws permit it. A poll is a voting + # MECHANISM, not a record: the evidence is a static export of the result, + # attested by the ICC coordinator, archived and hashed like minutes. The + # poll's URL proves nothing. + # evidence: {kind: poll-export, path: ..., sha256: ...} + recorded_by: "" + +# --------------------------------------------------------------------------- +# 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/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/evidence/fixture-board-minutes.md b/examples/starter/bills/evidence/fixture-board-minutes.md new file mode 100644 index 0000000..0aa1357 --- /dev/null +++ b/examples/starter/bills/evidence/fixture-board-minutes.md @@ -0,0 +1,15 @@ +# Record of Resolution — board +## Marrow Vale Lamplighters' Guild (FICTIONAL — test fixture) + +This is a fixture standing in for a signed record of resolution. A real record +is the signed minutes (or an attested poll export) archived beside the bill. + +**Resolution.** This meeting resolves on Bill 1 of 2026, substantive hash +recorded in the bill's `bill_sha256` field. + +Tallies and attendance counts only. Individual members' votes are not +published; where the by-laws require a roll call, the roster is an internal +annexure held by the ICC and referenced, not published. + +Presiding officer: (fixture) +Recorded by: (fixture) diff --git a/examples/starter/bills/evidence/fixture-intermediate-board-minutes.md b/examples/starter/bills/evidence/fixture-intermediate-board-minutes.md new file mode 100644 index 0000000..e7e4183 --- /dev/null +++ b/examples/starter/bills/evidence/fixture-intermediate-board-minutes.md @@ -0,0 +1,15 @@ +# Record of Resolution — intermediate-board +## Marrow Vale Lamplighters' Guild (FICTIONAL — test fixture) + +This is a fixture standing in for a signed record of resolution. A real record +is the signed minutes (or an attested poll export) archived beside the bill. + +**Resolution.** This meeting resolves on Bill 1 of 2026, substantive hash +recorded in the bill's `bill_sha256` field. + +Tallies and attendance counts only. Individual members' votes are not +published; where the by-laws require a roll call, the roster is an internal +annexure held by the ICC and referenced, not published. + +Presiding officer: (fixture) +Recorded by: (fixture) diff --git a/examples/starter/bills/evidence/fixture-units-minutes.md b/examples/starter/bills/evidence/fixture-units-minutes.md new file mode 100644 index 0000000..4c14cb2 --- /dev/null +++ b/examples/starter/bills/evidence/fixture-units-minutes.md @@ -0,0 +1,15 @@ +# Record of Resolution — units +## Marrow Vale Lamplighters' Guild (FICTIONAL — test fixture) + +This is a fixture standing in for a signed record of resolution. A real record +is the signed minutes (or an attested poll export) archived beside the bill. + +**Resolution.** This meeting resolves on Bill 1 of 2026, substantive hash +recorded in the bill's `bill_sha256` field. + +Tallies and attendance counts only. Individual members' votes are not +published; where the by-laws require a roll call, the roster is an internal +annexure held by the ICC and referenced, not published. + +Presiding officer: (fixture) +Recorded by: (fixture) diff --git a/examples/starter/bills/fixture-bill.yaml b/examples/starter/bills/fixture-bill.yaml new file mode 100644 index 0000000..9b41b35 --- /dev/null +++ b/examples/starter/bills/fixture-bill.yaml @@ -0,0 +1,195 @@ +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 +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: + - 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. + - 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. + - 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. + - 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. + - 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. +approvals: + - body: board + meeting: + date: "2026-04-11" + mode: in-person + place: Fixture Hall + presiding: (fixture) Presiding Officer + present: 24 + for: 19 + against: 3 + abstain: 2 + bill_sha256: e7f9b56608984fd19466e86cf0741dd616bbadcfe3ffec63dc5ea8e540f21a76 + evidence: + kind: minutes + path: examples/starter/bills/evidence/fixture-board-minutes.md + sha256: b3589fe5fb7b832092e2063dec71da8349ec08e55a5dcc3770ab3a1aa5bf6df8 + recorded_by: (fixture) ICC Coordinator + - body: intermediate-board + meeting: + date: "2026-04-12" + mode: in-person + place: Fixture Hall + presiding: (fixture) Presiding Officer + present: 15 + for: 11 + against: 3 + abstain: 1 + bill_sha256: e7f9b56608984fd19466e86cf0741dd616bbadcfe3ffec63dc5ea8e540f21a76 + evidence: + kind: minutes + path: examples/starter/bills/evidence/fixture-intermediate-board-minutes.md + sha256: 6de1adee11ad326b0d87e71c0dd1a31679bb61947e52b864d9c804e0e6161996 + recorded_by: (fixture) ICC Coordinator + - body: units + meeting: + date: "2026-04-14" + mode: online + place: Fixture Hall + presiding: (fixture) Presiding Officer + present: 41 + for: 28 + against: 9 + abstain: 4 + bill_sha256: e7f9b56608984fd19466e86cf0741dd616bbadcfe3ffec63dc5ea8e540f21a76 + evidence: + kind: poll-export + path: examples/starter/bills/evidence/fixture-units-minutes.md + sha256: 50d43497e6cafe5996d60d35c226c0747befa58bee4f23fbdb264025d63ba445 + recorded_by: (fixture) ICC Coordinator +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: null + signed_pdf_sha256: null + 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/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/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/process/AMENDMENT-PROCESS.md b/process/AMENDMENT-PROCESS.md new file mode 100644 index 0000000..cf12c88 --- /dev/null +++ b/process/AMENDMENT-PROCESS.md @@ -0,0 +1,333 @@ +# 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. + +--- + +## 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, +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. + +--- + +## 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 + +| 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/process/MINUTES-TEMPLATE.md b/process/MINUTES-TEMPLATE.md new file mode 100644 index 0000000..1f8ebd4 --- /dev/null +++ b/process/MINUTES-TEMPLATE.md @@ -0,0 +1,98 @@ +# Record of Resolution — template + +The structure of the record each approving body files under Article 16(3). If you are recording by +hand, follow this. If you would rather not compose it from scratch: + +```bash +npx opencodelaw bill ballot bills/2026/.yaml +``` + +That renders one pre-filled sheet per body — board, intermediate board, units — with the bill +details and the substantive hash already printed, so the hash cannot be mistyped. Print it, take it +to the meetings, sign it, scan it, and archive the scan beside the bill. + +--- + +## Why a signed record, and not a link + +A poll URL is not evidence. It is mutable, it is unattributable, it proves nothing about who +resolved what on which text, and it dies with the platform. The record a society keeps is the record +societies have always kept: **a signed record of resolution, archived immutably beside the instrument +it approves.** + +Where the by-laws permit an online vote — the units are spread across colleges, so they may — the +poll is the **voting mechanism**. The **record** is a static export of the result, attested by the +ICC coordinator, archived and hashed exactly like minutes. Record it as `kind: poll-export`. + +## The one line that must be read aloud + +Every record must carry the resolution sentence, verbatim, including the hash: + +> This meeting resolves on Bill 1 of 2026, substantive hash `ab12…`. + +`npx opencodelaw bill validate ` prints that sentence. **A vote binds to the hash, not to the +title.** A bill's title reads exactly the same before and after somebody edits an operation; the hash +does not. If the bill is edited after this meeting, this resolution is void and the body resolves +again — including where the bill's own operations were untouched, because a provision can become +contradictory purely because other articles moved. + +Get the text right before you circulate. + +## What the record must contain + +| | | +|---|---| +| Body | board / intermediate board / units | +| Date | | +| Mode | in person, online, or hybrid | +| Place or platform | | +| Presiding officer | signs by name | +| Members present | a count | +| Voting **for** | a count | +| Voting **against** | a count | +| **Abstaining** | a count — outside the threshold denominator | +| Resolution sentence | verbatim, with the hash | +| Attested by | the ICC coordinator, by name | + +Article 16(3) needs **two thirds of those present and voting**. Abstentions are not in the +denominator: nine for, one against, and one abstaining is 9/10 — not 9/11. + +## Privacy — tallies, not roll-calls + +This repository and the site built from it are **public**. + +Published records carry **tallies and attendance counts only**. Do not publish how individual +ordinary members voted. Presiding officers and the ICC coordinator sign by name, as they already do +on the Acts — that is a signature of office, not disclosure of a vote. + +If the by-laws ever require a roll-call vote, the roster is an **internal annexure held by the ICC**. +Reference it in the record; do not publish it. + +## A joint sitting + +The three bodies may sit together, and often should — it turns a re-vote after an edit into one +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 +that reading, and its approvals will not enact. + +## Filing it + +Archive the signed scan under `bills//evidence/`, then record it in the bill: + +```yaml +evidence: + kind: minutes # minutes | ballot-tally | poll-export + path: bills/2026/evidence/bill-1-2026-board-minutes.pdf + sha256: <64 hex characters> +``` + +`bill validate` checks that the file exists and that its checksum matches. A `url:` may sit +alongside as a convenience pointer — never instead of the archived file. diff --git a/process/PROPOSING.md b/process/PROPOSING.md new file mode 100644 index 0000000..73546bc --- /dev/null +++ b/process/PROPOSING.md @@ -0,0 +1,386 @@ +# 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. + +--- + +### 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 +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/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 139fd05..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": [ @@ -445,6 +475,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 +764,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..8572cbb --- /dev/null +++ b/schema/opencodelaw-bill-1.0.schema.json @@ -0,0 +1,536 @@ +{ + "$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 \u2014 operations carry their own text \u2014 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 \u2192 submitted \u2192 under-review \u2192 (returned \u21c4 under-review) \u2192 scheduled \u2192 approved | rejected \u2192 enacted \u2192 applied. `withdrawn` is the author's, any time before approval. `lapsed` is the board's and is set by hand \u2014 no timeout is invented, because the constitution states none." + }, + "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 \u2014 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": { + "oneOf": [ + { + "$ref": "#/$defs/status" + }, + { + "const": "approval-voided" + } + ], + "description": "A status, or `approval-voided` for the record of a vote that an edit invalidated." + }, + "actor": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": [ + "string", + "null" + ] + }, + "note": { + "type": "string" + }, + "approval": { + "type": "object", + "additionalProperties": true, + "description": "The approval as it stood, carried into history when an edit voided it. A body's vote is a legislative fact even after the text moves on; erasing it would be the anti-pattern this system exists against." + } + } + }, + "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 \u2014 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 \u2014 never a diff, never a splice, never \"insert after the words\u2026\". This is what makes application idempotent: the applier compares the target's current text against this, so re-running an applied Act is a no-op by construction rather than by luck." + }, + "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, + "description": "One body's resolution on a bill. A vote binds to the bill's substantive hash, not to its title: a title reads the same before and after an operation is edited, so an approval recorded against a title would silently survive a change to the text it approved.", + "properties": { + "body": { + "enum": [ + "board", + "intermediate-board", + "units" + ] + }, + "meeting": { + "type": "object", + "additionalProperties": false, + "description": "Where and how the body resolved. Notice and quorum are by-laws matters under Article 16(2); this records what happened.", + "properties": { + "date": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "mode": { + "enum": [ + "in-person", + "online", + "hybrid" + ] + }, + "place": { + "type": "string", + "description": "Venue, or the platform an online vote was held on." + }, + "presiding": { + "type": "string", + "description": "Presiding officer, who signs the record." + } + } + }, + "present": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "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." + }, + "bill_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^([a-f0-9]{64})?$", + "description": "The substantive hash of the bill as voted on. Required for enactment. If the bill is edited afterwards this no longer matches, the approval is void, and the body must resolve again." + }, + "evidence": { + "type": "object", + "required": [ + "kind", + "path", + "sha256" + ], + "additionalProperties": false, + "description": "A signed record of resolution, archived in the repository beside the instrument it approves. A live link is never evidence: a URL is mutable, unattributable, and dies with the platform.", + "properties": { + "kind": { + "enum": [ + "minutes", + "ballot-tally", + "poll-export" + ], + "description": "A poll is a voting MECHANISM, not a record. Where the by-laws permit an online vote, the evidence is a static export attested by the ICC coordinator, archived and hashed like minutes \u2014 never the poll's URL." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Repository-relative path. Must exist on disk." + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "url": { + "type": "string", + "description": "Convenience pointer only, permitted ALONGSIDE the archived file and never instead of it." + } + } + }, + "recorded_by": { + "type": "string", + "description": "The ICC coordinator who filed the record." + }, + "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 \u2014 the audit link between the printed Act and the patch it came from." + } + } + } + } +} diff --git a/src/ballot.mjs b/src/ballot.mjs new file mode 100644 index 0000000..99af4da --- /dev/null +++ b/src/ballot.mjs @@ -0,0 +1,137 @@ +/** + * The resolution sheet a meeting signs. + * + * Pre-filled with the bill number, short title, substantive hash, the + * resolution sentence and a summary of the operations — so the coordinator + * never composes a legal record from scratch and the hash cannot be mistyped. + * + * Rendered only for a bill at `scheduled` or later. Circulation is the freeze + * point: a ballot for a bill still under form review would carry a hash that + * the ICC is about to change. + */ +import { substantiveHash, resolutionSentence, REQUIRED_BODIES } from './bill.mjs' +import { houseStyle } from './bill-render.mjs' + +const BALLOTABLE = new Set(['scheduled', 'approved', 'enacted', 'applied']) + +const esc = s => String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') + +const BODY_LABEL = { + board: 'the Board', + 'intermediate-board': 'the Intermediate Board', + units: 'the Units' +} + +export function ballotGuard (bill) { + if (BALLOTABLE.has(bill.status)) return null + return `This bill is "${bill.status}". A resolution sheet is rendered only once a bill is ` + + 'scheduled, because circulation is the freeze point: the hash printed on the sheet is the ' + + 'text the bodies vote on, and a bill still under form review is one the ICC may yet change. ' + + 'Complete form review, set the bill to scheduled, then render.' +} + +export function ballotSheet (bill, body, options = {}) { + const m = houseStyle(options) + const b = bill.bill + const hash = substantiveHash(bill) + const name = b.number ? `Bill ${b.number} of ${b.year}` : `the draft bill "${b.short_title}"` + + const ops = bill.operations.map((op, i) => { + const what = op.operation === 'insert' ? `insert ${op.target}` + : op.operation === 'omit' ? `omit ${op.target}` + : op.operation === 'reserve' ? `reserve ${op.target}` + : op.operation === 'retitle' ? `retitle ${op.target}` + : `substitute ${op.target}` + return `
  • ${i + 1}. ${esc(what)}${op.title ? ` — ${esc(op.title)}` : ''} (${esc(op.scope)} scope)
  • ` + }).join('') + + return `
    +
    +

    ${esc(m.organization)}

    + ${m.committee ? `

    ${esc(m.committee)}

    ` : ''} +

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

    +
    + +

    ${esc(resolutionSentence(bill))}

    +

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

    + +
    +
    Bill
    ${esc(name)}
    +
    Short title
    ${esc(b.short_title)}
    +
    Type
    ${esc(b.type)}
    +
    Moved by
    ${esc(b.moved_by?.name ?? '')}${b.moved_by?.role ? `, ${esc(b.moved_by.role)}` : ''}
    +
    Drafted against
    constitution version ${esc(b.base_version)}
    +
    Substantive hash
    ${esc(hash)}
    +
    + +

    What this bill does

    +
      ${ops}
    +

    The full before-and-after text is in the bill's validation report, which accompanies + this sheet.

    + +

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

    + + + + + + + + + + +
    Date
    Modein-person / online / hybrid
    Place or platform
    Members present
    Voting FOR
    Voting AGAINST
    Abstaining
    +

    Article 16(3) requires two thirds of those present and voting. + Abstentions are outside the denominator. Record tallies and attendance counts only — individual + members' votes are not published.

    + +
    +

    Presiding officer (signature and name)

    +

    Attested — Internal Compliance Coordinator

    +
    +
    ` +} + +export function ballotDocument (bill, options = {}) { + const m = houseStyle(options) + const sheets = REQUIRED_BODIES.map(b => ballotSheet(bill, b, options)).join('\n') + return ` + +Resolution sheets — ${esc(bill.bill.short_title)} + + +

    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-cli.mjs b/src/bill-cli.mjs new file mode 100644 index 0000000..1233300 --- /dev/null +++ b/src/bill-cli.mjs @@ -0,0 +1,288 @@ +/** + * `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 { 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) + +/** + * 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 ??= [] + 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..5f7117b --- /dev/null +++ b/src/bill-commands.mjs @@ -0,0 +1,109 @@ +/** + * 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 { loadConstitution } = await import('./bill.mjs') + const c = loadConstitution() + // Pass info itself: houseStyle() reads info.instrument (snake_case in the + // YAML) and maps it. Spreading the YAML keys at top level looked + // equivalent and silently dropped the signatory office and the whole + // address footer, because those options are camelCase. + const opts = { info: c.info, orgYear: flag(rest, 'org-year', '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 === '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}.`) + 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-gate.mjs b/src/bill-gate.mjs new file mode 100644 index 0000000..1efb26d Binary files /dev/null and b/src/bill-gate.mjs differ diff --git a/src/bill-render.mjs b/src/bill-render.mjs new file mode 100644 index 0000000..94bc3da --- /dev/null +++ b/src/bill-render.mjs @@ -0,0 +1,682 @@ +/** + * 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. + * + * The LAYOUT is not invented here. It is taken from the extracted text of the + * 2024 Acts — acts/text/first-…, second-… and third-constitution-amendment-act- + * 2024.txt — down to the assent line, the operative item phrasings ("Amendment + * to Article 13:", "Insertion of new Article 21 - Financial Management:", + * "Amendment of Preamble:", "Amendment to Article 6, clause 1,2,3,4 and 5:"), + * the restated provision beneath each item, the ————— separator and the name + * standing over the office in the signature block. + * + * The WORDING that belongs to one organisation is not. Committee, signatory + * title, Act title pattern, enacting formula and the address footer are CONTENT: + * they live in the constitution's `info.instrument` and are passed in — + * + * renderBillText(bill, { info: doc.info, orgYear: 2, titles }) + * + * — or given directly as `organization`, `committee`, `signatoryTitle`, + * `footer`, `actTitlePattern`, `enactingFormula`, which override the block. A + * fork replaces the YAML and the renderer follows without being edited. + * + * 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. + */ +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 = '' +// No default: a fork with no info.instrument must not print another +// organisation's committee on its masthead. +export const DEFAULT_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, + // The mover is printed on the face of the instrument. The 2024 Acts named + // nobody, so their proposer had to be reconstructed afterwards and never + // was (Q8); recording moved_by in the bill only fixes that if the signed + // paper carries it too. + movedBy: meta.moved_by ?? 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('') + } + + if (m.movedBy?.name) { + out.push('') + out.push(pad(3) + `Moved by ${m.movedBy.name}${m.movedBy.role ? `, ${m.movedBy.role}` : ''}`) + } + 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 ')} +
    ` + : ''} + + ${m.movedBy?.name + ? `

    Moved by ${escapeHtml(m.movedBy.name)}${m.movedBy.role ? `, ${escapeHtml(m.movedBy.role)}` : ''}

    ` + : ''} +

    ${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 new file mode 100644 index 0000000..91ba9c6 --- /dev/null +++ b/src/bill.mjs @@ -0,0 +1,520 @@ +#!/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' +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 } +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 + +/** What each removing operation leaves behind on the provision it acts on. */ +export const OPERATION_STATUS = Object.freeze({ omit: 'omitted', reserve: 'reserved' }) + +export { canonicalJson, blockText, SUBSTANTIVE_FIELDS } + +export function tally (approvals = []) { + const perBody = REQUIRED_BODIES.map(body => { + const a = approvals.find(x => x.body === body) + const forVotes = a?.for ?? null + const against = a?.against ?? null + const voting = forVotes == null || against == null ? null : forVotes + against + const ratio = voting ? forVotes / voting : null + return { + body, + recorded: !!a, + date: a?.date ?? null, + present: a?.present ?? null, + for: forVotes, + against, + abstain: a?.abstain ?? null, + voting, + ratio, + passes: ratio != null && ratio >= THRESHOLD, + evidence: a?.evidence ?? null, + billSha256: a?.bill_sha256 ?? null, + meeting: a?.meeting ?? null + } + }) + + const complete = perBody.every(b => b.recorded && b.voting != null) + const pooledFor = perBody.reduce((n, b) => n + (b.for ?? 0), 0) + const pooledVoting = perBody.reduce((n, b) => n + (b.voting ?? 0), 0) + const pooledRatio = pooledVoting ? pooledFor / pooledVoting : null + + return { + perBody, + complete, + pooled: { for: pooledFor, voting: pooledVoting, ratio: pooledRatio, passes: pooledRatio != null && pooledRatio >= THRESHOLD }, + // The stricter reading governs. + passes: complete && perBody.every(b => b.passes), + missingBodies: perBody.filter(b => !b.recorded).map(b => b.body), + missingEvidence: perBody.filter(b => b.recorded && !b.evidence?.path).map(b => b.body), + failedBodies: perBody.filter(b => b.recorded && b.voting != null && !b.passes).map(b => b.body) + } +} + +// --------------------------------------------------------------------------- + +/** + * 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 _validateBillSchema +} + +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 validate = billValidator() + + 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}]`) + } + } + } + + // --- 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) + } + } + + // 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 ` + + '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)) { + 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') { + // Absent: insert it. Present and already reading as the Act prescribes: + // this Act has been applied, and re-running it must be a no-op like every + // other operation. Present and reading as something else: another + // provision occupies that number, and inserting would overwrite it. + if (!currentNode) return 'apply' + return current === proposed ? 'already-applied' : 'divergent' + } + if (['omit', 'reserve'].includes(op.operation)) { + // The provision is not deleted — its number is never reused, so the entry + // remains carrying a status. "Already applied" is that status being set, + // not the node being gone. + if (!currentNode) return 'already-applied' + // The operation is `omit`; the status it leaves behind is `omitted`. + // Comparing the two directly made re-applying an omission look like work + // forever — and the lifecycle test agreed, because it wrote the same wrong + // status the check expected. Both sides now name the mapping once. + return currentNode.status === OPERATION_STATUS[op.operation] ? 'already-applied' : 'apply' + } + if (op.operation === 'retitle') { + return normalise(currentNode?.title ?? '') === normalise(op.title) ? 'already-applied' : 'apply' + } + if (current === proposed) return 'already-applied' + if (baseText != null && current === normalise(baseText)) return 'apply' + if (baseText == null) return 'apply' + return 'divergent' +} + +export function report ({ problems, manifest, tally: t, bill }) { + const out = [] + const b = bill.bill + 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(` ${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' : ''}`) + 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'}` + 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'}`) + 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') +} + +// --------------------------------------------------------------------------- +// The substantive hash +// --------------------------------------------------------------------------- + +/** + * The fields a body actually votes on. Everything else — number, status, + * history, approvals, enactment — is clerking that changes after drafting, and + * including it would make a vote go stale for administrative reasons. + */ + +/** + * sha256 over the canonical form of what the bill actually proposes. + * + * A vote binds to this, not to "Bill 1 of 2026" — a title is the same string + * before and after someone edits an operation, and an approval recorded against + * a title would silently survive a change to the text it approved. + */ +export function substantiveHash (bill) { + return crypto.createHash('sha256') + .update(canonicalJson(substantiveSubject(bill)), 'utf8').digest('hex') +} + +/** The sentence a meeting reads into its minutes. */ +export function resolutionSentence (bill) { + const b = bill.bill + const name = b.number ? `Bill ${b.number} of ${b.year}` : `the draft bill "${b.short_title}"` + return `This meeting resolves on ${name}, substantive hash ${substantiveHash(bill)}.` +} + +export function fileSha256 (abs) { + return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex') +} diff --git a/src/build.mjs b/src/build.mjs index 3b2fef6..638afa5 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -16,6 +16,9 @@ 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' +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') @@ -27,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' @@ -218,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 = ` @@ -306,6 +325,53 @@ 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 }) + }))) + + // ---- propose: author a bill without editing YAML ---- + if (PROPOSE_ENABLED) { + written.push(write('propose/index.html', layout({ + ...shell, + showToc: false, + toc: tocSections(proposeTocItems(), { heading: 'Propose a bill' }), + title: `Propose an amendment — ${info.title}`, + description: `Draft a bill to amend the constitution of ${info.organization}. The page produces a draft for the Internal Compliance Committee; it does not submit, number or approve anything.`, + canonical: abs('propose/'), + og: { image: abs('assets/og/amendments.png'), imageAlt: 'Propose an amendment' }, + jsonLd: [breadcrumbLd([ + { name: 'Constitution', url: abs('') }, { name: 'Propose', url: abs('propose/') } + ])], + head: ``, + main: proposeMain({ url, escapeHtml, info }) + }))) + } + // ---- archive ---- const versions = fs.existsSync(path.join(ROOT, VERSIONS_DIR)) ? fs.readdirSync(path.join(ROOT, VERSIONS_DIR)).filter(f => f.endsWith('.yaml')).sort() @@ -407,6 +473,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. + if (PROPOSE_ENABLED) write('provisions.json', JSON.stringify({ + base_version: info.version, + generated_for: 'the propose page — targets are picked from this list, never typed', + provisions: [ + { id: doc.preamble.id, kind: 'preamble', number: null, title: doc.preamble.title, + title_source: doc.preamble.title_source ?? 'editorial', text: doc.preamble.content ?? '' }, + ...doc.articles.flatMap(a => [ + { id: a.id, kind: 'article', number: a.number, title: a.title, + title_source: a.title_source ?? 'editorial', status: a.status ?? 'active', + text: a.content ?? '', + sections: (a.sections ?? []).map(x => ({ number: x.number, title: x.title, text: x.content ?? '' })) }, + ...(a.sections ?? []).map(x => ({ + id: x.id, kind: 'section', number: x.number, title: x.title, + title_source: x.title_source ?? 'editorial', article: a.id, article_number: a.number, + text: x.content ?? '' })) + ]) + ], + // The next free article number, and any reserved slot an insert may occupy. + next_article: Math.max(...doc.articles.map(a => a.number)) + 1, + reserved: doc.articles.filter(a => a.status === 'reserved').map(a => a.number) + })) 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)))) @@ -444,6 +535,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. + 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'))) { fs.copyFileSync(path.join(ROOT, 'CNAME'), path.join(OUT(), 'CNAME')) @@ -635,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/cli.mjs b/src/cli.mjs index 0aa44f8..8d5b602 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -20,6 +20,16 @@ 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 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] + opencodelaw --version Environment: @@ -80,6 +90,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/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/bill-serialise.mjs b/src/scripts/bill-serialise.mjs new file mode 100644 index 0000000..6f7e681 --- /dev/null +++ b/src/scripts/bill-serialise.mjs @@ -0,0 +1,248 @@ +/** + * 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 => { + 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) { + 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' +]) + +/** + * 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) + ? 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)}`) + 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)}`) + 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}`) + 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}`) + 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)}`) + out.push(...opt(h, 'note', v => ` note: ${scalar(v)}`)) + out.push(...opt(h, 'approval', v => ` approval:${anyValue(v, 6)}`)) + } + } + + 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 ?? []) { + out.push(` - id: ${op.id}`) + out.push(` operation: ${op.operation}`) + out.push(` target: ${op.target}`) + out.push(` scope: ${op.scope}`) + 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)}`) + const st = blockText(s.text) + if (st === '') out.push(' text: ""') + else { 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}`] + 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']) { + 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}"`) + out.push(...opt(a.evidence, 'url', v => ` url: ${scalar(v)}`)) + } + out.push(...opt(a, 'recorded_by', v => ` recorded_by: ${scalar(v)}`)) + out.push(...opt(a, 'note', v => ` note: ${scalar(v)}`)) + } + } + + 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 new file mode 100644 index 0000000..140af8a --- /dev/null +++ b/src/scripts/propose.js @@ -0,0 +1,391 @@ +/** + * The bill builder. + * + * Two things it guarantees by construction rather than by asking: + * + * - a target is PICKED, never typed, so an operation cannot name a provision + * that does not exist; + * - the text box starts prefilled with the provision's current text, so an + * author edits a whole provision into its new form and physically cannot + * write "insert after the words…". + * + * Validation is the standalone build of the same JSON Schema the CLI uses; the + * substantive hash is computed the same way. A second implementation of either + * would be free to drift, which is the class of failure this project began with. + */ + +import { blockText, canonicalJson, substantiveSubject, billToYaml } from './bill-serialise.mjs' + +const $ = (sel, root = document) => root.querySelector(sel) +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)) + +const form = $('#propose-form') +if (form) init().catch(err => report([{ message: `The builder could not start: ${err.message}` }])) + +let PROVISIONS = null +let VALIDATE = null +let seq = 0 + +async function init () { + const base = new URL('../', import.meta.url) + const [provRes, validator] = await Promise.all([ + fetch(new URL('provisions.json', base)).then(r => r.json()), + import(new URL('bill-validator.mjs', import.meta.url).href).then(m => m.default ?? m).catch(() => null) + ]) + PROVISIONS = provRes + VALIDATE = typeof validator === 'function' ? validator : null + + $('#op-add').addEventListener('click', () => addOperation()) + $('#download').addEventListener('click', download) + $('#hash-copy').addEventListener('click', copyHash) + form.addEventListener('input', debounce(refresh, 200)) + addOperation() + refresh() +} + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms) } } + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +function addOperation () { + const node = $('#op-template').content.firstElementChild.cloneNode(true) + const n = ++seq + node.dataset.n = String(n) + $('.op__n', node).textContent = String($$('.op', $('#op-list')).length + 1) + + const kind = $('.op__kind', node) + const search = $('.op__search', node) + const results = $('.op__results', node) + + kind.addEventListener('change', () => shapeFor(node)) + search.addEventListener('input', () => showMatches(node, search.value)) + search.addEventListener('keydown', e => pickerKeys(e, node)) + search.addEventListener('blur', () => setTimeout(() => { results.innerHTML = ''; search.setAttribute('aria-expanded', 'false') }, 150)) + $('.op__remove', node).addEventListener('click', () => { + node.remove() + $$('.op', $('#op-list')).forEach((el, i) => { $('.op__n', el).textContent = String(i + 1) }) + refresh() + }) + for (const el of $$('.op__title, .op__text, .op__note, .op__number', node)) { + el.addEventListener('input', () => { renderDiff(node); refresh() }) + } + + $('#op-list').appendChild(node) + shapeFor(node) + return node +} + +/** Show only the inputs this kind of change needs. */ +function shapeFor (node) { + const kind = $('.op__kind', node).value + const isInsert = kind === 'insert' + const gone = kind === 'omit' || kind === 'reserve' + + $('.op__pick', node).hidden = isInsert + $('.op__insert', node).hidden = !isInsert + $('.op__titlebox', node).hidden = gone + $('.op__textbox', node).hidden = gone || kind === 'retitle' + $('.op__notebox', node).hidden = !gone + + if (isInsert) { + const next = PROVISIONS.next_article + const reserved = PROVISIONS.reserved ?? [] + const num = $('.op__number', node) + if (!num.value) num.value = String(next) + $('.op__insert-help', node).textContent = reserved.length + ? `The next free number is ${next}. Article ${reserved.join(', ')} ${reserved.length > 1 ? 'are' : 'is'} reserved and may be occupied instead — numbering may not otherwise skip.` + : `The next free number is ${next}. Numbering may not skip: to leave a number empty, reserve it deliberately.` + } + renderDiff(node) + refresh() +} + +function showMatches (node, q) { + const results = $('.op__results', node) + const search = $('.op__search', node) + const query = q.trim().toLowerCase() + if (!query) { results.innerHTML = ''; search.setAttribute('aria-expanded', 'false'); return } + + const kind = $('.op__kind', node).value + const pool = PROVISIONS.provisions.filter(p => { + if (kind === 'retitle') return true + if (kind === 'omit' || kind === 'reserve') return p.kind === 'article' + return true + }) + const hits = pool.filter(p => + p.id.includes(query) || + (p.title ?? '').toLowerCase().includes(query) || + (p.number != null && String(p.number) === query) + ).slice(0, 12) + + results.innerHTML = hits.map(p => { + const label = p.kind === 'preamble' ? 'Preamble' + : p.kind === 'section' ? `Article ${p.article_number}, clause ${p.number}` + : `Article ${p.number}` + return `
  • + ${escapeHtml(label)} — ${escapeHtml(p.title ?? '')} + ${escapeHtml(p.id)}
  • ` + }).join('') + search.setAttribute('aria-expanded', hits.length ? 'true' : 'false') + + for (const li of $$('li', results)) { + li.addEventListener('mousedown', e => { e.preventDefault(); choose(node, li.dataset.id) }) + } +} + +function pickerKeys (e, node) { + const items = $$('.op__results li', node) + if (!items.length) return + const active = items.findIndex(li => li.classList.contains('is-active')) + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + const next = e.key === 'ArrowDown' + ? Math.min(active + 1, items.length - 1) + : Math.max(active - 1, 0) + items.forEach(li => li.classList.remove('is-active')) + items[next].classList.add('is-active') + items[next].scrollIntoView({ block: 'nearest' }) + } else if (e.key === 'Enter') { + e.preventDefault() + choose(node, items[Math.max(active, 0)].dataset.id) + } else if (e.key === 'Escape') { + $('.op__results', node).innerHTML = '' + } +} + +/** Choosing a target prefills its current text. This is what makes full-text operations true. */ +function choose (node, id) { + const p = PROVISIONS.provisions.find(x => x.id === id) + if (!p) return + node.dataset.target = id + node.dataset.kindOf = p.kind + + const label = p.kind === 'preamble' ? 'Preamble' + : p.kind === 'section' ? `Article ${p.article_number}, clause ${p.number}` + : `Article ${p.number}` + $('.op__chosen', node).textContent = `Chosen: ${label} — ${p.title ?? ''} (${p.id})` + $('.op__search', node).value = '' + $('.op__results', node).innerHTML = '' + + const kind = $('.op__kind', node).value + $('.op__title', node).value = p.title ?? '' + if (kind === 'substitute') { + // Prefilled with what it says now; the author edits it into what it should say. + $('.op__text', node).value = fullTextOf(p) + } + if (kind === 'omit' || kind === 'reserve') { + $('.op__confirm', node).hidden = false + $('.op__confirm', node).innerHTML = + ` +
    ${escapeHtml(fullTextOf(p).slice(0, 800))}
    ` + } else { + $('.op__confirm', node).hidden = true + } + renderDiff(node) + refresh() +} + +const fullTextOf = p => [p.text ?? '', ...(p.sections ?? []).flatMap(s => [s.title, s.text])] + .filter(Boolean).join('\n').trim() + +function renderDiff (node) { + const body = $('.op__diff-body', node) + const id = node.dataset.target + const kind = $('.op__kind', node).value + const p = PROVISIONS.provisions.find(x => x.id === id) + + if (kind === 'insert') { + body.innerHTML = `

    New provision.

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

    Pick a provision to see the change.

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

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

    ` : ''} +

    Before

    ${escapeHtml(before)}
    +

    After

    ${after === null + ? '

    (provision removed)

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

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

    ' + return + } + el.className = 'banner banner--superseded' + el.innerHTML = `` +} + +const previewOf = bill => [ + `${bill.bill.short_title}`, + `Moved by ${bill.bill.moved_by.name || '—'}${bill.bill.moved_by.role ? ', ' + bill.bill.moved_by.role : ''}`, + `Drafted against constitution ${bill.bill.base_version}`, + '', + ...bill.operations.map((op, i) => `${i + 1}. ${verbFor(op)}`), + '', + 'STATEMENT OF OBJECTS AND REASONS', + bill.objects_and_reasons || '(none given)' +].join('\n') + +const verbFor = op => op.operation === 'insert' + ? `Insertion of new Article ${op.target.replace('art-', '')}${op.title ? ` - ${op.title}` : ''}:` + : op.operation === 'omit' ? `Omission of ${op.target}:` + : op.operation === 'reserve' ? `Reservation of ${op.target}:` + : op.operation === 'retitle' ? `Amendment to ${op.target} (heading):` + : `Amendment to ${op.target}:` + +// --------------------------------------------------------------------------- + + +async function download () { + const bill = buildBill() + const slug = (bill.bill.short_title || 'draft').toLowerCase() + .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'draft' + const blob = new Blob([billToYaml(bill)], { type: 'text/yaml' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `draft-${slug}.yaml` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(a.href), 1000) + toast('Draft downloaded. Send it to the ICC.') +} + +async function copyHash () { + const text = $('#hash-out').textContent + try { await navigator.clipboard.writeText(text) } catch { /* fall through */ } + toast('Hash copied.') +} + +function toast (message) { + const el = document.getElementById('toast') + if (!el) return + el.textContent = message + el.classList.add('is-visible') + setTimeout(() => el.classList.remove('is-visible'), 3200) +} + +function escapeHtml (s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 4ab8cad..a5c1dca 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1446,3 +1446,77 @@ body.is-modal-open { vertical-align: 0.28em; cursor: help; } + + +/* --- /bills/ ------------------------------------------------------------ + Lifted from bills.mjs, which exported these rules as a