Skip to content

Validate the hand-appended mutation ledger in shared CI - #385

Merged
thedavidmeister merged 1 commit into
mainfrom
mutation-ledger-gate
Sep 16, 2026
Merged

thedavidmeister merged 1 commit into
mainfrom
mutation-ledger-gate

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Closes #384. Upstream half of rainlanguage/rain.lib.hash#62, which
rainlanguage/rain.lib.hash#98 tried to fix inside a consumer's forge suite and
was closed unmerged for it.

What was wrong

audit/mutation-test-scans.json is the adversarial-mutation-test skill's
committed run record, appended by hand. Two automated consumers read it as
evidence — the audit skill's Pass-2 gate and rain-org-health's roh-scan —
and both trust the commit it names. Neither validates it, and no CI step in
the org parsed the file at all
.

Two records in the org are already wrong, found while building this:

repo value state
rainlanguage/rain.solmem "commit": "b3bd859" a 7-character prefix
ST0x-Technology/st0x.oracle [1] "commit": "05bb66bfd8d0e" resolves to no commit in that repo

GET /repos/ST0x-Technology/st0x.oracle/commits/05bb66bfd8d0e answers
422 No commit found for SHA and search/commits hash: returns 0. That record
claims a whole-repo run over 119 behaviours with "every non-equivalent mutant
killed … no coverage gap, no new test … zero findings filed"
— against a tree
that does not exist. It has been sitting green since 2026-08-11.

What changed

rainix-static mutation-ledger, a composite action around it, and the action
wired into both rainix-sol-static.yaml and rainix-rs-static.yaml. Rust
does the parsing and the comparing, bash only decides whether there is a record
to look at and fetches the history ancestry needs (CLAUDE.md).

Shape. A non-empty JSON array of run objects; per record a strict
YYYY-MM-DDTHH:MM:SSZ timestamp, a 40-character lowercase-hex commit,
tool == "adversarial-mutation-test", a non-empty scope, and a summary
object whose inner shape is per-repo and deliberately unchecked.

Ancestry. git merge-base --is-ancestor <sha> HEAD for every recorded SHA.
"not a commit in this repo" and "not an ancestor of HEAD" are reported
differently, because the fixes differ.

The decisions #384 asked for

A repo with no ledger is not failed for absence — and does not pay for the
check.
Decided in two places on purpose, the same split
frozen-snapshots-append-only uses: the composite's bash tests for the file
first, so a repo that has never been mutation-tested does not pay for a fetch to
be told it is fine; and the Rust treats an absent file as a pass too, so running
the binary directly gives the same verdict the step does. This validates a
record, it never requires one.

A shallow checkout is refused, not answered. The shared checkout is
fetch-depth: 1, where merge-base --is-ancestor calls every commit past the
graft boundary unreachable — a silent wrong answer, the exact failure class
this gate exists to remove. So the Rust half errors rather than reporting a
false non-ancestor, and the action deepens first. The deepen is commits-only:
--filter=tree:0 fetches the commit graph without trees or blobs, which is all
ancestry needs. Measured on rainlanguage/raindex (186MB, the largest
consumer): 4.3s, .git 4.4MB → 9.0MB.

testsAfterCommit and skillVersion are shape-checked when written, never
required.
12 of the 18 records committed across rainlanguage name no
testsAfterCommit, and its presence does not track the skill version either way
(rain.sol.codegen wrote it at 0.30.0, rain.string did not at 0.33.0). The value
cannot be recovered for a run that is over, so requiring it would red those
repos until someone invented a SHA — the fabricated evidence this gate exists to
catch. rain.extrospection carries no skillVersion for the same treatment.

Both static workflows. The ledger is a JSON record, not a Solidity artifact.
All 14 ledger repos today are sol repos, so gating only the sol job would cover
every one of them — and would leave a future rust-only repo silently ungated,
which is #341 repeating: a skip and a pass are the same green. raindex and
rain.metadata run both jobs and do carry a ledger, so they check it twice: one
extra commits-only fetch, the price of the hole not existing.

