From d55b7b07d0b9c9c7ef836150c7fe2abaa7d17aaa Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 12 Aug 2026 10:19:32 +0000 Subject: [PATCH 1/4] Derive the artifact axis from real builds, in a fixed directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wet claims: changing only the source bytes moves exactly the materialized artifact axis, and rebuilding identical source in place moves no axis. Both build through the production path, so the axis is derived from bytes rather than minted from a literal. Both hold the build directory FIXED, because a Rust debug binary embeds its build path: the same source in two temp dirs yields different bytes. A fixture varying source AND directory would pass with identical sources, proving nothing about the source. Guarded on both sides materializing, since artifact_identity_agrees answers false for two unbuilt artifacts — without it the claim passes with both builds broken. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/ci_layer_roots.dag | 2 + ...ialization_real_execution_witness_test.dag | 139 +++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/ci_layer_roots.dag b/dag/gunbc/ci_layer_roots.dag index 1d265d3666d..1b0780230ba 100644 --- a/dag/gunbc/ci_layer_roots.dag +++ b/dag/gunbc/ci_layer_roots.dag @@ -999,6 +999,8 @@ data bin_witness_wet_entries: List = [ bin_wet(entry: "dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag", f: "the_materialized_path_is_the_one_the_real_cargo_stream_named"), bin_wet(entry: "dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag", f: "a_target_the_build_never_produced_refuses_and_does_not_materialize"), bin_wet(entry: "dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag", f: "the_digest_is_of_the_file_cargo_named_not_of_some_other_real_file"), + bin_wet(entry: "dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag", f: "changing_only_the_source_bytes_names_exactly_the_artifact_axis"), + bin_wet(entry: "dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag", f: "rebuilding_identical_source_in_place_moves_no_axis"), bin_wet(entry: "dag/test/claim/dag_collect_fingerprint_witness_test.dag", f: "dag_collect_fingerprint_witness_keystone_holds"), bin_wet(entry: "dag/test/claim/dag_compile_clean_perturb_receipts_test.dag", f: "perturb_optional_skew_fixture_red_holds"), bin_wet(entry: "dag/test/claim/dag_compile_clean_perturb_receipts_test.dag", f: "perturb_unresolved_import_fixture_red_holds"), diff --git a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag index a9175828aab..8500d5c86e5 100644 --- a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag +++ b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag @@ -6,6 +6,17 @@ import extdeps.filesystem.filesystem_io import extdeps.shell import extdeps.cargo_build import gunbc.self_host_artifact_materialization +import v2.compiler.self_host.generation { + ArtifactIdentity, + ArtifactMaterialized, + ArtifactNotMaterialized, + GenerationIdentity, + MaterializedArtifactAxis, + generation_axis_symbol, + generation_identity_mismatched_axes +} +import v2.std.algebra { for_all, length } +import v2.std.node { symbol_identity_digest } data live_tree_disposition: LiveTreeDisposition = ReadsLiveTree @@ -28,6 +39,10 @@ type BuiltFixture | FixtureSetupRefused { cause: String } fn build_fixture_once(target_name: String) -> BuiltFixture { + build_fixture_from_source(target_name: target_name, main_source: crate_main) +} + +fn build_fixture_from_source(target_name: String, main_source: String) -> BuiltFixture { let dir = shell.Mktemp.Dir() if !dir.success { FixtureSetupRefused { cause: "mktemp -d failed" } @@ -36,7 +51,7 @@ fn build_fixture_once(target_name: String) -> BuiltFixture { let manifest = "{root}/Cargo.toml" let main_rs = "{root}/main.rs" let wrote_manifest = Filesystem.Write(path: manifest, content: crate_manifest) - let wrote_main = Filesystem.Write(path: main_rs, content: crate_main) + let wrote_main = Filesystem.Write(path: main_rs, content: main_source) if !wrote_manifest.success || !wrote_main.success { FixtureSetupRefused { cause: "fixture crate write failed" } } else { @@ -132,3 +147,125 @@ test fn the_digest_is_of_the_file_cargo_named_not_of_some_other_real_file() -> B } } } + +data crate_main_variant: String = "fn main() \{ println!(\"gunbc artifact axis fixture, second program\"); \}\n" + +data build_path_is_in_the_bytes_note: String = "MEASURED, and it is why both claims below build in ONE directory. A Rust debug binary embeds its build path, so the SAME source compiled in two different temp directories produces DIFFERENT bytes and therefore a different digest, while the same directory rebuilt produces byte-identical output. Verified directly: two mktemp dirs, one source, two digests; one dir built twice, one digest. + +THAT INVALIDATES THE OBVIOUS FIXTURE SHAPE. A claim that builds source A in dir 1 and source B in dir 2 and observes the artifact axis move proves nothing about the source, because the path difference alone accounts for it — the claim would pass unchanged if both sources were identical. That is the same non-discriminating shape as asserting a field is populated: something moved, and the fixture cannot say what. Holding the directory fixed and varying only main.rs makes the byte difference attributable to the source, which is the whole point of a per-axis derivation claim. + +CONSEQUENCE FOR THE AXIS, worth naming rather than leaving for someone to hit. The artifact digest is not a pure function of the five model-side axes: it also carries build location. Relocating an otherwise identical build reports as a moved artifact axis. That direction is fail-closed — a false INVALIDATION, never a false agreement, so nothing is silently admitted — but it means artifact-axis equality across hosts or workspaces is not expected to hold and must not be treated as a reproducibility oracle. Making it one needs --remap-path-prefix or an equivalent, which is a build-configuration decision this lane does not own." + +type TwoBuilds + = TwoBuilds { first: ArtifactIdentity, second: ArtifactIdentity } + | TwoBuildsSetupRefused { cause: String } + +fn build_twice_in_one_dir(first_source: String, second_source: String) -> TwoBuilds { + let dir = shell.Mktemp.Dir() + if !dir.success { + TwoBuildsSetupRefused { cause: "mktemp -d failed" } + } else { + let root = trim(dir.path as String) + let manifest = "{root}/Cargo.toml" + let main_rs = "{root}/main.rs" + let wrote_manifest = Filesystem.Write(path: manifest, content: crate_manifest) + let wrote_first = Filesystem.Write(path: main_rs, content: first_source) + if !wrote_manifest.success || !wrote_first.success { + TwoBuildsSetupRefused { cause: "fixture crate write failed" } + } else { + let first = gunbc.self_host_artifact_materialization.artifact_identity_of( + outcome: gunbc.self_host_artifact_materialization.materialize_artifact_wet( + workdir: root, manifest_path: manifest, target_dir: "{root}/target", + target_name: fixture_target_name, extra_args: [] + ) + ) + let wrote_second = Filesystem.Write(path: main_rs, content: second_source) + if !wrote_second.success { + TwoBuildsSetupRefused { cause: "second fixture source write failed" } + } else { + TwoBuilds { + first: first, + second: gunbc.self_host_artifact_materialization.artifact_identity_of( + outcome: gunbc.self_host_artifact_materialization.materialize_artifact_wet( + workdir: root, manifest_path: manifest, target_dir: "{root}/target", + target_name: fixture_target_name, extra_args: [] + ) + ) + } + } + } + } +} + +fn axis_is_materialized(a: ArtifactIdentity) -> Bool { + match a { + ArtifactMaterialized { digest: _ } => true + ArtifactNotMaterialized => false + } +} + +fn axis_identity(artifact: ArtifactIdentity) -> GenerationIdentity { + GenerationIdentity { + producer_compiler: symbol_identity_digest(sym: "axis-derivation-producer-compiler"), + source_closure: symbol_identity_digest(sym: "axis-derivation-source-closure"), + target_model: symbol_identity_digest(sym: "axis-derivation-target-model"), + toolchain: symbol_identity_digest(sym: "axis-derivation-toolchain"), + build_configuration: symbol_identity_digest(sym: "axis-derivation-build-configuration"), + materialized_artifact: artifact + } +} + +fn moved_axis_count(both: TwoBuilds) -> Int { + match both { + TwoBuildsSetupRefused { cause: _ } => 0 - 1 + TwoBuilds { first: f, second: s } => + if !axis_is_materialized(a: f) || !axis_is_materialized(a: s) { + 0 - 1 + } else { + length( + xs: generation_identity_mismatched_axes( + left: axis_identity(artifact: f), + right: axis_identity(artifact: s) + ) + ) + } + } +} + +test fn changing_only_the_source_bytes_names_exactly_the_artifact_axis() -> Bool { + let both = build_twice_in_one_dir( + first_source: crate_main, + second_source: crate_main_variant + ) + match both { + TwoBuildsSetupRefused { cause: _ } => false + TwoBuilds { first: f, second: s } => + axis_is_materialized(a: f) + && axis_is_materialized(a: s) + && moved_axis_count(both: both) == 1 + && for_all( + xs: generation_identity_mismatched_axes( + left: axis_identity(artifact: f), + right: axis_identity(artifact: s) + ), + predicate: fn(named) { + generation_axis_symbol(axis: named) == + generation_axis_symbol(axis: MaterializedArtifactAxis) + } + ) + } +} + +test fn rebuilding_identical_source_in_place_moves_no_axis() -> Bool { + let both = build_twice_in_one_dir( + first_source: crate_main, + second_source: crate_main + ) + match both { + TwoBuildsSetupRefused { cause: _ } => false + TwoBuilds { first: f, second: s } => + axis_is_materialized(a: f) + && axis_is_materialized(a: s) + && moved_axis_count(both: both) == 0 + } +} From 17d8205a91c036adcc3fa68516a67d32b3e97be9 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 12 Aug 2026 10:24:08 +0000 Subject: [PATCH 2/4] State build-path sensitivity as an incompleteness, not a caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note framed it as "do not use artifact-axis equality as a reproducibility oracle". The sharper statement: two generations can agree on all five model-side axes and still differ in artifact bytes, so the axis set does not DETERMINE the artifact — it identifies a generation up to build location. Names the two honest remedies (remap-path-prefix, or build path joins build_configuration) and rules out the third. Still not urgent: the direction is false invalidation, never false agreement. Co-Authored-By: Claude Opus 5 (1M context) --- ...ost_artifact_materialization_real_execution_witness_test.dag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag index 8500d5c86e5..07bb32467d5 100644 --- a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag +++ b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag @@ -154,7 +154,7 @@ data build_path_is_in_the_bytes_note: String = "MEASURED, and it is why both cla THAT INVALIDATES THE OBVIOUS FIXTURE SHAPE. A claim that builds source A in dir 1 and source B in dir 2 and observes the artifact axis move proves nothing about the source, because the path difference alone accounts for it — the claim would pass unchanged if both sources were identical. That is the same non-discriminating shape as asserting a field is populated: something moved, and the fixture cannot say what. Holding the directory fixed and varying only main.rs makes the byte difference attributable to the source, which is the whole point of a per-axis derivation claim. -CONSEQUENCE FOR THE AXIS, worth naming rather than leaving for someone to hit. The artifact digest is not a pure function of the five model-side axes: it also carries build location. Relocating an otherwise identical build reports as a moved artifact axis. That direction is fail-closed — a false INVALIDATION, never a false agreement, so nothing is silently admitted — but it means artifact-axis equality across hosts or workspaces is not expected to hold and must not be treated as a reproducibility oracle. Making it one needs --remap-path-prefix or an equivalent, which is a build-configuration decision this lane does not own." +CONSEQUENCE, AND IT IS AN INCOMPLETENESS IN THE IDENTITY RATHER THAN A CAVEAT ON COMPARING IT. Two generations can agree on all five model-side axes — same producer compiler, same source closure, same target model, same toolchain, same DECLARED build configuration — and still produce different artifact bytes, because build path is an input to the artifact that no axis names. So the axis set does not DETERMINE the artifact; it identifies a generation up to build location. The promotion mechanism's premise is that these axes identify a generation, and that premise is true only modulo this. Exactly two honest remedies exist: --remap-path-prefix (or equivalent) removes path from the output, and the five axes become an actual determinant; or build path joins build_configuration, and the axis tells the truth about what it covers. The third option — leaving it implicit and absorbing it in a comparison — is the one this lane does not take. NOT URGENT, because the direction is fail-closed: an identical build at a different path reports as a MOVED artifact axis, a false invalidation, never a false agreement, so nothing is silently admitted. It is recorded here because it was measured here; the carrier-side row belongs with the identity in v2.compiler.self_host.generation, not with the witness that found it." type TwoBuilds = TwoBuilds { first: ArtifactIdentity, second: ArtifactIdentity } From aa6ea3bb5cd0fc889f68153a7f0394a50c5661dd Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Wed, 12 Aug 2026 10:28:56 +0000 Subject: [PATCH 3/4] Unbreak main: point the artifact producer at the renamed identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8177 renamed v2.compiler.self_host.generation's ArtifactIdentity to GeneratedArtifactIdentity, resolving a genuine homonym with std.cache_interface's generic ArtifactIdentity. #8176 landed a new consumer of the old name in the same window. Whichever merged second was not rechecked against the other, so main carries a resolve failure: dag/gunbc/self_host_artifact_materialization.dag:32:3: error: name 'ArtifactIdentity' not found in module 'v2.compiler.self_host.generation' Three sites, all mechanical: the import, the return type of artifact_identity_of, and a prose reference naming a type that no longer exists — the stale-citation class, repaired rather than left to rot. Verified by execution rather than by grep, which is how the break was found in the first place: my first pass grepped for names that were still present and concluded main was fine. Resolving the module is what actually answered it. All 10 artifact-materialization witnesses now pass. Worth recording beside the fix: #8177's own title notes the collision "only broke whole-tree", so per-PR affected-set selection did not see the edge between a renamed declaration in one lane and a new consumer landing in another. Both PRs were green when they merged. That is a selection gap on cross-lane rename edges, not a review failure by either author. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/self_host_artifact_materialization.dag | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dag/gunbc/self_host_artifact_materialization.dag b/dag/gunbc/self_host_artifact_materialization.dag index 5df5280452d..0025b940206 100644 --- a/dag/gunbc/self_host_artifact_materialization.dag +++ b/dag/gunbc/self_host_artifact_materialization.dag @@ -29,13 +29,13 @@ import extdeps.tools.sha512sum { sha512sum_observe_file } import v2.compiler.self_host.generation { - ArtifactIdentity, + GeneratedArtifactIdentity, ArtifactMaterialized, ArtifactNotMaterialized, observed_artifact_digest } -data artifact_materialization_subject_note: String = "SH-C: turning a cargo build into the artifact axis of a compiler generation, with no step that predicts a path instead of observing one. The chain is build -> the path cargo NAMED -> a digest of THAT path -> ArtifactIdentity. The load-bearing property is that the digest and every later execution consume ONE path value, produced by extdeps.cargo_message from cargo's own --message-format=json output. A convention-derived path breaks that at both ends simultaneously: it digests one file while executing another, with each operation individually honest and the composition silently wrong, and it also makes the artifact axis a FUNCTION of the build-configuration axis, collapsing two of the six independent axes v2.compiler.self_host.generation keeps apart." +data artifact_materialization_subject_note: String = "SH-C: turning a cargo build into the artifact axis of a compiler generation, with no step that predicts a path instead of observing one. The chain is build -> the path cargo NAMED -> a digest of THAT path -> GeneratedArtifactIdentity. The load-bearing property is that the digest and every later execution consume ONE path value, produced by extdeps.cargo_message from cargo's own --message-format=json output. A convention-derived path breaks that at both ends simultaneously: it digests one file while executing another, with each operation individually honest and the composition silently wrong, and it also makes the artifact axis a FUNCTION of the build-configuration axis, collapsing two of the six independent axes v2.compiler.self_host.generation keeps apart." data artifact_materialization_decomposition_note: String = "The decision logic is deliberately split into two PURE folds with a thin wet composition between them, rather than one wet function. select_artifact_path decides what the build reported; materialization_from_digest decides what the digest observation means. Both are total folds over typed inputs, so both are witnessed hermetically on every PR, and the only part that needs a wet cadence is the three service calls that produce their inputs. The alternative — one wet function carrying the decisions — would put every refusal arm behind a cadence that does not run per PR, which is how a decision nobody executes reads as a decision that holds." @@ -200,7 +200,7 @@ fn materialize_artifact_wet( // The projection into the generation carrier. ArtifactMaterialized is reachable from // exactly one arm, and only through observed_artifact_digest, whose argument is a // Sha512Digest that existed only because sha512sum reported it for this path. -fn artifact_identity_of(outcome: ArtifactMaterializationOutcome) -> ArtifactIdentity { +fn artifact_identity_of(outcome: ArtifactMaterializationOutcome) -> GeneratedArtifactIdentity { match outcome { ArtifactMaterializationObserved { executable: _, digest: d } => ArtifactMaterialized { digest: observed_artifact_digest(observed: d) } From 1a439e5757b53260167ce4f2701b339012a69b1f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 12 Aug 2026 10:43:00 +0000 Subject: [PATCH 4/4] Move the build-path finding from a String row to a source annotation DESIGN 4c: a String declaration whose sole purpose is commentary is misplaced data. Attached above build_twice_in_one_dir, the fn whose fixed-directory shape it justifies. Co-Authored-By: Claude Opus 5 (1M context) --- ...ialization_real_execution_witness_test.dag | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag index 07bb32467d5..19dda22e4cf 100644 --- a/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag +++ b/dag/test/claim/self_host_artifact_materialization_real_execution_witness_test.dag @@ -150,16 +150,40 @@ test fn the_digest_is_of_the_file_cargo_named_not_of_some_other_real_file() -> B data crate_main_variant: String = "fn main() \{ println!(\"gunbc artifact axis fixture, second program\"); \}\n" -data build_path_is_in_the_bytes_note: String = "MEASURED, and it is why both claims below build in ONE directory. A Rust debug binary embeds its build path, so the SAME source compiled in two different temp directories produces DIFFERENT bytes and therefore a different digest, while the same directory rebuilt produces byte-identical output. Verified directly: two mktemp dirs, one source, two digests; one dir built twice, one digest. - -THAT INVALIDATES THE OBVIOUS FIXTURE SHAPE. A claim that builds source A in dir 1 and source B in dir 2 and observes the artifact axis move proves nothing about the source, because the path difference alone accounts for it — the claim would pass unchanged if both sources were identical. That is the same non-discriminating shape as asserting a field is populated: something moved, and the fixture cannot say what. Holding the directory fixed and varying only main.rs makes the byte difference attributable to the source, which is the whole point of a per-axis derivation claim. - -CONSEQUENCE, AND IT IS AN INCOMPLETENESS IN THE IDENTITY RATHER THAN A CAVEAT ON COMPARING IT. Two generations can agree on all five model-side axes — same producer compiler, same source closure, same target model, same toolchain, same DECLARED build configuration — and still produce different artifact bytes, because build path is an input to the artifact that no axis names. So the axis set does not DETERMINE the artifact; it identifies a generation up to build location. The promotion mechanism's premise is that these axes identify a generation, and that premise is true only modulo this. Exactly two honest remedies exist: --remap-path-prefix (or equivalent) removes path from the output, and the five axes become an actual determinant; or build path joins build_configuration, and the axis tells the truth about what it covers. The third option — leaving it implicit and absorbing it in a comparison — is the one this lane does not take. NOT URGENT, because the direction is fail-closed: an identical build at a different path reports as a MOVED artifact axis, a false invalidation, never a false agreement, so nothing is silently admitted. It is recorded here because it was measured here; the carrier-side row belongs with the identity in v2.compiler.self_host.generation, not with the witness that found it." - type TwoBuilds = TwoBuilds { first: ArtifactIdentity, second: ArtifactIdentity } | TwoBuildsSetupRefused { cause: String } +// MEASURED, and it is why both claims below build in ONE directory. A Rust debug binary +// embeds its build path, so the SAME source compiled in two different temp directories +// produces DIFFERENT bytes and therefore a different digest, while the same directory +// rebuilt produces byte-identical output. Verified directly: two mktemp dirs, one source, +// two digests; one dir built twice, one digest. +// +// THAT INVALIDATES THE OBVIOUS FIXTURE SHAPE. A claim that builds source A in dir 1 and +// source B in dir 2 and observes the artifact axis move proves nothing about the source, +// because the path difference alone accounts for it — the claim would pass unchanged if +// both sources were identical. That is the same non-discriminating shape as asserting a +// field is populated: something moved, and the fixture cannot say what. Holding the +// directory fixed and varying only main.rs makes the byte difference attributable to the +// source, which is the whole point of a per-axis derivation claim. +// +// CONSEQUENCE, AND IT IS AN INCOMPLETENESS IN THE IDENTITY RATHER THAN A CAVEAT ON +// COMPARING IT. Two generations can agree on all five model-side axes — same producer +// compiler, same source closure, same target model, same toolchain, same DECLARED build +// configuration — and still produce different artifact bytes, because build path is an +// input to the artifact that no axis names. So the axis set does not DETERMINE the +// artifact; it identifies a generation up to build location. The promotion mechanism's +// premise is that these axes identify a generation, and that premise is true only modulo +// this. Exactly two honest remedies exist: --remap-path-prefix (or equivalent) removes path +// from the output, and the five axes become an actual determinant; or build path joins +// build_configuration, and the axis tells the truth about what it covers. The third option +// — leaving it implicit and absorbing it in a comparison — is the one this lane does not +// take. NOT URGENT, because the direction is fail-closed: an identical build at a different +// path reports as a MOVED artifact axis, a false invalidation, never a false agreement, so +// nothing is silently admitted. It is recorded here because it was measured here; the +// carrier-side row belongs with the identity in v2.compiler.self_host.generation, not with +// the witness that found it. fn build_twice_in_one_dir(first_source: String, second_source: String) -> TwoBuilds { let dir = shell.Mktemp.Dir() if !dir.success {