Skip to content

Commit 0d6686e

Browse files
committed
P-022 Stage 3: harden ACTION-BUILD for reproducibility, and record the ruling
OWNER RULING: ACTION-BUILD is ACCEPTED for Stage 3 as a declared TEMPORARY distribution cost -- not a parity difference, not a semantic difference, not a blocker. The contract is intact: the default is rust, Python stays the explicit rollback until Stage 4, the Rust toolchain is installed by the Action rather than assumed, and what is built is the production own-cli crate, never own-shadow-engine or a test adapter. It is simply a heavy way to deliver a binary -- a consumer today pays setup-python, setup-dotnet, setup-rust and a cargo build to run a static analyzer. EXIT CONDITION: the first published own-cli/Owen.Cli artifact, after which the Action downloads an immutable platform binary. That is a post-Stage-3 packaging follow-up and deliberately NOT Stage 4, which is about the Python distribution dependency. Accepted, but the build had to be made to mean one thing over time. Three of its four inputs were pinned and the fourth was not: source pinned by the action ref the caller writes dependencies pinned by rust/Cargo.lock ... only with --locked, which was absent actions pinned by SHA rustc FLOATING on `stable` A caller who pins PhysShell/Own.NET@<tag> is entitled to have that tag mean one thing. With a moving channel the same tag builds with whatever rustc shipped that month, so a future release that compiled the crate differently -- or refused it -- would change the behaviour of a revision nobody touched. rustc is now pinned to 1.98.1, the concrete stable the qualification CI already runs, and the build is `--locked` so an unchanged source revision cannot silently resolve a different dependency graph. Verified: the locked build succeeds on this tree. The cache-key comment claimed more than the key delivers, and the claim was worth more than the key. It said a caller who bumps the pinned version gets a rebuild rather than a stale binary; `github.action_ref` is whatever the caller wrote, so a moving major tag like `v1` keeps ONE key across every commit it ever points at. It is not a correctness bug -- Cargo rebuilds a local crate whose sources changed, cache hit or not -- so the claim is withdrawn rather than propped up with a digest-identity gate nobody needs: the cache is an optimization and the Cargo build is authoritative. Both properties are asserted, not just commented: tests/test_stage3_surfaces.py refuses a floating channel and a build without --locked, and both are mutation-proved. That control also learned, for the third time in this file, that comments are not code: the prose justifying these pins contains the phrase "cargo build" and was duly reported as an unlocked build. Evidence recorded with it: `packaging-windows` moves from OWED to MEASURED. The `owen CLI (gate A)` job on windows-latest concluded success at 2ecf501 carrying the unconditional Stage-3 assertion that a bare `owen check` with an unusable OWEN_PYTHON still finds OWN001 -- under `bash -e` with an explicit `exit 1`, so success is that step passing. Nothing is carried over from Linux; that is windows-latest reporting on itself. A new row records what the same run proved about the Stage-1 controls there: 19 passed natively, including default-is-rust and an unexpected child status mapped to exit 5 with the raw Windows-native status retained. rollback-windows and cancellation-windows remain OWED. Refs #262 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01376o4syzCem5aQ6NssxwX2
1 parent 6ed880d commit 0d6686e

4 files changed

Lines changed: 123 additions & 20 deletions

File tree

‎action.yml‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,42 @@ runs:
111111
# for the runner's platform is strictly better. Recorded rather than
112112
# smoothed over, because the cost lands on users of this Action.
113113
#
114+
# OWNER RULING (#262 Stage 3): ACTION-BUILD is ACCEPTED for Stage 3 as a
115+
# DECLARED TEMPORARY DISTRIBUTION COST. It is not a parity difference, not a
116+
# semantic difference and not a Stage-3 blocker: the default is rust, Python
117+
# stays the explicit rollback until Stage 4, the Rust toolchain is installed
118+
# by this Action rather than assumed, and what is built is the production
119+
# `own-cli` crate -- never own-shadow-engine and never a test adapter. So it
120+
# is not an undeclared runtime dependency. It is simply a heavy way to
121+
# deliver a binary: a consumer today pays setup-python, setup-dotnet,
122+
# setup-rust and a cargo build to run a static analyzer.
123+
#
124+
# EXIT CONDITION: the first suitable published own-cli / Owen.Cli artifact.
125+
# At that point this Action downloads an immutable platform binary and the
126+
# consumer-side Rust build disappears. That is a separate post-Stage-3
127+
# packaging follow-up and deliberately NOT Stage 4, which is about removing
128+
# the Python distribution dependency -- a different question.
129+
#
114130
# Skipped entirely for `engine: python`, so the rollback path stays as
115131
# cheap as it was before Stage 3.
132+
# THE PINNED QUALIFICATION TOOLCHAIN. This is the canonical place it is
133+
# written down, and `tests/test_stage3_surfaces.py` refuses a floating
134+
# channel here.
135+
#
136+
# `stable` was wrong for a consumer-facing surface, and wrong in a way that
137+
# only shows up later: the action ref is pinned, the dependency graph is
138+
# pinned by rust/Cargo.lock, and the compiler was the one input left
139+
# drifting in time. A caller who pins PhysShell/Own.NET@<tag> is entitled to
140+
# have that tag mean one thing; with a moving channel the same tag would
141+
# build with whatever rustc shipped that month, and a future release that
142+
# compiled the crate differently -- or refused it -- would change the
143+
# behaviour of a revision nobody touched. For a migration cutover that is a
144+
# variable with no upside.
116145
- name: Set up Rust (Owen core)
117146
if: inputs.engine != 'python'
118147
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
119148
with:
120-
toolchain: stable
149+
toolchain: "1.98.1"
121150
- name: Cache the built Owen core
122151
if: inputs.engine != 'python'
123152
uses: actions/cache@v4
@@ -126,17 +155,28 @@ runs:
126155
${{ github.action_path }}/rust/target
127156
~/.cargo/registry
128157
~/.cargo/git
129-
# Keyed by the action's REF, so a caller who bumps the pinned version
130-
# gets a rebuild rather than a stale binary from the previous one --
131-
# the "a stale binary silently stands in" failure D3 exists to prevent.
158+
# The cache is an OPTIMIZATION; the Cargo build below is authoritative.
159+
#
160+
# An earlier comment here claimed this key gives a caller who bumps the
161+
# pinned version a rebuild rather than a stale binary. It does not, and
162+
# the claim was worth more than the key: `github.action_ref` is whatever
163+
# the caller wrote, so a moving major tag like `v1` keeps one key across
164+
# every commit it ever points at. What actually prevents a stale binary
165+
# is Cargo -- it rebuilds a local crate whose sources changed, cache hit
166+
# or not -- so the correctness argument belongs there and not here.
132167
key: owen-core-${{ runner.os }}-${{ github.action_ref || github.sha }}
133168
- name: Build the Owen core (own-cli)
134169
if: inputs.engine != 'python'
135170
shell: bash
136171
run: |
137172
set -euo pipefail
138173
cd "${{ github.action_path }}/rust"
139-
cargo build -p own-cli --release
174+
# --locked: rust/Cargo.lock is committed, so there is no reason for a
175+
# production build on a consumer's runner to be allowed to recompute the
176+
# dependency graph. Without it a yanked or newly published version can
177+
# change what gets built from an unchanged source revision, and the
178+
# build would succeed while quietly not being the qualified one.
179+
cargo build -p own-cli --release --locked
140180
ext=""
141181
if [ "$RUNNER_OS" = "Windows" ]; then ext=".exe"; fi
142182
core="$(cd target/release && pwd)/own-cli$ext"

‎docs/evidence/p022-stage3-cutover.json‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@
7777
"id": "packaging-windows",
7878
"question": "the same, natively on Windows",
7979
"platform": "windows",
80-
"label": "DEFERRED EVIDENCE",
81-
"method": "CI job ownsharp-cli-smoke (windows-latest), which now builds own-cli natively and packs it before installing",
82-
"result": "OWED — measured by CI, not predicted here"
80+
"label": "MEASURED OBSERVATION",
81+
"method": "CI job `owen CLI (gate A)` on windows-latest (run 35304488082, job 105473759598, at 2ecf501), which builds own-cli natively on that runner, packs it into the Owen.Cli nupkg, installs the PACKED artifact from an isolated feed and then runs it",
82+
"result": "PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job"
8383
},
8484
{
8585
"id": "cancellation-linux",
@@ -136,6 +136,14 @@
136136
"label": "MEASURED OBSERVATION",
137137
"method": "tests/test_stage3_surfaces.py over every workflow and script",
138138
"result": "63 invocations over 26 files; 23 explicit, 40 bare and all resolvable; 0 hollow Python injections"
139+
},
140+
{
141+
"id": "stage1-controls-windows",
142+
"question": "do the Stage-1 engine-seam controls hold natively on Windows after the cutover?",
143+
"platform": "windows",
144+
"label": "MEASURED OBSERVATION",
145+
"method": "CI job `owen --engine (#262 Stage 1 controls)` on windows-latest (run 35304488082, job 105473759377, at 2ecf501); https://github.com/PhysShell/Own.NET/actions/runs/35304488082",
146+
"result": "19 controls passed, 0 failed, 0 skipped, 1 not applicable (a non-executable candidate cannot be constructed for the shell surface under git-bash). Includes default-is-rust, unset-locator-is-d6, rust-failure-no-fallback and an unexpected child status mapped to public exit 5 with the raw WINDOWS-NATIVE status (-1073740791) retained. The job as a whole went red on a HARNESS defect in the Stage-3 rollback control that ran after these, not on any of them"
139147
}
140148
],
141149
"known_differences": [
@@ -166,8 +174,8 @@
166174
},
167175
{
168176
"id": "ACTION-BUILD",
169-
"status": "NEW, a cost rather than a difference",
170-
"summary": "The Owen Action builds the production own-cli from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Consumers pay a Rust toolchain and a first build (cached by action ref). Revisit the moment a release exists."
177+
"status": "OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost",
178+
"summary": "The Owen Action builds the production `own-cli` from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Ruled NOT a parity difference, NOT a semantic difference and NOT a Stage-3 blocker -- the default is rust, Python stays the explicit rollback until Stage 4, the Rust toolchain is installed by the Action rather than assumed, and what is built is the production crate, never own-shadow-engine or a test adapter. So it is not an undeclared runtime dependency; it is a heavy way to deliver a binary. A consumer today pays setup-python, setup-dotnet, setup-rust and a cargo build to run a static analyzer. EXIT CONDITION: the first suitable published own-cli/Owen.Cli artifact, at which point the Action downloads an immutable platform binary and the consumer-side Rust build disappears -- a separate post-Stage-3 packaging follow-up, deliberately NOT Stage 4, which is about removing the Python distribution dependency. HARDENED for reproducibility at the ruling: rustc pinned to the concrete qualification toolchain (1.98.1) instead of the moving `stable` channel, and `cargo build --locked` so an unchanged source revision cannot silently resolve a different dependency graph. The build cache is an optimization and the Cargo build is authoritative: an earlier comment claimed the cache key gives a caller who bumps a pinned version a rebuild, which it does not -- a moving major tag keeps one key across every commit it points at."
171179
}
172180
],
173181
"closed_in_this_change": [

‎docs/generated/p022-stage3-packet.md‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
# P-022 Stage 3 — #262 cutover decision packet
33

44
```text
5-
Stage-3 candidate SHA: 2ecf5017a89242dd64c6f420eedac6452b130ce0 (DIRTY TREE — not evidence)
5+
Stage-3 candidate SHA: 6ed880dd70564c9c20b23f4b53401ec9769fdaea (DIRTY TREE — not evidence)
66
Observation window: the repository's own CI on the candidate branch, plus the release workflow's packed-artifact smoke test; first run b590bf46e9f1: 7 job failures, every one a real consequence of the cutover, all diagnosed and fixed
77
Fast compare result: 104 documents, 104 agreed, 0 acceptance-unexplained | samples: 1 document, 1 agreed, 0 acceptance-unexplained
88
Five-repo compare result: 10/10 documents agreed over 6 targets; 0 acceptance-unexplained, 0 declared-boundary; at b590bf46e9f1; local run (no CI anchor)
99
Large-solution result: the largest .sln of every target that has one is inside the same run (4 solution documents); 0 diverged, 0 execution-failure
10-
Windows packaging result: OWED — measured by CI, not predicted here
10+
Windows packaging result: PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job
1111
Linux packaging result: bare `owen check` -> OWN001, rc 1, no Python present; --engine python -> same finding; packaged binary chmod 644 -> materialised to ~/.owen/rust-core/<sha256> and ran; packaged binary absent -> rc 2 denying a fallback
1212
Startup delta: DEFERRED BY OWNER — NOT MEASURED
1313
End-to-end delta: DEFERRED BY OWNER — NOT MEASURED
1414
Peak memory delta: DEFERRED BY OWNER — NOT MEASURED
15-
Known differences: WIN-ABC (carried forward from #262); CLI-B1 (carried forward); CLI-B2 (NEW); V4 (carried forward); CANCEL-TRACEBACK (NEW); ACTION-BUILD (NEW)
15+
Known differences: WIN-ABC (carried forward from #262); CLI-B1 (carried forward); CLI-B2 (NEW); V4 (carried forward); CANCEL-TRACEBACK (NEW); ACTION-BUILD (OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost)
1616
Rollback command/config: `--engine python` / `-Engine python` / `engine: python`; see `docs/notes/owen-engine-rollback.md`
1717
Python-removal timing: Stage 4 — a separate, separately reviewable PR, after the observation policy. NOT now.
1818
```
@@ -33,8 +33,8 @@ CLI contract campaign: 24/24 mutants caught at b5d9272a0a85
3333
str.isprintable() is answered from a version-dependent Unicode table. A representation/diagnostic boundary only, excluded from the byte-parity denominator and separately measured. Verified on this tree: the supported Python matrix is still 3.11/3.12/3.13 and the Rust snapshot is still unicode-properties 0.1.4.
3434
* **CANCEL-TRACEBACK** — _NEW, measured_
3535
On Linux an interrupted Python reference prints a KeyboardInterrupt traceback (1478 bytes measured) while the Rust core prints nothing. After the cutover a cancelled run therefore stops printing a stack trace. User-visible, caused by the cutover, and recorded rather than fixed: Stage 3 changes the default engine, not the reference.
36-
* **ACTION-BUILD** — _NEW, a cost rather than a difference_
37-
The Owen Action builds the production own-cli from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Consumers pay a Rust toolchain and a first build (cached by action ref). Revisit the moment a release exists.
36+
* **ACTION-BUILD** — _OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost_
37+
The Owen Action builds the production `own-cli` from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Ruled NOT a parity difference, NOT a semantic difference and NOT a Stage-3 blocker -- the default is rust, Python stays the explicit rollback until Stage 4, the Rust toolchain is installed by the Action rather than assumed, and what is built is the production crate, never own-shadow-engine or a test adapter. So it is not an undeclared runtime dependency; it is a heavy way to deliver a binary. A consumer today pays setup-python, setup-dotnet, setup-rust and a cargo build to run a static analyzer. EXIT CONDITION: the first suitable published own-cli/Owen.Cli artifact, at which point the Action downloads an immutable platform binary and the consumer-side Rust build disappears -- a separate post-Stage-3 packaging follow-up, deliberately NOT Stage 4, which is about removing the Python distribution dependency. HARDENED for reproducibility at the ruling: rustc pinned to the concrete qualification toolchain (1.98.1) instead of the moving `stable` channel, and `cargo build --locked` so an unchanged source revision cannot silently resolve a different dependency graph. The build cache is an optimization and the Cargo build is authoritative: an earlier comment claimed the cache key gives a caller who bumps a pinned version a rebuild, which it does not -- a moving major tag keeps one key across every commit it points at.
3838

3939
## Closed by this change (recorded as closures, not as differences)
4040

@@ -49,8 +49,6 @@ CLI contract campaign: 24/24 mutants caught at b5d9272a0a85
4949

5050
## Owed, and named rather than predicted
5151

52-
* **packaging-windows** (windows) — OWED — measured by CI, not predicted here
53-
via CI job ownsharp-cli-smoke (windows-latest), which now builds own-cli natively and packs it before installing
5452
* **cancellation-windows** (windows) — OWED — measured by CI. 130 is NOT invented, and the Linux disposition is NOT carried across
5553
via the same control on windows-latest, which sends CTRL_BREAK_EVENT (a parent cannot deliver Ctrl-C to a specific child group there) and records what it finds
5654
* **rollback-windows** (windows) — OWED — measured by CI
@@ -65,11 +63,12 @@ CLI contract campaign: 24/24 mutants caught at b5d9272a0a85
6563
* `cli-replay` [linux] **MEASURED OBSERVATION** — 94 frozen cases replayed byte-for-byte; 2 declared boundaries (CLI-B1, CLI-B2)
6664
* `hygiene-tails` [linux] **MEASURED OBSERVATION** — invalid UTF-8 rc 70 -> rc 2 (byte parity); V1 moved to the JSON door; V2 top-level -0 was ACCEPTED as v0 and is now refused; nested -0 unmoved
6765
* `packaging-linux` [linux] **MEASURED OBSERVATION** — bare `owen check` -> OWN001, rc 1, no Python present; --engine python -> same finding; packaged binary chmod 644 -> materialised to ~/.owen/rust-core/<sha256> and ran; packaged binary absent -> rc 2 denying a fallback
68-
* `packaging-windows` [windows] **DEFERRED EVIDENCE** — OWED — measured by CI, not predicted here
66+
* `packaging-windows` [windows] **MEASURED OBSERVATION** — PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job
6967
* `cancellation-linux` [linux] **MEASURED OBSERVATION** — python reference: SIGINT -> died by signal 2, stdout 0b, stderr 1478b (a KeyboardInterrupt traceback); rust own-cli: SIGINT -> died by signal 2, stdout 0b, stderr 0b. Neither has an exit code in that state; neither returned a verdict
7068
* `cancellation-windows` [windows] **DEFERRED EVIDENCE** — OWED — measured by CI. 130 is NOT invented, and the Linux disposition is NOT carried across
7169
* `rollback-linux` [linux] **MEASURED OBSERVATION** — all four held on both surfaces: default=Rust; explicit python agrees on the verdict; broken candidate with nothing asked -> rc 2 denying a fallback; broken candidate with python asked -> python runs
7270
* `rollback-windows` [windows] **DEFERRED EVIDENCE** — OWED — measured by CI
7371
* `stage1-controls` [linux] **MEASURED OBSERVATION** — 19 controls passed, 0 failed, 0 skipped — including default-is-rust and unset-locator-is-d6
7472
* `stage2-controls` [linux] **MEASURED OBSERVATION** — 9 controls passed, including public-default-is-rust
7573
* `ci-surfaces` [n/a] **MEASURED OBSERVATION** — 63 invocations over 26 files; 23 explicit, 40 bare and all resolvable; 0 hollow Python injections
74+
* `stage1-controls-windows` [windows] **MEASURED OBSERVATION** — 19 controls passed, 0 failed, 0 skipped, 1 not applicable (a non-executable candidate cannot be constructed for the shell surface under git-bash). Includes default-is-rust, unset-locator-is-d6, rust-failure-no-fallback and an unexpected child status mapped to public exit 5 with the raw WINDOWS-NATIVE status (-1073740791) retained. The job as a whole went red on a HARNESS defect in the Stage-3 rollback control that ran after these, not on any of them

0 commit comments

Comments
 (0)