Not rain-org-health. roh-scan reports, it does not gate: it runs hourly
on a cron in its own repo and commits site/health.json, blocking no merge
anywhere. It reads one file at a time over the contents API and holds no
checkout
, so merge-base --is-ancestor is not available to it; it would have
to hit the compare API per SHA per repo, and the answer would be against the
default branch rather than the branch proposing the record. And its degradation
is correct — mutation.rs returns None for a malformed ledger, "never a
fabricated 'never ran'" — which is exactly why a reporter cannot be the gate.
Full reasoning in #384.

QA

Against all 14 real ledger repos in the org, each cloned --depth 1 and
deepened as the action does. 12 clean, 2 red, and rainix itself (no ledger)
skips:

raindex … rain.datacontract … rain.math.saturating … rain.lib.hash …
rain.intorastring … rain.metadata … rain.lib.typecast … rain.extrospection …
rain.math.binary … rain.sol.codegen … rain.string … st0x.atomic-bridge
    mutation-ledger: clean                                     exit 0

rain.solmem   ERROR: …[0].commit: "b3bd859" is not a 40-character
              lowercase-hex git commit — record the full object name
              (`git rev-parse <ref>`), never a prefix              exit 1
st0x.oracle   ERROR: …[1].commit: "05bb66bfd8d0e" …               exit 1

rainix        mutation-ledger: no audit/mutation-test-scans.json; skip  exit 0

Mutations, each applied to rain.lib.hash's real ledger in its real clone and
each run. Baseline clean, 9/9 killed, exit 1 every time, one line of output
naming the record index and field:

mutation what it printed
commit → a 40-hex SHA that exists nowhere is not a commit in this repo — a mistyped or rebased-away SHA
commit → a real commit in the repo, off this branch is not an ancestor of HEAD … not on this history
trailing comma before ] is not valid JSON: trailing comma at line 25 column 1
timestamp → "2026-09-03 09:40:00" is not YYYY-MM-DDTHH:MM:SSZ — roh-scan ranks runs by comparing these as strings
[] is an empty array — … indistinguishable from a repo that has never been mutation-tested, except that it looks like evidence
array wrapper stripped is an object — the ledger is a JSON ARRAY
tool → "some-other-tool" expected the string "adversarial-mutation-test"
testsAfterCommit → the same SHA uppercased is not a 40-character lowercase-hex git commit
the deepen removed (shallow clone, check run directly) is a shallow checkout, where ancestry is unanswerable

The non-ancestor case is a commit actually created in that clone with
git commit-tree, not a stub: git merge-base --is-ancestor really answered 1
for it and 0 for the recorded SHAs.

Unit tests: +29 in mutation_ledger.rs, suite 203 → 232, 0 failed. Shape
messages are covered against a stub probe, and git_probe itself against a real
temp repo built with two commits on HEAD and one on a side branch, so the
ancestor / non-ancestor / unknown split is exercised through git rather than
described. Separate tests cover a real shallow clone being refused and a
directory git cannot answer for erroring rather than passing.

Bats: +9. 8 on the composite's bash — a ledger reaches the check, no ledger
skips without fetching, an audit/ directory alone is not a ledger, a
directory at the ledger path is not a record, a shallow checkout is deepened
commits-only, a deep one is not refetched, the deepen precedes the check (it
must — the Rust half refuses a shallow repo, so the reverse order would red
every ledger repo), and the check runs from the composite's own checkout rather
than a pinned RAINIX_SHA. 1 asserting each static workflow wires the action,
since nothing here executes either reusable and a dropped step is invisible.

cargo fmt --check, cargo clippy -D warnings -D clippy::all and the full
pre-commit bundle are clean.

Landing consequence, stated

rainlanguage/rain.solmem and ST0x-Technology/st0x.oracle go red on their next
push until their records are corrected. That is the gate working. rain.solmem's
is mechanical — b3bd859 expands to a real object name that is an ancestor of
main — and is being fixed alongside this. st0x.oracle's is not: the SHA
resolves to nothing, so what that record should say is a human call and gets its
own issue there rather than a value invented to make CI green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN

audit/mutation-test-scans.json is read as evidence by the audit skill's
Pass-2 gate and by rain-org-health's scanner, both of which trust the
commit it names and neither of which validates it. Nothing in CI parsed
it, so a trailing comma, a short SHA or a rebased-away commit merged
green and surfaced later as a refused or wrong-based audit.

Two live examples found while building this: rain.solmem records
"b3bd859", and ST0x-Technology/st0x.oracle records "05bb66bfd8d0e",
which resolves to no commit in that repo at all — the run it claims
names a tree that does not exist.

Adds `rainix-static mutation-ledger` plus a composite wired into both
static reusables. Shape: non-empty array, strict YYYY-MM-DDTHH:MM:SSZ
timestamp, 40-char lowercase-hex commit, tool literal, scope, summary
object. Ancestry: every recorded SHA must be an ancestor of HEAD, with
"not a commit here" and "not on this history" reported differently.

testsAfterCommit and skillVersion are shape-checked when written and
never required: 12 of 18 committed records name no such tree and the
value cannot be recovered for a finished run, so requiring it would only
invite an invented SHA. A repo with no ledger passes. A shallow checkout
is refused rather than answered, and the action deepens commits-only
first (--filter=tree:0: 4.3s and 4.4MB -> 9.0MB on raindex).

Closes #384.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 44 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 1110897b-3dc0-453d-b647-8023a63aefb8

📥 Commits

Reviewing files that changed from the base of the PR and between bcff1f8 and b32f5cf.

📒 Files selected for processing (9)
  • .github/actions/mutation-ledger/action.yml
  • .github/workflows/rainix-rs-static.yaml
  • .github/workflows/rainix-sol-static.yaml
  • flake.nix
  • rainix-static/src/main.rs
  • rainix-static/src/mutation_ledger.rs
  • test/bats/action/mutation-ledger.test.bats
  • test/bats/workflow/rainix-rs-static.test.bats
  • test/bats/workflow/rainix-sol-static.test.bats

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thedavidmeister
thedavidmeister merged commit e8be950 into main Sep 16, 2026
18 checks passed
@github-actions

Copy link
Copy Markdown

@coderabbitai assess this PR size classification for the totality of the PR with the following criterias and report it in your comment:

S/M/L PR Classification Guidelines:

This guide helps classify merged pull requests by effort and complexity rather than just line count. The goal is to assess the difficulty and scope of changes after they have been completed.

Small (S)

Characteristics:

  • Simple bug fixes, typos, or minor refactoring
  • Single-purpose changes affecting 1-2 files
  • Documentation updates
  • Configuration tweaks
  • Changes that require minimal context to review

Review Effort: Would have taken 5-10 minutes

Examples:

  • Fix typo in variable name
  • Update README with new instructions
  • Adjust configuration values
  • Simple one-line bug fixes
  • Import statement cleanup

Medium (M)

Characteristics:

  • Feature additions or enhancements
  • Refactoring that touches multiple files but maintains existing behavior
  • Breaking changes with backward compatibility
  • Changes requiring some domain knowledge to review

Review Effort: Would have taken 15-30 minutes

Examples:

  • Add new feature or component
  • Refactor common utility functions
  • Update dependencies with minor breaking changes
  • Add new component with tests
  • Performance optimizations
  • More complex bug fixes

Large (L)

Characteristics:

  • Major feature implementations
  • Breaking changes or API redesigns
  • Complex refactoring across multiple modules
  • New architectural patterns or significant design changes
  • Changes requiring deep context and multiple review rounds

Review Effort: Would have taken 45+ minutes

Examples:

  • Complete new feature with frontend/backend changes
  • Protocol upgrades or breaking changes
  • Major architectural refactoring
  • Framework or technology upgrades

Additional Factors to Consider

When deciding between sizes, also consider:

  • Test coverage impact: More comprehensive test changes lean toward larger classification
  • Risk level: Changes to critical systems bump up a size category
  • Team familiarity: Novel patterns or technologies increase complexity

Notes:

  • the assessment must be for the totality of the PR, that means comparing the base branch to the last commit of the PR
  • the assessment output must be exactly one of: S, M or L (single-line comment) in format of: SIZE={S/M/L}
  • do not include any additional text, only the size classification
  • your assessment comment must not include tips or additional sections
  • do NOT tag me or anyone else on your comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant