Skip to content

feat(ci): #262 Stage 2 — Own.NET's own CI and dogfood select Rust; the public contract does not move - #350

Merged
PhysShell merged 7 commits into
mainfrom
claude/checkpoint-0-execution-ggdagx
Sep 9, 2026
Merged

feat(ci): #262 Stage 2 — Own.NET's own CI and dogfood select Rust; the public contract does not move#350
PhysShell merged 7 commits into
mainfrom
claude/checkpoint-0-execution-ggdagx

Conversation

@PhysShell

@PhysShell PhysShell commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Что и зачем

#262 Stage 2: this repository's own CI and dogfood run on the Rust core, and nothing a user can see changes. Stage 1 proved the core works behind the launcher; Stage 2 proves only that Own.NET can run on it internally. It is not the cutover — all four public surfaces still resolve Python, and Stage 3 stays behind its own owner decision.

Тип изменения

  • feat — новая возможность
  • fix — исправление бага
  • docs — документация
  • refactor / chore / test / ci — без изменения поведения

No product behaviour changes. EngineSelection.Default is still Engine.Python, action.yml's engine input still defaults to python, and a bare owen / own-check.sh / own-check.ps1 / Action invocation still resolves Python.

The census, because the claim needs a denominator

"Own.NET CI is Rust-default" is unfalsifiable if it means "we found one job that runs Rust". docs/evidence/p022-stage2-census.json therefore names every call site in every workflow that executes the analysis core or a production launcher surface, and the role each one plays. tests/test_stage2_dogfood.py enumerates the workflows itself and fails in both directions: an unclassified call site, and a ledger entry for a job that no longer invokes anything. A census that can be satisfied by editing prose is not a census.

class count what it is Stage-2 treatment
A — public-contract verifier 8 proves how the packaged/default surface behaves stays Python-default on purpose
B — explicit reference 6 parity, fixture semantics, benchmark methodology, cross-tool oracle stays the reference
C — compare gate 5 the ratified #260 / Stage-1 differential contract untouched
D — operational dogfood 2 Own.NET analysing its own tree selects Rust explicitly

21 call sites across 5 workflows, 10 entry-point patterns. Two negative lookaheads are declared in the ledger and were measured, not guessed: benchmark.py --selftest and shadow_sweep.py --collect/--result analyse nothing, so counting them would inflate the denominator with runs that execute no core.

Class D was exactly one job before this change: own-check-codescan, which calls itself dog-food in its own name. It now builds the production own-cli, records the candidate's sha256 and byte length, and passes engine: rust. The Action's public default is untouched and still exercised bare by own-check-surface and by the marketplace consumer simulation, so switching the dogfood deleted no public-contract evidence.

stage2-dogfood is added because codescan is ubuntu-only and uploads a single code-scanning category, so it cannot carry Windows — and the two launcher surfaces differ in exactly the mechanics that cost Stage 1 six CI rounds. It runs this repository's own tree through own-check.sh on both platforms and through own-check.ps1 on Windows, then breaks the candidate on purpose to show the run fails visibly rather than being rescued by Python.

Nine controls, positive and negative

ok[stage2-census]              21 call sites, 5 workflows, all classified (A=8 B=6 C=5 D=2)
ok[internal-default-not-rust]  both Class-D sites select Rust explicitly, with a candidate
ok[public-default-moved]       action.yml + EngineSelection still python; a BARE run ignores
                               an unusable Rust candidate entirely
ok[rust-job-falls-back]        no Class-D step swallows its exit code; a forced Rust failure
                               produces no verdict
ok[wrong-rust-candidate]       every Class-D job builds the production own-cli and records
                               which binary ran
ok[locator-contract-bypassed]  no PATH / target-dir discovery; the locator is OWEN_RUST_CORE
ok[python-reference-lost]      6 reference call sites remain; --engine python still answers
ok[compare-gate-dropped]       all 5 compare gates present and enabled
ok[platform-leg-lost]          linux: own-check-codescan, stage2-dogfood; windows: stage2-dogfood

The public-default control is a falsifier, not a grep. It runs a bare invocation with OWEN_RUST_CORE pointing at something that cannot work: if the default had moved, the run would die on the locator; because it has not, the run ignores the candidate and produces a verdict. Written that way after the first version failed for the wrong reason — it assumed own-check.sh honours OWEN_PYTHON, and it does not. That asymmetry between the launcher surfaces is recorded in the control rather than papered over.

The relabel escape is closed: every other rule is satisfied by moving a job out of Class D, so a job that calls itself dog-food is Class D by declaration and the ledger does not get to disagree with the workflow about what the job is for.

Mutation campaign — and the survivor that mattered

p022-stage2-1 @ 345cf04fe9f9, dirty:false: 11/11 caught, 0 survived, 0 compile-error, 0 invalid, 0 runner-error, every expected catcher hit, M00 surviving the unmutated tree.

The first run was 9/10, and the survivor was worth more than the nine kills. S03 deletes windows-latest from the dogfood matrix — the exact regression platform-leg-lost exists to catch — and survived because the control asked whether the string windows-latest appeared anywhere in the job. After the deletion it still does, in every if: matrix.os == 'windows-latest' step guard left behind. A job that could no longer run on Windows still looked like one that did.

Same defect class as the Stage-1 shell locator whose bracket matched nothing, and as the agreement control whose stderr assertion was switched off by empty data: a validator reading a proxy instead of the thing. The control now reads each Class-D job's actual runner images and names them. Parsed rather than yaml.safe_loaded, because PyYAML is not a declared dependency here and a control that only runs where an undeclared package happens to be installed is the same kind of accident. Every other text-scanning control now reads a comment-stripped view, and S11 is the mutant for that: the engine selection commented out rather than deleted, which is the shape a temporary rollback actually takes.

The platform claim is measured, not argued

The recorded run is Linux. Every mutation edits declarative text and every catcher reads that text, so the verdicts should not differ by platform — and "should not differ" is the reasoning that failed here, twice over.

A second Windows-only defect, in the harness rather than in a mutant. enumerate_call_sites built its ledger key with str(path.relative_to(ROOT)). On Windows that is .github\workflows\ci.yml while the committed ledger stores forward slashes, so every key missed: the census read all twenty call sites as unclassified and all twenty ledger entries as stale, and two more controls failed behind it. Green on Linux, red on Windows, on the control whose entire job is to be the denominator. This repository has paid for a host separator once already — #260's mutation harness took it into a catcher name and reported five protected rules as unprotected. Keys are POSIX by definition now.

No Linux campaign could have reported that, so the campaign verdict is now measured on both platforms by a stage2-windows-mutations gate. Windows agrees: all 11 caught, each by the catcher its definition names. The gate records nothing — one campaign has one recorded provenance, and a second file claiming the same campaign name would make "which tree was measured" ambiguous — and it asserts that it left nothing behind rather than merely tidying up.

Как проверено

  • python tests/run_tests.py — rc 0; stage-2 dogfood controls 9/9, 0 skipped
  • ruff check . — All checks passed; mypy --strict — Success
  • python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage2-1.json --run — 11/11
  • python scripts/render_checkpoint_status.py --check — fragments in sync with the evidence
  • the Rust dogfood, measured: 137 OWN001 at exit 1 over this repository's own tree; SARIF 2.1.0 with 165 results at --severity warning; a broken candidate → exit 2, no verdict

Связанные issue

Refs #262 (Stage 2 of four; #262 stays open through Stage 4), #261, #260. Closes nothing.

Чеклист

  • изменение покрыто тестом/селфтестом — nine adversarial controls plus an 11-mutation campaign proving they are load-bearing
  • README/docs обновлены — P-022 row 8, the preferred queue and docs/proposals/README.md move together, with the qualifier: repository CI/dogfood only, Python remains the public engine and reference
  • коммиты в conventional-commit стиле

Measured on CI at this head

evidence value
dogfood candidate, ubuntu a39b3eab6505b70a…, 1 899 136 bytes
dogfood candidate, windows 11ab5be26143d1a8…, 1 660 416 bytes
dogfood, ubuntu OK: Rust-default dogfood on ubuntu-latest (own-check.sh)
dogfood, windows OK through both own-check.sh and own-check.ps1
no fallback, both platforms OK: a broken candidate is a visible failure (exit 2), never a Python rescue
codescan on Rust the Action wrote 178 354 bytes of SARIF and uploaded it
Stage-2 controls 9/9, 0 skipped, on both ubuntu and windows
Windows campaign verdict all 11 caught, recorded nothing

Findings recorded rather than fixed

  1. own-check.sh has no OWEN_PYTHON override. The C# launcher does; the shell surface calls a bare python. A real asymmetry between two public surfaces, out of Stage-2 scope, recorded in the control that tripped over it.
  2. A mutation campaign restores the mutated source but not the rebuilt binary. The full local suite failed once because the launcher DLL on disk was still the last mutant's build. Harmless inside a campaign — the layer rebuilds before testing — but it silently poisons any ad-hoc run afterwards.
  3. The Owen code-scanning check reports neutral — "1 configuration not found". It is GitHub's code-scanning tool status, not a CI failure, and it reads the same way on main. Named here so a reviewer does not have to wonder why the head is not uniformly green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb


Generated by Claude Code


Generated by Claude Code

… does not

#262 Stage 2 is deliberately thin. Stage 1 proved the Rust core works behind
the launcher; Stage 2 proves only that Own.NET can RUN on it internally, and
that doing so moved nothing a user can see.

What did NOT change, because changing it would be Stage 3 wearing a disguise:
EngineSelection.Default is still Engine.Python, action.yml's engine input still
defaults to python, and a bare `owen` / `own-check.sh` / `own-check.ps1` /
Action invocation still resolves Python. Stage 2 is not implemented by moving a
default — it is implemented by every internal dogfood call site ASKING for
Rust, which is why the ask is written at each call site where a control can
read it back.

The census, because the claim needs a denominator. "Own.NET CI is Rust-default"
is unfalsifiable if it means "we found one job that runs Rust", so
docs/evidence/p022-stage2-census.json names EVERY call site in every workflow
that executes the analysis core or a production launcher surface — twenty of
them across five workflows — and the role each plays: 7 public-contract
verifiers that stay Python-default on purpose, 6 explicit references, 5 compare
gates, 2 operational dogfood. tests/test_stage2_dogfood.py enumerates the
workflows itself and fails in BOTH directions: an unclassified call site, and a
ledger entry for a job that no longer invokes anything. A census that can be
satisfied by editing prose is not a census.

Class D — the Rust-default population — was exactly one job before this change:
own-check-codescan, which calls itself dog-food in its own name. It now builds
the production own-cli, records the candidate's sha256 and byte length, and
passes `engine: rust`. The Action's own public default is untouched and is
still exercised bare by own-check-surface and by the marketplace consumer
simulation, so switching the dogfood deleted no public-contract evidence.

stage2-dogfood is added because codescan is ubuntu-only and uploads a single
code-scanning category, so it cannot carry Windows — and the two launcher
surfaces differ in exactly the mechanics that cost Stage 1 six CI rounds. It
runs this repository's own tree through own-check.sh on both platforms and
through own-check.ps1 on Windows, and then breaks the candidate on purpose to
show the run fails visibly rather than being rescued by Python.

Nine controls, positive and negative: the census; every Class-D site selects
Rust explicitly with a candidate; the public defaults have not moved; a forced
Rust failure is never rescued; the candidate is the production own-cli and the
job says which one; no PATH or target-dir discovery; the reference paths still
exist and `--engine python` still answers; no compare gate was traded for Rust
exposure; the claim covers Linux AND Windows.

The public-default control is a falsifier, not a grep. It runs a BARE
invocation with OWEN_RUST_CORE pointing at something that cannot work: if the
default had moved, the run would die on the locator; because it has not, the
run ignores the candidate entirely and produces a verdict. Written that way
after the first version failed for the wrong reason — it assumed own-check.sh
honours OWEN_PYTHON, and it does not, which is a real asymmetry between the
launcher surfaces and is recorded in the control rather than papered over.

And the relabel escape is closed: every other rule is satisfied by moving a job
OUT of Class D, so a job that calls itself dog-food is Class D by declaration
and the ledger does not get to disagree with the workflow about what the job is
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
…urvived

The first campaign run was 9/10, and the survivor is the interesting one.

S03 deletes `windows-latest` from the Rust-default dogfood matrix, which is
exactly the regression `platform-leg-lost` exists to catch. It survived,
because the control asked whether the string `windows-latest` appeared
anywhere in the job — and after the deletion it still does, in every
`if: matrix.os == 'windows-latest'` step guard left behind. A job that could no
longer run on Windows still looked like one that did.

That is the same defect class as the Stage-1 shell locator whose bracket
matched nothing, and as the agreement control whose stderr assertion was
switched off by empty data: a validator that reads a proxy for the thing
instead of the thing. So the control now reads the job's ACTUAL runner images
— a literal `runs-on`, or `runs-on: ${{ matrix.os }}` resolved through
`strategy.matrix.os` in either its flow or block form — and reports them by
name. Verified against the mutant before recording anything: with S03 applied
it fails and names the two Linux legs it could still see.

Parsed rather than loaded, because PyYAML is not a declared dependency of this
repository and a control that only runs where an undeclared package happens to
be installed is the same kind of accident.

The same hole existed by construction in every other control that scanned a
job as text, so they now read a comment-stripped view: a mutation that comments
a line out must not leave the words behind for a grep to find. S11 is that
mutant — the engine selection commented out rather than deleted, which is the
shape a temporary rollback actually takes — and its twin S01 still deletes the
line, so both directions are covered.

The earlier result is deleted rather than kept: it measured a control that has
since changed, and evidence that describes a tree nobody can check out is not
evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
p022-stage2-1 @ e02e209, dirty:false — 11/11 caught, 0 survived,
0 compile-error, 0 invalid, 0 runner-error, every expected catcher hit, and M00
surviving the unmutated tree.

  S01, S11 -> stage2::internal-default-not-rust   engine selection deleted / commented out
  S02      -> stage2::wrong-rust-candidate        candidate becomes the #260 dev adapter
  S03      -> stage2::platform-leg-lost           the matrix loses its Windows leg
  S04      -> stage2::locator-contract-bypassed   the candidate is found on PATH
  S05      -> stage2::rust-job-falls-back         the dogfood swallows its exit code
  S06, S07 -> stage2::public-default-moved        the product / the Action defaults to Rust
  S08      -> stage2::compare-gate-dropped        a compare gate is disabled
  S09, S10 -> stage2::stage2-census               a bare call site appears / a job is relabelled

One layer on Linux, and that is a claim rather than a convenience. Every
mutation here edits DECLARATIVE text — a workflow key, a ledger entry, an
action input, one C# constant — and every catcher reads that text, so the
verdict is identical on either platform. This is not the Stage-1 situation,
where mutants of own-check.ps1 changed PowerShell semantics that only a Windows
runtime can evaluate; those keep their Windows-native campaign and this one
does not inherit their requirement. The one mutation with a platform dimension,
S03, DELETES the windows-latest leg — and deleting it is precisely what a
Windows runner could not have reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 40628603-2456-421d-a87b-33234322331e


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.

…Windows saw it

`enumerate_call_sites` built its ledger key with `str(path.relative_to(ROOT))`.
On Windows that is `.github\workflows\ci.yml`; the committed ledger stores
forward slashes. Every key missed, so the census read all twenty call sites as
unclassified AND all twenty ledger entries as stale, and `python-reference-lost`
and `compare-gate-dropped` failed behind it for the same reason. Green on
Linux, red on Windows, on a control whose entire job is to be the denominator.

This repository has paid for a host separator once already: #260's mutation
harness took it into a catcher NAME and reported five protected rules as
unprotected. The ledger is a committed artefact shared by both platforms, so
its keys are POSIX by definition and are now built that way.

What the defect actually costs is the reasoning around it. The campaign's own
note said one Linux layer sufficed because every mutant edits declarative text
and every catcher reads that text, so the verdict "is identical on either
platform". That argument was wrong in the only way that matters — not about a
mutant, about the harness underneath every mutant — and no Linux campaign could
have reported it. So the verdict is now MEASURED on both platforms by a
`stage2-windows-mutations` gate rather than argued.

The gate records nothing. One campaign has one recorded provenance, and it
stays the Linux run; a second file claiming the same campaign name would make
"which tree was measured" ambiguous, which is the failure the Stage-1 evidence
work already fixed once. It only fails if Windows disagrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
… 11/11

p022-stage2-1 @ 30a4918, dirty:false — 11/11 caught, 0 survived,
0 compile-error, 0 invalid, 0 runner-error, every expected catcher hit, M00
surviving the unmutated tree.

Re-recorded because the previous run measured a census whose ledger key took
the host's path separator, and because S11 changed the definition's hash. A
result that names a tree nobody would want to check out is not evidence,
whatever its counts say.

The Windows verdict on the same eleven mutations is a CI gate
(stage2-windows-mutations) rather than a second recorded file: one campaign has
one recorded provenance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
… agreed

The gate did its job — "Windows agrees: all 11 mutations caught, each by the
catcher its definition names" — and then failed the step after it, on

    git checkout -- docs/evidence/p022-stage2-1.result.json

because at that commit the recorded result was not tracked yet, and `git
checkout --` on an untracked pathspec is an error rather than a no-op. A job
whose measurement had already succeeded reported red for a housekeeping line.

Restore-or-remove now, so the step is correct whether or not the file is
tracked at the commit under test, and the cleanup is turned into the assertion
it was always meant to be: the step fails if this gate left a recorded result
behind. That is the invariant worth having — one campaign, one recorded
provenance — and it was previously implied by a command rather than checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
p022-stage2-1 @ 345cf04, dirty:false — 11/11 caught, 0 survived,
0 compile-error, 0 invalid, 0 runner-error, every expected catcher hit, M00
surviving the unmutated tree.

Re-recorded because the previous run named 30a4918, and the gate-cleanup fix
after it touched .github/workflows/ci.yml — which is a mutation TARGET here.
Evidence must name the tree that ships, not the one it was convenient to
measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
@PhysShell
PhysShell merged commit ca8b106 into main Sep 9, 2026
62 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.

2 participants