Repository navigation
P-022 step 8: Rust-default cutover, rollback gate, and Python distribution removal #262
Description
Parent: #250
Recommended tier: strong + owner decision
Status (2026-09-18) — Stage 3 COMPLETE and merged (PR #359 → 63148d0); Stage 4 open
Stage 1 DONE — opt-in Rust behind the launcher
Stage 2 DONE — Own.NET's own CI and dogfood on Rust
Stage 3 COMPLETE — three qualification points, kept apart:
decision f1d3681e96e31bba7a36dda09967d0af7c3e9b56
run 35312190310, 31/31 — established the decision
implementation b22543680da9e8fa6b2607435bb54a5437815bff
run 35320877147, 31/31 — after the repository-hygiene
commit untracked rust/target-fault (build output)
terminal merge 1bf17e4b0f64f012909d5c7c762febf1253e198d
run 35322222621, 31/31 — the reconciliation-only head
of branch claude/p022-stage3-rust-cutover-q0n2q5
public default: Rust on owen / own-check.sh / own-check.ps1 / Action
rollback: explicit Python selection, tested — no silent fallback
performance: DEFERRED BY OWNER — NOT MEASURED; #263 remains open
merge to main: DONE — PR #359 merged 2026-09-18 as 63148d0b2bdd873d18234ac8fff782a3b6b78856
(a merge commit whose tree is 1bf17e4's tree; main was 70189a3)
Stage 4 OPEN / NOT STARTED — Python distribution removal, a separate PR
after the observation period
P-037 A1 UNBLOCKED — not started; begins on a new branch from the
Rust-default baseline (main at 63148d0)
The full terminal record, with the decision packet, the defects found and closed during qualification and the known differences carried forward, is the Stage 3 comment below (2026-09-18); the terminal merge candidate and its run are recorded in the second Stage 3 comment. The comments are the historical decision record; this body is the status surface.
CLI prerequisite satisfied — #261 / 261.B: the production Rust OwnIR executable landed via PR #347 (merged as 206e9c7) and #261 is closed completed; it was the sole CLI prerequisite of this cutover by owner decision C-5 (ratified 2026-09-08). #260 is reached. Not blocked by #345, the residual .own/dev CLI split out of #261.
Performance: DEFERRED BY OWNER at the Stage 3 decision (2026-09-18), not measured. #263 remains open and owes the baselines; the performance-gates section below is retained as #263's obligation, not as a Stage 3 predicate.
Also depends on: #256 and #259 (both reached)
Goal
Make an explicit, evidence-backed decision to switch Owen's default engine from Python to Rust, preserve a tested rollback path, and only later remove Python as a distribution dependency.
This is not an implementation catch-all. It is a cutover gate with measurable correctness, reliability, distribution and performance requirements.
Staged rollout
Stage 1: opt-in Rust — DONE
- Python remains default.
- Rust engine is explicitly selectable — by the launcher, never by the Rust executable itself (owner decision C-4 with P-022 step 7b: the production Rust OwnIR executable (
own-cli ownir) — command, output and exit-code parity behind the existing launcher #261: the executable presents one engine and knows nothing of Python). - Compare mode is available.
- Dogfood and selected CI jobs exercise Rust.
Stage 2: repository CI/dogfood Rust default — DONE
- Rust is default in Own.NET's own CI and internal dogfood.
- Python remains an explicit fallback/reference mode.
- All compare gates continue to run.
Stage 3: public Rust default — COMPLETE (decision f1d3681, run 35312190310; terminal merge candidate 1bf17e4, run 35322222621; PR #359 merged as 63148d0)
- Owen public package/Action uses Rust by default.
- A documented escape hatch can select Python during the observation window.
- No silent fallback from Rust failure to Python success.
Stage 4: remove Python distribution dependency — OPEN / NOT STARTED
Separate PR after an observation period:
- remove vendored Python core from the package;
- remove runtime Python discovery/download/cache requirements;
- deprecate/remove
OWEN_PYTHONand legacyOWN_PYTHONaccording to a documented compatibility plan; - retain Python reference code and oracle fixtures in the repository while they remain valuable for differential testing.
Launcher rulings (ratified 2026-09-08 with #261's decision packet)
- The Rust executable behind the launcher is
own-cli ownir(P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261) — one engine. Engine selection, the staging above and the escape hatch are the launcher's, explicit and documented; no silent fallback from a Rust failure to a Python success at any stage. - An unexpected Rust child exit code outside the legal set maps to the public internal-error path (exit 5), and the raw child status is retained in the evidence/report. A catchable panic in the child is one actionable stderr diagnostic and exit 70 (mapped onto the internal-error path as today); an uncatchable death is a visible hard failure, never findings and never a clean exit, with no particular OS exit number contracted.
- SIGINT/cancellation: the Windows and Linux reference behaviour is measured first; 130 is not invented as a universal contract. (Measured at Stage 3: Linux signal 2 with no exit-code contract; Windows
0xC000013A/STATUS_CONTROL_C_EXIT; the universal 130 explicitly not contracted.) - stdin is not part of the production OwnIR contract.
Known differences recorded ahead of the packet (2026-09-08, from #261's 261.B measurement)
Named here so they enter the decision packet's Known differences: line as recorded facts rather than being discovered at cutover. Status at Stage 3 (2026-09-18): the invalid-UTF-8 tail, V1 and V2 were closed in the reference first, before the default moved (invalid UTF-8 → rc 2 on both engines at byte parity; V1 to the JSON door; V2's literal top-level -0 refused, with the residual narrowing CLI-B2); V3 verified not reopened; V4's reopen predicate verified not fired. Carried forward: WIN-ABC, CLI-B1, CLI-B2, V4, CANCEL-TRACEBACK (Linux-only) and ACTION-BUILD (a declared temporary distribution cost until the first published artifact). The paragraphs below are kept as the record of what was known before the packet.
- Windows console/pipe encoding — a deliberate behavior change at cutover, not a regression. On a piped Windows stdout the Python reference encodes with
cp1252, translates line endings to CRLF, and dies withUnicodeEncodeErroron non-ASCII output that the Linux reference renders. The Rust executable emits canonical UTF-8 on both platforms. So a Windows user's bytes change at cutover — cp1252 / CRLF (and the occasionalUnicodeEncodeError) today, canonical UTF-8 tomorrow. Three claims, kept apart:- A — canonical reference parity: claimed. Rust reproduces the Linux/UTF-8 reference byte for byte.
- B — Rust portability: claimed. Rust on Linux and Rust on Windows emit identical bytes.
- C — native-Windows Python parity: NOT claimed. It does not exist today, because the Windows reference is not byte-portable; "better" here still means "different", so it is a behavior change, not folded into a parity claim.
- Invalid UTF-8 input — CLOSED before the cutover (2026-09-18, Python-first, byte parity). As recorded before the packet: the Python reference exited 70 on a
UnicodeDecodeError(itsload()caughtOSError/JSONDecodeErroronly, so a decode failure escaped to the exit-70 catch-all), and a Python-first hygiene fix was owed before public cutover —UnicodeDecodeError→OwnIRError→ rc 2. It landed in the reference first; both engines now report rc 2 with a message that states the offending byte and offset, so this is byte parity rather than a declared boundary.
Parser-domain rulings V1–V4 (owner-ratified 2026-09-08, surfaced by #261's Version census)
The ownir_version gate exposes classes where CPython's permissive json and serde_json decode the same source differently — V1–V3, acceptance / kind / sub-branch divergences, not message wording — plus one representation-only boundary, V4, where the two sides' Unicode tables are independently versioned. They are ruled here:
- V1 — non-finite constants
NaN/Infinity/-Infinity: declared Python-reference defect. CPythonjson.loadsaccepts them, so the reference reaches the Version door (got nan/got inf/got -inf);serde_jsonrejects them at the JSON door (kindJson). The Rust JSON refusal is not a P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261 parity failure and the parser is not taught non-standard JSON. Python-first hygiene owed before public cutover: reject them at the JSON door →OwnIRError/ rc 2 with a frozen message. Closed before the cutover (2026-09-18). - V2 — literal
-0: declared cross-parser encoding defect. CPython reads-0as the integer0, so{"ownir_version": -0}is accepted as v0 (measured);serde_jsonreads it as the float-0.0, so the Rust door refuses it wrong-type. This is the ambiguity P-022 step 7a: add dual-engine shadow mode and zero-diff reproduction artifacts #260 already froze a refusal for (tests/fixtures/repro/domain_negative_zero.facts.json; manifest reason "the literal -0: this reference reads it as the integer 0, serde_json as the float -0.0. Refused rather than reconciled"). Python's acceptance is not emulated in Rust for P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261 — an exact owner-declared exception to C-1, scoped to the top-level scalarownir_versionvalue the type check reads. A-0inside a wrong-type container is not this exception and needs no emulation:{"ownir_version": [-0]}rendersgot [0]byte-identically on both sides — the reference's ownreprreads the nested-0as the integer0— and P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261's Version census pins that case as byte parity (negative_zero_below_the_top_level_matches_the_reference), not a declared difference. Python-first hygiene owed before public cutover: reject the literal top-level-0at the OwnIR input boundary, consistent with P-022 step 7a: add dual-engine shadow mode and zero-diff reproduction artifacts #260's frozen domain. Closed before the cutover (2026-09-18): the literal top-level-0is refused; the residual difference isCLI-B2, a narrowing of the ratified boundary from accept-versus-reject down to one token's spelling inside a refusal both sides make. - V3 — integral version value beyond
i64/u64: reproduced (byte parity). Such a value is anintto Python and takes the Version mismatch branch;serde_json's lossyf64would otherwise send the port to wrong-type. P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261's repair-2 reproduced the mismatch branch narrowly — without enablingserde_json/arbitrary_precision— for oversized values of both signs, byte-identical to the reference in the Version census (Group 1, the byte denominator). Accepted at P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261's 261.B head (PR feat(own-cli): the production Rust OwnIR executable, behind the unchanged launcher (#261 261.B) #347, merged 2026-09-08 as206e9c7): this is a completed parity fact, no longer an open divergence. Verified not reopened at both signs at Stage 3. - V4 —
str.isprintable()is answered from a version-dependent Unicode table: declared reference-version exception. Unlike V1–V3 this is not an accept/reject divergence and does not touch the parser:serde_jsonremains the sole parser deciding acceptance. It is a representation / diagnostic boundary — when a rejectedownir_versionis a string, itsreprescapes or prints each code point bystr.isprintable(), which each side answers from the Unicode table it was built with. The supported CPython reference matrix is itself version-dependent — Python 3.11 links UCD 14.0.0, 3.12 links 15.0.0, 3.13 links 15.1.0 — so for a code point whose classification differs between those releases there is no single "what Python does". No single static Unicode-property table can be byte-identical to every supported CPython reference version on such code points; a pin would buy parity with one interpreter and silently lose it against another, which is a change to the P-022 reference contract rather than a technical fix — so V4 is not described as "unrepairable from Rust". V4 stays excluded from the byte-parity denominator but remains separately declared and measured. Its reach in the frozen fixture family is zero, evidenced first by the green Python-oracle matrix on 3.11/3.12/3.13 (three different Unicode tables, all green) and corroborated by a whole-plane sweep. Any exact mismatch count is a specific two-version measurement and must be labelled as such — e.g. the 15 097 code points measured for CPython 3.11.15 / UCD 14.0.0 againstunicode-properties0.1.4 / UCD 17.0.0 — never presented as a version-independent size of V4. Reopens if the supported Python matrix changes, the Rust Unicode snapshot changes, a divergent code point enters the frozen fixtures, or this difference reaches accept/reject/verdict behaviour or any previously byte-parity production output. The sameis_printablehelper is carried byown-syntax, so the boundary is a repository-wide tail to consolidate (one measurement, one place) rather than anything P-022 step 7b: the production Rust OwnIR executable (own-cli ownir) — command, output and exit-code parity behind the existing launcher #261 introduces. Reopen predicate verified not fired at Stage 3 (matrix still 3.11/3.12/3.13, snapshot stillunicode-properties0.1.4).
Correctness gates
- P-022 step 7a: add dual-engine shadow mode and zero-diff reproduction artifacts #260 fast and broad compare matrices report zero unexplained differences.
- Five pinned OSS repositories report zero unexplained differences.
- Full diagnostic/message/Evidence/SARIF parity is green (
reportwas struck at P-022 step 5b: port the SARIF projection with canonical parity #256 and is not a surface here). - The production OwnIR executable's command/output/exit-code parity (P-022 step 7b: the production Rust OwnIR executable (
own-cli ownir) — command, output and exit-code parity behind the existing launcher #261) is green. The residual.own/dev CLI (P-022 step 7b residual: Rust CLI for the.ownand dev surfaces —cfg,summaries,explain,.own check;emitbehind #257 #345) is not a gate of this cutover (C-5). - No severity or diagnostic-count drift is accepted without a separate Python-first decision.
Reliability gates
- malformed OwnIR and malformed source inputs do not panic;
- Rust crashes are visible and produce reproduction artifacts;
- cancellation/interruption behavior is tested against the measured reference (the SIGINT ruling above);
- deterministic reruns produce identical normalized output;
- memory/resource limits for hostile or very large inputs are documented;
- Windows and Linux clean-machine paths are covered.
Distribution gates
- Rust binary is packaged for all supported platforms;
- public
Owen.Cliinstall and Action use work without undeclared runtimes; - package upgrade/uninstall/reinstall tested;
- rollback engine selection is documented and tested during the observation stage;
- release workflow tests the actual packed artifact, not a project build.
Performance gates — DEFERRED BY OWNER at Stage 3; #263 open
Establish and publish baselines for:
- process startup;
- OwnIR parse;
- bridge/lowering;
- analysis;
- CLI/SARIF rendering;
- end-to-end C# project/solution run;
- peak RSS/allocations where practical.
#263 produces these baselines. At the Stage 3 decision (2026-09-18) the owner deferred them: the packet's three performance fields read exactly DEFERRED BY OWNER — NOT MEASURED and are kept rather than deleted, because a packet missing the line is a packet nobody can notice is missing it. Do not require a theatrical multiplier. Rust must meet an explicit budget and must not materially regress the user-visible path. Profile before optimizing; JSON and rendering may dominate once analysis becomes cheap.
Cutover decision packet
The PR/ADR must include:
Observation window:
Fast compare result:
Five-repo compare result:
Large-solution result:
Windows packaging result:
Linux packaging result:
Startup delta:
End-to-end delta:
Peak memory delta:
Known differences:
Rollback command/config:
Python-removal timing:
The Known differences: line begins from the section recorded above (the Windows A/B/C behavior change, the invalid-UTF-8 tail, and the parser-domain rulings V1–V4), plus anything the observation window adds. Delivered at Stage 3: generated from a measurement ledger, never typed — docs/generated/p022-stage3-packet.md from docs/evidence/p022-stage3-cutover.json, held by tests/test_stage3_packet.py to mutation-proved rules; zero OWED rows; the three performance fields deferred as above.
Rollback
- Rollback must be a documented engine selection or package patch, not a hidden automatic fallback.
- A Rust failure must remain observable.
- Immutable release tags are never moved.
- A broken release is corrected by a new patch release.
Guardrails
- No new diagnostics.
- No semantic cleanup mixed into cutover.
- No removal of Python reference/oracle tests merely because distribution no longer needs Python.
- No public Rust default before the evidence packet is approved.
- No Python dependency removal in the same PR that first changes the default.
- No engine selection inside the Rust executable, and no fallback anywhere (C-4).
Acceptance
Stage 3 — met on the decision candidate f1d3681 (run 35312190310, 31/31), re-confirmed on the implementation candidate b225436 (run 35320877147, 31/31) and on the terminal merge candidate 1bf17e4 (run 35322222621, 31/31)
- Explicit owner-approved cutover decision exists — met (2026-09-18, recorded in the Stage 3 comment).
- Rust is public default only after all gates pass — met for the correctness, reliability and distribution gates on the terminal run; the performance gates are deferred by owner decision with IDE foundation: establish cold/warm/incremental latency and memory baselines #263 open, not passed.
- Rollback path is tested and documented — met (explicit Python selection; no silent fallback).
- Observation-period results are recorded — met in the generated decision packet, zero OWED rows.
- Public Owen install/Action behavior remains correct on supported platforms — met on Linux and Windows;
ACTION-BUILDaccepted as a declared temporary distribution cost until the first publishedown-cliartifact. - Merge of the terminal merge candidate
1bf17e4intomain— done (PR P-022 Stage 3: Rust is the public default engine (cutover, rollback gate, decision packet) #359, merged 2026-09-18 as63148d0, a merge commit whose tree is1bf17e4's tree).
Stage 4 — open
- Python distribution removal occurs in a later, separately reviewable PR, after the observation period.
- The Python reference code and oracle fixtures are retained while they remain valuable for differential testing.
Activity
docs(p022): move step 7b surfaces to the ratified #261 decision packet
docs(p022): record what #261 built, and the three findings it did not…
docs(p022): repair the status contradiction and state the rulings as …
docs(p022): record what the second measurement found, and name the tr…
docs(p022): keep the status surfaces consistent with the second repai…
Merge pull request #348 from PhysShell/claude/checkpoint-0-execution-…
feat(launcher): opt-in Rust engine behind the existing launcher (#262 …
fix(stage1): a Windows compare divergence is the declared A/B/C change
Merge pull request #349 from PhysShell/claude/checkpoint-0-execution-…
feat(ci): Stage 2 — this repository runs on Rust; the public contract…
Merge pull request #350 from PhysShell/claude/checkpoint-0-execution-…
feat(perf): #263-A — the measurement instrument, calibrated and firew…
fix(perf): close the boolean N/A escape hatch and check the whole cel…
fix(perf): D7's metrics are not the instrument's attribution taxonomy
2 remaining items
P-022 Stage 3: re-qualify the differential evidence on the candidate …
P-022 Stage 3: fix what the first CI run on the cutover actually found
P-022 Stage 3: derive the #262 decision packet from evidence, not fro…
P-022 Stage 3: re-anchor the Stage-1/Stage-2 campaigns the cutover in…
P-022 Stage 3: reconcile the status surfaces so they cannot disagree
P-022 Stage 3: re-record the Stage-1 campaign against the inverted mu…
P-022 Stage 3: harden ACTION-BUILD for reproducibility, and record th…
P-022 Stage 3: re-record the Stage-2 campaign; both inverted mutants …
P-022 Stage 3: fix the own-check.ps1 output regression the cutover in…
P-022 Stage 3: record the Windows campaign, and fix the assertion tha…
P-022 Stage 3: COMPLETE — reconcile at the terminal candidate
PhysShell commentedon Sep 18, 2026
Stage 3 — COMPLETE
Every predicate above is confirmed on f1d3681, not assembled from earlier runs.
The decision packet
Generated from a measurement ledger, never typed:
docs/generated/p022-stage3-packet.mdfromdocs/evidence/p022-stage3-cutover.json.tests/test_stage3_packet.pyholds the ledger to rules that are each mutation-proved — a deferral cannot soften into a claim, performance language cannot appear outside the deferred fields, an OWED Windows row cannot be filled in from the Linux one beside it, a closed bug cannot be re-listed as a standing difference, and the committed packet must match what the ledger produces. Zero OWED rows remain.The three performance fields read exactly
DEFERRED BY OWNER — NOT MEASUREDand are not deleted: #262 asked for them, #263 still owes them, and a packet missing the line is a packet nobody can notice is missing it.What was owed before the cutover, and is now closed
The three Python-first hygiene tails were closed in the reference first, before the default moved:
NaN/Infinity/-Infinitymoved to the JSON door, where the port already refused them;-0, which until this change was accepted as v0 and analysed by the public default engine. Now refused. Its residual difference is CLI-B2, a narrowing of the ratified boundary from accept-versus-reject down to one token's spelling inside a refusal both sides make.V3verified not reopened at both signs;V4's reopen predicate verified not fired (matrix still 3.11/3.12/3.13, snapshot stillunicode-properties0.1.4).Defects found and closed during qualification
Recorded as closures, deliberately not as known differences — after the fixes they are not permitted Stage-3 behaviour. They form one sequence:
Each looked respectable alone; all three were invisible while Python was the default.
own-check.ps1's Rust branch spawned a child inheriting the console handle, bypassing PowerShell's pipeline, so$out = & ./scripts/own-check.ps1 ...captured nothing while the text still printed. Closed by redirecting and replaying the streams; exit tiers and SARIF bytes unchanged.$null, and PowerShell's array/filter semantics make an empty capture pass. The other Stage-2 controls and the actual Rust-default execution (exit code, no-fallback, candidate identity) remain separately evidenced. This is not a claim that Stage 2 as a whole was unproven.tests/test_stage3_surfaces.pynow asserts the property over 63 invocations across 26 workflows and scripts.Known differences carried forward
WIN-ABC(unchanged — and it bit a Stage-3 control that had compared raw bytes across engines on Windows; that control now compares the verdict),CLI-B1,CLI-B2(new, a narrowing),V4,CANCEL-TRACEBACK(new, and Linux-only — Python prints a 1478-byteKeyboardInterrupttraceback there and nothing on Windows), andACTION-BUILD, accepted per the owner ruling as a declared temporary distribution cost with its exit condition recorded, hardened meanwhile with a pinned rustc (1.98.1) and--locked.D6
RustCoreLocator.csnamed this gap in its own Stage-1 text. Theown-clibinary now ships inside the Owen.Cli package and resolves from the tool's own payload by platform key — packaged resolution, not discovery: one computed path, no PATH lookup, norust/targetprobing, and a visible exit-2 configuration error when absent.OWEN_RUST_COREkeeps Stage 1's meaning and precedence; only an absent variable reaches the packaged path.Hand-off
Not started here and not in this PR. A1 begins on a new branch from the Rust-default baseline, so nobody has to work out later whether the first
ConsumesParamchange belonged to the Rust migration or to a new inference feature.Generated by Claude Code
Repository hygiene: untrack rust/target-fault and ignore Cargo build …
P-022 Stage 3: record the implementation qualification on the cleaned…
PhysShell commentedon Sep 18, 2026
Stage 3 — terminal merge record (2026-09-18)
Three qualification points, kept apart because they answer three different questions:
What happened between the decision and the merge candidate, and why it is recorded here rather than in the tree:
f1d3681.rust/target-fault/(the build directory of the fault-injection candidate; committed by accident in04c3303, present inf1d3681, and rightly not a functional failure for the decision run, which checks the program and not the tree's manners). Build output is not evidence and is never versioned:b225436untracked all of it and ignores/rust/target/and/rust/target-fault/explicitly.git ls-files 'rust/target*'is empty; the branch diff againstmaincarries zero such files.ci.ymlmatrix was re-run on the cleaned head rather than inherited from its ancestor: run 35320877147, 31/31. That makesb225436the implementation qualification point.1bf17e4is the reconciliation-only commit that records those two points in the ledger, the regenerated packet, the decision note and P-022. It deliberately does not name its own SHA — a commit cannot, without a fixed-point hunt — so the canonical terminal merge candidate and its run are recorded here:1bf17e4, run 35322222621, 31/31. The SHA that is merged is the SHA that was qualified.Unchanged by this record: Rust is the public default on
owen/own-check.sh/own-check.ps1/ the Action; rollback is explicit Python selection, tested; no silent fallback; performance DEFERRED BY OWNER with #263 open; Stage 4 not started; P-037 A1 unblocked and not started.Generated by Claude Code