Phase 8: the amendment pipeline - #32
Merged
Merged
Conversation
The inversion this phase exists for: the bill YAML is the source of truth and the signed PDF is a rendering of it. An amendment is born machine-applicable, so application is a comparison rather than a transcription. schema/opencodelaw-bill-1.0.schema.json and a heavily commented bills/TEMPLATE.yaml. Operations carry the COMPLETE resulting text of their target — never a diff, never a splice. That is what makes application idempotent by construction: classifyOperation compares current against proposed and against base, giving already-applied, apply, or divergent. Re-running Act 1 of 2024 was unsafe precisely because its clause edits were line splices into text that no longer existed after the first run. renumber is absent from the operations vocabulary and is rejected by name with the reason: anchor stability is a public API, and renumbering is lawful only in a revision bill with a major bump and an explicit anchor map. Article 16(3) is implemented as settled policy. All three bodies are required and none is inferred. Collectively bears two readings, so until the board resolves it the stricter governs — two thirds within each body separately, abstentions excluded from the denominator — and both per-body and pooled tallies are recorded so the record satisfies either reading. Quorum and notice are by-laws matters under 16(2) and are recorded, not invented. base_version makes staleness fatal: a bill drafted against a superseded version fails with a rebase instruction, so no body votes on text that has moved. moved_by is recorded at drafting, which permanently fixes the Q8 class of problem. CLI: bill new / validate / render / submit, act enact / apply. Guard failures name the missing thing in plain language — a coordinator reads these, not a developer. Register gains origin: bill | external-pdf. The three 2024 Acts are external-pdf and their history is not rewritten. Derivation is extended so bill-origin Acts derive their entry from the bill file, and the drift rule covers both. No constitutional text is touched; tripwire green with zero permitted changes.
Completes the pipeline. 77 tests plus the 10-step keyboard walkthrough; the constitution is untouched and the tripwire is green with zero permitted changes. process/AMENDMENT-PROCESS.md states the three-body Article 16(3) requirement as settled policy — any checklist or shortcut requiring less is invalid on its face — records that the ICC checks form and never substitutes for a body's approval, and marks the "collectively" reading as pending a board resolution with the stricter per-body two-thirds governing meanwhile. Quorum and notice are left to the by-laws under 16(2). process/PROPOSING.md is the author's guide, with a wholly fictional worked example. src/bill-render.mjs renders the instrument in the 2024 house style, text and print-oriented HTML. src/templates/bills.mjs builds /bills/, which lists rejected and withdrawn bills too — a legislature's failed bills are part of its record. Three defects found and fixed while integrating, none of them cosmetic: - validateBill threw on its SECOND call in a process. Ajv registers a schema under its $id on compile, and I had memoised the instance rather than the compiled validator, so validating two bills in one run failed — which the CLI and the site build would both have hit immediately. The tests had been importing the module under a fresh specifier to work around it. - src/bill-render.mjs hardcoded the organisation, the committee, the signatory title and the footer address. That is content living in the engine, and the reusability test caught it. House style moves to info.instrument in the constitution, where a forking organisation replaces it without editing code. - A test passed `undefined` to a helper with a default parameter, which fires on undefined — so the template was silently validated against the fixture constitution rather than the version it names. Register gains origin: bill | external-pdf; the 2024 Acts stay external-pdf and their history is not rewritten.
Adds the substantive hash that Part 1 of the addendum binds approvals to: sha256 over a canonical JSON form (sorted keys, UTF-8) of exactly short_title, type, base_version, objects_and_reasons and operations. Number, status, history, approvals and enactment are clerking fields that change after drafting, so including them would make a vote go stale for administrative reasons. Verified stable across clerking edits and sensitive to any change in an operation. A vote binds to this rather than to "Bill 1 of 2026", because a title reads the same before and after someone edits an operation, and an approval recorded against a title would silently survive a change to the text it approved. Four defects from the contract review, all confirmed before fixing: - The CLI passed house style in a shape houseStyle() cannot read, spreading the YAML's snake_case keys at top level where the options are camelCase. The signatory office line and the ENTIRE address footer silently vanished from the rendered instrument. It now passes info itself. - DEFAULT_COMMITTEE carried STM's own wording, so a fork with no info.instrument would print another organisation's committee on its masthead. Defaults are empty; house style comes from the constitution. - moved_by was never printed on the instrument. The schema requires it and both docs call it the permanent fix for the 2024 Acts naming no proposer — but recording it in the bill only fixes that if the signed paper carries it. It now prints above the separator, in text and HTML. - BILLS_CSS was exported as a <style> string nothing imported, so /bills/ shipped with class attributes and no rules — including a focusable overflow container that could never scroll. The rules move into layout.css. The renderer had zero test coverage, which is why the option-shape defect was invisible to CI. tests/bill-render.test.mjs now covers house-style resolution, every house-style element in order, the mover, the unenacted case, self- containment, and the hash's stability and sensitivity. A guard checks that the base_version quoted in bills/TEMPLATE.yaml and any "Today that is X" prose matches the live constitution — deliberately not the fictional worked example, which is a different society at its own version. 85 tests. Constitution untouched; tripwire green at zero permitted changes.
Approvals now bind to the bill's substantive hash rather than its title, and carry a signed record of resolution archived in the repository. The schema replaces the flat approval entry with meeting details (date, mode, place, presiding), tallies, bill_sha256, and an evidence object of kind, path and sha256. `kind` distinguishes minutes from an attested poll export: a poll is a voting mechanism, never a record, because a URL is mutable, unattributable, and dies with the platform. Five rules enforced at validate and at enact: evidence must exist on disk with a matching checksum; a tally with no record is an assertion and is refused; a vote with no bill_sha256 is unbound and is refused; an approval whose hash no longer matches is void; all three bodies still required with the stricter per-body two-thirds. The voiding rule is implemented unsoftened, including its hardest edge. base_version sits inside the hash, so a rebase voids every approval even when the bill's own operations are byte-identical. There is deliberately no clean-rebase exemption: approval attaches to an amendment in context, not to isolated strings — art-10's retained paragraph was harmless until art-21 existed — and "operations unchanged" is machine-decidable while "no interaction effects" is not. A rule a tool cannot adjudicate honestly is a rule it should not have. The reasoning is in the code, next to the check. Voided approvals are history, not deletions: the schema carries an approval-voided history entry holding the body, tallies, evidence and the hash it was recorded against. A body's vote is a legislative fact even after the text moves on. `bill validate` prints the resolution sentence with the hash, and flags any body that voted on different text. `bill ballot` renders one pre-filled resolution sheet per body — hash already printed, so it cannot be mistyped — guarded to `scheduled` or later, because circulation is the freeze point and a sheet for a bill still under form review would carry a hash the ICC is about to change. process/MINUTES-TEMPLATE.md documents the same structure for anyone recording by hand, including the privacy rule: published records carry tallies and attendance counts, never roll-calls of ordinary members' votes; a roll-call roster is an internal ICC annexure, referenced and not published. 91 tests. Constitution untouched; tripwire green at zero permitted changes.
A static builder at /propose/ that produces a draft bill and nothing else. It cannot authenticate, number, submit or approve, and it says so plainly rather than implying otherwise — a static page pretending to verify membership would be theatre. Authority stays with the ICC and the three bodies. Two guarantees are structural rather than advisory: - A target is PICKED from a searchable index, never typed, so an operation cannot name a provision that does not exist. - Choosing a target PREFILLS its current text. The author edits a whole provision into its new form and physically cannot write "insert after the words…". Full-text operations stop being a rule authors must follow and become the only thing the form can produce. Validation is the same JSON Schema the CLI enforces, compiled to a standalone browser module at build time. A hand-written second check in the page would be a second implementation free to drift, which is the class of failure this project began with when the docs, the specs and the renderer each described a different root key. provisions.json carries every provision's id, title, title_source and current text, plus the next free article number and any reserved slot, so an insert is validated against the numbering rules rather than trusted. One defect found by driving the page in a real browser, and it was the important one: the hash the page displayed did not equal the hash the CLI computed from the downloaded file. A YAML block scalar always round-trips with exactly one trailing newline, and the page was hashing the raw textarea value without it. A meeting would have resolved on a number that did not match the file it was voting on — the precise failure the hash exists to prevent. The page now hashes the form it actually writes. tests/propose.e2e.mjs drives the real page: the prefill is the provision as it currently reads, the CLI validator accepts what the page produces, the two hashes agree, the form offers no revision type, and the picker is fully keyboard-operable. Also adds the shared-evidence coherence warning and the per-body recording sentence from the Part 1 refinements: one record may prove a joint sitting, but a body's two-thirds is proven only by that body's own tally — evidence can be shared, arithmetic cannot. 91 unit tests, 15 e2e. Constitution untouched; tripwire green at zero.
The invariant is now stated where both sides can see it, at the top of the shared module: hash what will be PARSED, never what is DISPLAYED. Any path that hashes screen state, a textarea value, or a pre-serialisation object recreates the defect it names. canonicalJson, blockText, the hash subject and the YAML emitter move to one module shared verbatim by the CLI and the page. There were two copies of the first two; one shared normaliser is also what keeps untouched text comparing equal, which is the phantom-edit risk. Three guards close the hole the existing e2e could not reach — it catches a dropped REQUIRED field because the CLI rejects the output, but never a dropped OPTIONAL one, because absence is valid: 1. A maximal fixture with every optional field populated round-trips through the serialiser and must deep-equal its input. 2. A coverage test walks the schema's property paths; each must appear in the fixture or on an explicit exclusion list, so a new schema field fails BY NAME until it is handled or excluded deliberately. 3. The exclusion list doubles as the page's authority boundary, asserted: the page emits no number, no history, no approval tally, no evidence and no enactment field. It produces drafts and structurally cannot produce more. The round-trip guard immediately earned itself: history[].evidence: null was being dropped, because the emitter tested `!= null` rather than key presence. Present-but-null is not absent — it records that a transition had no evidence, which is itself a fact. history[].approval is now serialised too, so a voided approval survives verbatim rather than being reshaped. Adds the phantom-edit e2e: select a target, change nothing, download — the operation must classify as already-applied rather than as an edit nobody made. It passes, which is what the shared normaliser buys. 94 unit tests, 16 e2e. Constitution untouched; tripwire green at zero.
A pull request touching bills/** now runs the full per-bill check — schema, target resolution, staleness, evidence files with matching checksums, and the threshold for any recorded approvals — plus two checks no single-bill run can see. Pending conflicts. Two OPEN bills amending the same provision are an error naming both, because whichever applies second would overwrite or contradict the first. Terminal bills (applied, rejected, withdrawn, lapsed) cannot conflict with anything. The gate does not resolve it: the ICC sequences them and the second rebases — and re-collects its approvals, because a rebase voids them, which the message says. The constitution guard. A pull request may touch constitution/current.yaml only when an enacted Act in the same pull request accounts for it: every provision whose text moved must be named by that Act's operations, and nothing else may have moved. Otherwise it fails with "the constitution is amended by `act apply`, not by hand." Verified against a synthetic hand edit, which the guard caught by name (art-2) before the commit was reset. An Act naming a provision the diff does not show is a warning rather than an error: expected when a provision already read as the Act prescribes, and suspicious otherwise. Warnings — including shared-evidence coherence — are emitted as GitHub annotations as well as log text. A warning nobody sees is a warning that does not exist. CI uploads the gate report and the rendered instruments and ballot sheets as artifacts, so the tech department reviews what the approving bodies actually read rather than a YAML diff. 96 tests. Constitution untouched; tripwire green at zero.
Named-by is necessary and not sufficient. CI cannot assume the diff in front
of it came from `act apply` — replacing that assumption is the guard's whole
job — so every provision an Act names is now compared by CONTENT, through the
same three-way classify the applier uses. One compare, everywhere.
already-applied the PR applied it, or it already read that way
apply the Act prescribes different text and the PR does not
apply it: error
divergent it matches neither the base nor the prescribed text: the
Act names it, but this diff is not that Act: error
The unmoved-but-named case now splits on content rather than suspicion: a
genuine no-op stays a warning, and a provision matching neither is an error.
Verified against a synthetic PR carrying an enacted Act naming art-5 while
hand-editing art-5 to text the Act never said — caught as divergent.
Append-only record paths. Modifying or deleting anything under
constitution/versions/, acts/pdf/, acts/text/ or bills/**/evidence/ fails, as
does touching a bill that was terminal in the base image. Additions are always
allowed. This is "voided approvals are history, never deletions" at file
granularity: nothing routinely re-validates a concluded bill, so its evidence
was otherwise unprotected. A rename fails too, since git reports it as delete
plus add — correct, because that conversation should start with a red check.
Verified by tampering: a modified archived version and a deleted Act text file
were both caught by name.
CONTRIBUTING gains the lesson from testing the previous guard: commit first,
test guards on throwaway branches, never stash around uncommitted work.
98 tests. Constitution untouched; tripwire green at zero.
The CLI now writes BILL FILES through billToYaml — register and constitution writes keep js-yaml, which were never part of this. Not for the hash, which is computed from parsed content: the reasons are that two dumpers poison the diff the gate reviewer reads, and that the round-trip guard previously covered only the browser's path. All three conditions landed first, and two of them caught real defects: - Adversarial scalars — colons, hashes, quotes, leading and trailing spaces, an empty string, unicode, lines that look like YAML syntax, a very long line — found that `recorded_by: ''` was silently dropped. An empty string is not absence, the same defect as the null evidence. Every optional field now emits through one presence check rather than a truthiness test. - The same fixture found that an empty block scalar cannot represent "\n": YAML yields "". blockText now returns "" for empty input and the emitter writes text: "" — the format's actual round-trip, rather than a value it cannot carry. - Emit idempotence holds across three successive writes. - A minimal-diff golden test asserts that submitting a page-authored draft changes only number, status and the new history entry. Two idempotency bugs found by running the fixture lifecycle end to end: - Re-applying an insert returned divergent, because the article now exists. It is already-applied when the existing article reads as the Act prescribes, and divergent only when something else occupies that number. - Re-applying an omit or reserve returned apply forever. The operation is `omit`; the status it leaves is `omitted`. The check compared the two directly — and the lifecycle test agreed with it, because the test wrote the same wrong status the check expected. OPERATION_STATUS names the mapping once, and a test asserts the applier writes what the classifier expects. Process doc closes the inversion the ballot guard was already enforcing: the freeze point, "get the text right before you circulate", both mid-cycle recovery paths with the joint sitting named, and the sequencing rule. Plus the note that any CLI write strips comments — an emitter emits an object. 104 unit tests, 16 e2e. Constitution untouched; tripwire green at zero.
Two different kinds of surface, so they ship differently. /bills/ is RECORD and ships with the merge. An empty register is a true statement: "no bills are before the board" is information, not absence. /propose/ is ACTION and ships dark behind PROPOSE_ENABLED, defaulted false in the deploy workflow. Its one actionable instruction is "email this file to the ICC"; putting that in front of the public before the ICC can receive would make the system's first impression on its first real author silence. When the flag is off, the page, provisions.json, the standalone validator and the footer link are all absent — verified in both directions, with the link checker clean either way. process/ADOPTION.md is the checklist that flips it, and names the condition that is currently failing: Cloudflare's Email Address Obfuscation rewrites the mailto at the edge, so the page's only actionable instruction is broken for a reader without JavaScript. Either the feature is disabled or the instruction points at a contact page. Plus the ICC briefing, one announcement to all three bodies — 16(3) convenes all of them, so none should first hear of this when summoned — and one human dry run of the fixture lifecycle, so the first real bill is not the first time a human touches the tools. Also records that the tripwire's zero-permitted era ends when the first real Act applies: from then on the Act's manifest is the permission, and a legitimate first diff under an Act is not a breach. The idempotency test gains the comment saying why it exists — the test it replaced agreed with the bug, writing the same wrong status the classifier expected, so two implementations confirmed each other's error indefinitely. It broke open only against an independent derivation of what the applier writes.
The suite asserts against built output as well as source. The gate ran npm test without npm run build, so 29 tests failed on "dist/ missing" the first time it ran on a real pull request. The deploy workflow already built first; this one did not. Adds the link check to the gate while there, for the same reason: it also reads dist/.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Amendments are now born machine-applicable. The bill YAML is the source of truth; the signed PDF is a rendering of it.
No constitutional text changes. The tripwire ran at zero permitted changes for the whole phase.
The inversion
The 2024 Acts were authored as prose PDFs and applied by hand — which produced a half-applied constitution, an unrecorded application, a splice that made re-running an Act unsafe, and fourteen reconciliation questions. Now an operation carries the complete resulting text of its target, so application is a comparison rather than a transcription:
already-applied/apply/divergent, idempotent by construction for all five operation types.Article 16(3) as settled policy
All three bodies required, none inferred. The stricter per-body two-thirds governs until the board resolves what "collectively" means; both per-body and pooled tallies are recorded so the record satisfies either reading. Quorum and notice stay with the by-laws under 16(2).
Evidence binds to a hash, not a title
A vote records the bill's substantive hash. Edit the bill and those approvals are void — including when the operations are untouched, because approval attaches to an amendment in context. Voided approvals move to
history, never deleted. Evidence is a signed record archived in the repo with a matching checksum; a poll is a voting mechanism, never a record.The gate
A PR touching
bills/**runs schema, targets, staleness, evidence and thresholds, plus two cross-cutting checks: pending conflicts between open bills, and the guard that the constitution is amended byact apply, not by hand — comparing content, not names. The record is append-only: archives, signed instruments, extracted text and evidence may only gain files.Shipping
/bills/live (record)./propose/dark behindPROPOSE_ENABLEDuntilprocess/ADOPTION.mdis checked off (action).104 unit tests, 16 e2e, validation clean, zero internal 404s.