Skip to content

Phase 8: the amendment pipeline - #32

Merged
KiranPranay merged 13 commits into
mainfrom
feature/amendment-workflow
Aug 15, 2026
Merged

Phase 8: the amendment pipeline#32
KiranPranay merged 13 commits into
mainfrom
feature/amendment-workflow

Conversation

@KiranPranay

Copy link
Copy Markdown
Member

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 by act 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 behind PROPOSE_ENABLED until process/ADOPTION.md is checked off (action).

104 unit tests, 16 e2e, validation clean, zero internal 404s.

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/.
@KiranPranay
KiranPranay merged commit d8734b3 into main Aug 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant