Skip to content

Commit e3628e0

Browse files
committed
feat(ci): Stage 2 — this repository runs on Rust; the public contract 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
1 parent ba1d4cf commit e3628e0

8 files changed

Lines changed: 1130 additions & 6 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ jobs:
196196
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
197197
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext"
198198
python tests/test_stage1_ps1.py
199+
# P-022 Stage 2 (#262): the CI/dogfood census and the Rust-default
200+
# controls. They live here because this is the job that already has the
201+
# candidate, the launcher and both platforms — and REQUIRE=1 because a
202+
# census that skips is a census that measured nothing.
203+
- name: Stage-2 dogfood controls (census + Rust-default + public contract)
204+
env:
205+
OWEN_STAGE2_REQUIRE: "1"
206+
run: |
207+
ext=""
208+
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
209+
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
210+
export OWEN_STAGE1_LAUNCHER_DLL="$PWD/frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0/ownsharp.dll"
211+
python tests/test_stage2_dogfood.py
199212
# The explicit Rust-selected run on this platform, through the shell
200213
# launcher, recorded as its own step so the evidence names the surface
201214
# and the platform rather than being inferred from a green job.
@@ -2636,8 +2649,18 @@ jobs:
26362649
# consumer-facing payoff the exporter was built for. The samples are intentional
26372650
# leak fixtures, so the alerts are real-if-intentional; a dedicated
26382651
# `own-net-samples` category keeps them from colliding with anything else.
2652+
# P-022 step 8 (#262) STAGE 2. This is the repository's dog-food — Own.NET
2653+
# analysing its own tree and publishing the result to its own code scanning —
2654+
# so under Stage 2 it runs on the RUST core, selected explicitly.
2655+
#
2656+
# Explicitly, and that is the whole design. The product default stays Python
2657+
# (Stage 3 is a separate authorization), so a job that asks for nothing gets
2658+
# Python; "the dogfood is Rust-default" therefore has to be written at the
2659+
# call site, where tests/test_stage2_dogfood.py can read it back. The Action's
2660+
# own public default is unaffected and is still exercised bare by
2661+
# own-check-surface and by the marketplace consumer simulation.
26392662
own-check-codescan:
2640-
name: own-check SARIF -> GitHub code scanning (dog-food)
2663+
name: own-check SARIF -> GitHub code scanning (Rust dog-food)
26412664
runs-on: ubuntu-latest
26422665
# Skip on fork PRs: GitHub downgrades GITHUB_TOKEN to read-only for a
26432666
# pull_request from a fork, so security-events:write is never granted and the
@@ -2649,14 +2672,36 @@ jobs:
26492672
permissions:
26502673
contents: read
26512674
security-events: write
2675+
# The candidate reaches the composite action through the environment, and
2676+
# job level rather than step level because that is the inheritance a
2677+
# composite action's own steps can be relied on to see.
2678+
env:
2679+
OWEN_RUST_CORE: ${{ github.workspace }}/rust/target/release/own-cli
26522680
steps:
26532681
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2654-
- name: Own.NET leak check (SARIF surface)
2682+
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
2683+
with:
2684+
toolchain: stable
2685+
# The PRODUCTION binary, built here, from this commit. Never
2686+
# own-shadow-engine (the #260 dev adapter) and never the Stage-1 test
2687+
# stub: a dogfood run through an instrument proves nothing about the
2688+
# thing being dogfooded.
2689+
- name: Build the production own-cli candidate
2690+
working-directory: rust
2691+
run: cargo build -p own-cli --release
2692+
- name: Record which candidate ran
2693+
run: |
2694+
test -x "$OWEN_RUST_CORE" || { echo "FAIL: no candidate at $OWEN_RUST_CORE"; exit 1; }
2695+
echo "candidate: $OWEN_RUST_CORE"
2696+
echo "sha256: $(sha256sum "$OWEN_RUST_CORE" | cut -d' ' -f1)"
2697+
echo "bytes: $(wc -c < "$OWEN_RUST_CORE")"
2698+
- name: Own.NET leak check (SARIF surface, Rust engine)
26552699
id: own
26562700
uses: ./
26572701
with:
26582702
path: frontend/roslyn/samples
26592703
format: sarif
2704+
engine: rust # STAGE 2: the dogfood runs on the Rust core
26602705
severity: warning # include the injected-source (warning-tier) leaks
26612706
fail-on-finding: "false" # let code scanning be the gate, not the step
26622707
- name: The action exposes the SARIF path
@@ -2671,6 +2716,105 @@ jobs:
26712716
sarif_file: ${{ steps.own.outputs.sarif-file }}
26722717
category: own-net-samples
26732718

2719+
# P-022 step 8 (#262) STAGE 2 — the platform half of the Rust-default claim.
2720+
#
2721+
# own-check-codescan is the dogfood of record, but it is ubuntu-only and
2722+
# uploads a single code-scanning category, so it cannot carry Windows. The
2723+
# two launcher surfaces differ in exactly the mechanics that cost Stage 1 six
2724+
# CI rounds — process launch, executable bits, path forms, stream capture —
2725+
# so a Linux-only "our CI runs on Rust" is a claim about half the product.
2726+
#
2727+
# This job is deliberately NOT a contract test. stage1-engine already proves
2728+
# the engine contract; this one only asks the operational question: does this
2729+
# repository's own tree analyse correctly, through the shipped launchers,
2730+
# with the Rust core explicitly selected, on both platforms.
2731+
stage2-dogfood:
2732+
name: Rust-default dogfood (Own.NET's own tree, via own-cli)
2733+
strategy:
2734+
fail-fast: false
2735+
matrix:
2736+
os: [ubuntu-latest, windows-latest]
2737+
runs-on: ${{ matrix.os }}
2738+
defaults:
2739+
run:
2740+
shell: bash
2741+
steps:
2742+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2743+
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
2744+
with:
2745+
dotnet-version: "8.0.x"
2746+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
2747+
with:
2748+
python-version: "3.13"
2749+
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
2750+
with:
2751+
toolchain: stable
2752+
- name: Build the production own-cli candidate
2753+
working-directory: rust
2754+
run: cargo build -p own-cli --release
2755+
- name: Record which candidate ran
2756+
run: |
2757+
ext=""
2758+
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
2759+
core="$PWD/rust/target/release/own-cli$ext"
2760+
test -f "$core" || { echo "FAIL: no candidate at $core"; exit 1; }
2761+
echo "candidate: $core"
2762+
echo "sha256: $(sha256sum "$core" | cut -d' ' -f1)"
2763+
echo "bytes: $(wc -c < "$core")"
2764+
# The operational run. --fail-on-finding is deliberate: the dogfood tree
2765+
# HAS a leak, so exit 1 is the correct answer and exit 0 would mean the
2766+
# Rust core analysed nothing and said so quietly.
2767+
- name: Own.NET's own tree, through own-check.sh on the Rust core
2768+
run: |
2769+
ext=""
2770+
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
2771+
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
2772+
set +e
2773+
out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding \
2774+
-- frontend/roslyn/samples)
2775+
rc=$?
2776+
set -e
2777+
echo "$out"
2778+
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings), got $rc"; exit 1; }
2779+
case "$out" in *OWN001*) ;; *) echo "FAIL: the Rust core found no OWN001"; exit 1 ;; esac
2780+
echo "OK: Rust-default dogfood on ${{ matrix.os }} (own-check.sh)"
2781+
# The Windows launcher is a separate implementation, not a wrapper around
2782+
# the shell one, so the Windows leg has to go through it to mean anything.
2783+
- name: Own.NET's own tree, through own-check.ps1 on the Rust core
2784+
if: matrix.os == 'windows-latest'
2785+
shell: pwsh
2786+
run: |
2787+
$env:OWEN_RUST_CORE = "$PWD/rust/target/release/own-cli.exe"
2788+
$out = & ./scripts/own-check.ps1 -Engine rust -Format human -FailOnFinding `
2789+
-Paths frontend/roslyn/samples
2790+
$rc = $LASTEXITCODE
2791+
$out | Write-Host
2792+
if ($rc -ne 1) { throw "expected exit 1 (findings), got $rc" }
2793+
if ($out -notmatch 'OWN001') { throw "the Rust core found no OWN001" }
2794+
Write-Host "OK: Rust-default dogfood on windows (own-check.ps1)"
2795+
exit 0
2796+
# No fallback, measured rather than asserted: with the candidate broken,
2797+
# the dogfood must fail visibly. A run that quietly produced a verdict
2798+
# here would mean Python had answered for Rust, and every green Rust
2799+
# dogfood above would be worth nothing.
2800+
- name: A broken candidate fails the dogfood instead of being rescued
2801+
run: |
2802+
broken="$RUNNER_TEMP/not-a-core"
2803+
printf 'this is not an executable image\n' > "$broken"
2804+
export OWEN_RUST_CORE="$broken"
2805+
set +e
2806+
out=$(bash scripts/own-check.sh --engine rust --format human \
2807+
-- frontend/roslyn/samples 2>&1)
2808+
rc=$?
2809+
set -e
2810+
case "$out" in
2811+
*OWN001*)
2812+
echo "FAIL: a verdict was produced with a broken candidate — Python answered for Rust"
2813+
exit 1 ;;
2814+
esac
2815+
[ "$rc" -ne 0 ] || { echo "FAIL: a broken candidate exited 0"; exit 1; }
2816+
echo "OK: a broken candidate is a visible failure (exit $rc), never a Python rescue"
2817+
26742818
# P-014 Tier B: external-reference resolution. The SAME sample, run two ways, must give two
26752819
# verdicts — proving the extractor binds a THIRD-PARTY event only when its DLL is referenced:
26762820
# A (no refs) -> ObservableObject is an error type -> OWN050 (honest skip), no leak

‎docs/evidence/p022-stage2-1.json‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
{
2+
"schema": 1,
3+
"comment": "GENERATED-BY-HAND definition; the RESULT beside it is recorded by scripts/mutate_campaign.py --run and the counts are derived from it, never typed.",
4+
"campaign": "p022-stage2-1",
5+
"description": "#262 Stage 2 — the CI/dogfood ENGINE-SELECTION seam. Stage 2's deliverable is a configuration, so its mutants are configuration: each one is a plausible way the Rust-default claim could quietly stop being true, or the public contract could quietly start moving, while every job still went green. The catchers are the controls in tests/test_stage2_dogfood.py, which read the workflows rather than being told about them.",
6+
"layers_comment": "One layer, on Linux, and that is a claim rather than a convenience. Every mutation here targets DECLARATIVE text — a workflow key, a ledger entry, a C# constant, an action input — and every catcher reads that text, so the verdict is identical on either platform. This is not the Stage-1 situation, where mutants of scripts/own-check.ps1 changed PowerShell SEMANTICS that only a Windows runtime can evaluate; those keep their Windows-native campaign (p022-stage1-windows) and this one does not inherit their requirement. The one Stage-2 mutation with a platform dimension, S03, deletes the windows-latest leg from the matrix — and deleting it is exactly what a Windows runner could not report.",
7+
"layers": [
8+
{
9+
"id": "stage2",
10+
"cwd": ".",
11+
"parser": "python-fail",
12+
"command": [
13+
"python",
14+
"tests/test_stage2_dogfood.py"
15+
],
16+
"env": {
17+
"OWEN_STAGE2_REQUIRE": "1"
18+
}
19+
}
20+
],
21+
"control": {
22+
"id": "M00",
23+
"description": "harness-honesty control: no mutation at all, which must report zero failing layers"
24+
},
25+
"mutations": [
26+
{
27+
"id": "S01",
28+
"rule": "dogfood-selects-rust-explicitly",
29+
"description": "the dog-food job stops asking for an engine — the most likely regression of all, because the job still runs, still analyses, still uploads, and silently goes back to the public Python default",
30+
"target": ".github/workflows/ci.yml",
31+
"pattern": " engine: rust # STAGE 2: the dogfood runs on the Rust core\n",
32+
"replacement": "",
33+
"expected_catchers": [
34+
"stage2::internal-default-not-rust"
35+
]
36+
},
37+
{
38+
"id": "S02",
39+
"rule": "dogfood-runs-the-production-core",
40+
"description": "the dog-food candidate becomes own-shadow-engine — the #260 dev adapter is a Rust binary that this repository really builds, so the substitution looks right and measures a thing that is not the product",
41+
"target": ".github/workflows/ci.yml",
42+
"pattern": " OWEN_RUST_CORE: \\$\\{\\{ github\\.workspace \\}\\}/rust/target/release/own-cli",
43+
"replacement": " OWEN_RUST_CORE: ${{ github.workspace }}/rust/target/release/own-shadow-engine",
44+
"expected_catchers": [
45+
"stage2::wrong-rust-candidate"
46+
]
47+
},
48+
{
49+
"id": "S03",
50+
"rule": "the-claim-covers-both-platforms",
51+
"description": "the Rust-default dogfood matrix loses its Windows leg — 'our CI runs on Rust' quietly becomes a statement about Linux, which is the half of the product whose launcher mechanics never broke",
52+
"target": ".github/workflows/ci.yml",
53+
"pattern": " name: Rust-default dogfood \\(Own\\.NET's own tree, via own-cli\\)\n strategy:\n fail-fast: false\n matrix:\n os: \\[ubuntu-latest, windows-latest\\]",
54+
"replacement": " name: Rust-default dogfood (Own.NET's own tree, via own-cli)\n strategy:\n fail-fast: false\n matrix:\n os: [ubuntu-latest]",
55+
"expected_catchers": [
56+
"stage2::platform-leg-lost"
57+
]
58+
},
59+
{
60+
"id": "S04",
61+
"rule": "the-locator-is-owen-rust-core-alone",
62+
"description": "the dogfood finds its candidate on PATH — 'the binary is right there, why spell out an absolute path', which is precisely how a stale binary stands in for the one under test",
63+
"target": ".github/workflows/ci.yml",
64+
"pattern": " core=\"\\$PWD/rust/target/release/own-cli\\$ext\"",
65+
"replacement": " core=$(which own-cli || echo \"$PWD/rust/target/release/own-cli$ext\")",
66+
"expected_catchers": [
67+
"stage2::locator-contract-bypassed"
68+
]
69+
},
70+
{
71+
"id": "S05",
72+
"rule": "a-rust-failure-is-visible",
73+
"description": "the dogfood run swallows its exit code — the classic 'don't let the dogfood job break the build', which turns every Rust-default claim in this PR into decoration",
74+
"target": ".github/workflows/ci.yml",
75+
"pattern": " out=\\$\\(bash scripts/own-check\\.sh --engine rust --format human --fail-on-finding \\\\\n -- frontend/roslyn/samples\\)",
76+
"replacement": " out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding -- frontend/roslyn/samples || true)",
77+
"expected_catchers": [
78+
"stage2::rust-job-falls-back"
79+
]
80+
},
81+
{
82+
"id": "S06",
83+
"rule": "the-public-default-does-not-move",
84+
"description": "the PRODUCT default flips to Rust — the covert Stage 3, and the single edit that would make every internal Rust job pass by accident while changing what every user gets",
85+
"target": "frontend/roslyn/OwnSharp.Cli/EngineSelection.cs",
86+
"pattern": "public const Engine Default = Engine\\.Python;",
87+
"replacement": "public const Engine Default = Engine.Rust;",
88+
"expected_catchers": [
89+
"stage2::public-default-moved"
90+
]
91+
},
92+
{
93+
"id": "S07",
94+
"rule": "the-public-default-does-not-move",
95+
"description": "the ACTION's public engine input defaults to rust — the same cutover through the other public door, and the one a C#-only control would miss",
96+
"target": "action.yml",
97+
"pattern": " required: false\n default: \"python\"",
98+
"replacement": " required: false\n default: \"rust\"",
99+
"expected_catchers": [
100+
"stage2::public-default-moved"
101+
]
102+
},
103+
{
104+
"id": "S08",
105+
"rule": "compare-evidence-is-not-traded-away",
106+
"description": "a #260 compare gate is disabled — the cheapest way to make a Rust-default dogfood green is to remove the job that would have disagreed with it",
107+
"target": ".github/workflows/ci.yml",
108+
"pattern": " shadow-compare:\n name: shadow compare \\(committed corpus\\)\n",
109+
"replacement": " shadow-compare:\n name: shadow compare (committed corpus)\n if: false\n",
110+
"expected_catchers": [
111+
"stage2::compare-gate-dropped"
112+
]
113+
},
114+
{
115+
"id": "S09",
116+
"rule": "no-unclassified-call-site",
117+
"description": "a new bare launcher invocation appears in a job nobody classified — the hole the census exists to close, and the one that reopens every time somebody adds a convenient scan step",
118+
"target": ".github/workflows/ci.yml",
119+
"pattern": " - name: Check the emitted method is still in sync with the golden host\n",
120+
"replacement": " - name: Check the emitted method is still in sync with the golden host\n run: scripts/own-check.sh --format human -- frontend/roslyn/samples\n",
121+
"expected_catchers": [
122+
"stage2::stage2-census"
123+
]
124+
},
125+
{
126+
"id": "S10",
127+
"rule": "no-escape-by-relabelling",
128+
"description": "the dog-food job is reclassified out of the Rust-default population — every other rule is satisfied by shrinking Class D, so without a rule that reads the job's own name this is a green way to stop dogfooding",
129+
"target": "docs/evidence/p022-stage2-census.json",
130+
"pattern": " \"job\": \"own-check-codescan\",\n \"class\": \"D\",",
131+
"replacement": " \"job\": \"own-check-codescan\",\n \"class\": \"A\",",
132+
"expected_catchers": [
133+
"stage2::stage2-census"
134+
]
135+
}
136+
]
137+
}

0 commit comments

Comments
 (0)