Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,29 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
- name: Refuse a binary/source revision skew (the release binary is built from the PR merge ref; this job checked out the branch head)
run: |
BUILT_FROM="${{ github.sha }}"
git fetch --quiet origin "$BUILT_FROM" 2>/dev/null || git fetch --quiet origin "${{ github.ref }}" 2>/dev/null || true
if ! git rev-parse --verify --quiet "$BUILT_FROM" >/dev/null; then
echo "heal refused: cannot resolve the revision the release binary was built from" >&2
echo " wanted: $BUILT_FROM (github.sha — the ref the build job compiled)" >&2
echo " without it this job cannot establish that its binary and its tree agree." >&2
exit 1
fi
SEED_AHEAD=$(git rev-list HEAD.."$BUILT_FROM" -- src/v1 | head -20)
if [ -n "$SEED_AHEAD" ]; then
echo "heal refused: binary/source revision skew" >&2
echo " the release binary was built from $BUILT_FROM;" >&2
echo " this job checked out the BRANCH HEAD, which lacks these seed (src/v1) commits:" >&2
echo "$SEED_AHEAD" | sed 's/^/ /' >&2
echo " a seed commit can add a Rust->.dag call this tree does not contain, so regenerating" >&2
echo " with that binary would project artifacts from a compiler this tree disagrees with." >&2
echo " remedy: bring this branch up to the revision above, then re-run." >&2
exit 1
fi
echo "heal preflight: binary revision $BUILT_FROM carries no seed commits missing from this tree"
timeout-minutes: 5
- name: Download release-bins artifact
uses: actions/download-artifact@v4
with:
Expand Down
40 changes: 40 additions & 0 deletions dag/gunbc/ci_spec.dag
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,46 @@ fn gunbc_ci_heal_regen_invoke() -> String {
)
}

data ci_heal_binary_source_skew_note: String = "THE PAIRING THE HEAL JOB NEVER MODELED (incident 2026-07-29). A generated artifact's identity is (authority source x compiler), and this job silently pairs TWO DIFFERENT REVISIONS of that product: the release binary comes from `build`, which uses the default pull_request checkout — the MERGE ref (main + PR) — while this job checks out `github.head_ref`, the BRANCH HEAD, because it pushes the regeneration back (ci_heal_branch_checkout_note reasons carefully about the push semantics and never about the binary's provenance; it calls build's output 'the release gunbc binary', treating a projection of one revision as a context-free tool). The skew is latent until a seed change adds a Rust->.dag call: then the merge-ref binary invokes a `.dag` function the branch-head tree does not contain, `lookup_fn` misses, and the run dies mid-regen. Live receipt: #7348 landed the scoped-run observation seed, whose Rust calls gunbc.observation_seed_render.scoped_run_begin_write_measured; every open PR whose branch head predated it went red at once (#7392, #7394, #7395 — identical failure, none of them at fault), while main stayed green because ci_regen_heal_if skips this job on push. Reproduced by execution: ONE binary built from the merge ref, run against a pre-#7348 tree -> the exact CI failure; run against the same tree with origin/main merged in -> ExitSuccess, zero drift. The guard below refuses the unsound pairing instead of discovering it as a mid-regen crash."

data ci_heal_binary_source_skew_guard_note: String = "THE COMPARAND IS THE BUILT-FROM REVISION, NOT A BRANCH NAME (operator ruling 2026-07-29). The first cut of this guard compared against `origin/main`, and that was wrong on the operator's own objection: `origin/main` names several different things — the one on this runner, the one on a developer's machine, and the actual source of truth — so a guard keyed on it asserts a relationship between the tree and WHICHEVER main this box last fetched, and can even race an advance mid-run. There is no ambiguity to resolve because the question was never about main: it is 'was this binary compiled from a revision this tree can satisfy'. `github.sha` answers it exactly — on a pull_request it IS the merge commit the build job checked out, so the guard compares the tree against the binary's own provenance, in the same workflow run, with no floating ref anywhere. The guard fails closed if that revision cannot be resolved: an unverifiable provenance is a refusal, never an assumed-fine.\n\nPRECISION IS ALSO THE POINT (DESIGN §5 — refuse, never widen; and never manufacture friction the defect does not warrant). A blanket 'tree must equal the built-from revision' would red nearly every PR to catch a rare pairing — a wall priced in other people's time. The DECIDABLE condition that actually matters is narrower: the binary is unsound against this tree exactly when the built-from revision carries SEED commits (src/v1 — the Rust the binary is compiled from) this tree lacks, because only those can introduce a Rust->.dag expectation the tree cannot satisfy. `git rev-list HEAD..$BUILT_FROM -- src/v1` is exactly that set: empty on the overwhelming majority of PRs (a .dag-only advance cannot skew a compiled binary), non-empty precisely in the #7348 shape. VERIFIED BY EXECUTION on the incident itself: at the pre-#7348 branch point the set is two commits and one of them IS 445bac31c (#7348), so the guard names the responsible commit in its own refusal; at current main and at the same branch after the advance was merged in, the set is empty and the guard is silent — the heal that went green on #7395 is the live receipt for the passing arm. On non-empty the step REFUSES rather than regenerating with a compiler that disagrees with the tree, which is the fabricated-plausible-output arm §5 forbids: main_wet would write whatever artifacts that mismatched pair produces and the commit-push step would push them to the branch. NOT IN SCOPE: preventing a stale branch from MERGING at all. That is merge-admission policy, it needs a source-of-truth model this repo does not have yet (operator: the SCM lane after v1 deletion), and this guard deliberately does not pretend to supply one — it protects the one pairing this job has agency over."

data ci_heal_binary_source_skew_guard_shell_scaffold: Disposition = Scaffold {
dissolves_to: SingleAuthority,
bind: DeclarationRef {
module_path: "gunbc.ci_spec",
decl_name: "gunbc_ci_heal_binary_source_skew_guard_script",
field: WholeDeclaration
}
}

data ci_heal_binary_source_skew_guard_dissolution_trigger: String = "🟡 dissolve-on: gunbc_ci_heal_binary_source_skew_guard_script — a hand-authored git-interrogation shell guard in a GitHub Actions run: foreign-executor context, the same scaffold class as gunbc_ci_heal_commit_push_script beside it. It is VALIDATION standing where CONSTRUCTION was too expensive today (DESIGN §5): the construction fix is for this job to run a binary built from the very tree it heals, at which point the pairing is correct by construction and no revision comparison exists to make. That is a real cost decision, not an oversight — heal would need its own branch-head build (~10min) where it currently downloads build's artifact (~2min) — so the guard lands now and the build lands when the heal's cost budget is revisited. DISSOLVES WHEN either (a) the heal job builds or receives a branch-head-provenance binary, or (b) the shell->intent lane models the git interrogation as typed host effects on host_effect_apply, whichever lands first — (a) deletes the guard outright, (b) only re-homes its transport."

fn gunbc_ci_heal_binary_source_skew_guard_script() -> String {
join([
"BUILT_FROM=\"${{ github.sha }}\"",
"git fetch --quiet origin \"$BUILT_FROM\" 2>/dev/null || git fetch --quiet origin \"${{ github.ref }}\" 2>/dev/null || true",
"if ! git rev-parse --verify --quiet \"$BUILT_FROM\" >/dev/null; then",
" echo \"heal refused: cannot resolve the revision the release binary was built from\" >&2",
" echo \" wanted: $BUILT_FROM (github.sha — the ref the build job compiled)\" >&2",
" echo \" without it this job cannot establish that its binary and its tree agree.\" >&2",
" exit 1",
"fi",
"SEED_AHEAD=$(git rev-list HEAD..\"$BUILT_FROM\" -- src/v1 | head -20)",
"if [ -n \"$SEED_AHEAD\" ]; then",
" echo \"heal refused: binary/source revision skew\" >&2",
" echo \" the release binary was built from $BUILT_FROM;\" >&2",
" echo \" this job checked out the BRANCH HEAD, which lacks these seed (src/v1) commits:\" >&2",
" echo \"$SEED_AHEAD\" | sed 's/^/ /' >&2",
" echo \" a seed commit can add a Rust->.dag call this tree does not contain, so regenerating\" >&2",
" echo \" with that binary would project artifacts from a compiler this tree disagrees with.\" >&2",
" echo \" remedy: bring this branch up to the revision above, then re-run.\" >&2",
" exit 1",
"fi",
"echo \"heal preflight: binary revision $BUILT_FROM carries no seed commits missing from this tree\""
], "\n")
}

data ci_heal_commit_push_note: String = "The heal COMMIT+PUSH step: a GitHub Actions run: foreign-executor context (the shell-intent design's bootstrap/foreign-executor case that renders through bash), so the git commit/push is a hand-authored shell string like the deploy invoke and the release-bins unpack script beside it. COMMIT SCOPE IS ARTIFACT-ONLY BY CONSTRUCTION (review 41703, DESIGN §5 no-silent-widen): staging is `git add` over EXACTLY committed_generated_artifact_paths() (the registry the drift gate guards and the exact set main_wet writes) — never `git add -A`, so an incidental dirty file on the runner (checkout residue, line-ending normalization, leftover state) can never be swept into an unattended push. Each path is guarded by a `[ -e ]` existence test (ci_heal_git_add_lines): a registered artifact that is absent on this tree — e.g. one a later PR deleted while its registry row lingered (the #7121 FleetConvergeArtifact incident) — is loudly echoed and skipped, never an exit-128 `git add` pathspec error that fails the whole heal; the drift gate remains the wall for a registered-but-missing artifact, the heal just commits what main_wet actually produced. The commit fires ONLY when `git diff --cached --quiet` reports a staged change — i.e. only when main_wet actually healed a registry artifact — so a clean tree is a no-op, and the byte-idempotent fixed point makes the healed commit's own re-run a no-op that terminates the loop. The push target is the TRIGGERING branch, not hardcoded main: `git push origin HEAD:${{ github.head_ref || github.ref_name }}` resolves to the PR source branch on a pull_request and to main on a push (operator-directed 2026-07-23: heal pushes to PRs so drift is repaired on the PR branch before it reaches main). The push authenticates with the job's GITHUB_TOKEN (persisted by actions/checkout, which checked out that same branch head) and needs the job-scoped contents:write grant. Loop termination is NOT a re-run of CI on the healed commit: a push made with the workflow's GITHUB_TOKEN does NOT trigger a new workflow run (GitHub's cascade-prevention is by design), so the healed commit fires no follow-on run — termination is guaranteed instead by the byte-idempotent fixed point itself, PROVEN pre-merge by generated_artifact_drift_test.witness_committed_is_fixed_point (committed bytes == artifact_generate) with its RED control, so a second heal would be a no-op even if one were triggered (review 41782 correction: the earlier 'verified by execution on the healed commit' claim was wrong — no such run exists). The healed artifacts are re-validated by the batch-0 drift gate on the NEXT unrelated PR/push, not on the heal commit, which is correct-by-construction (it is main_wet's own output). A concurrent advance on the target branch makes the push a non-fast-forward that fails LOUDLY (never a force-push), and the next run on that branch retries. A fork PR's branch is not writable by the workflow GITHUB_TOKEN, so a fork heal push fails loudly rather than silently — fork heals are out of scope; the fleet's PR model is same-repo session branches, which are pushable."

data ci_heal_commit_push_shell_emit_scaffold: Disposition = Scaffold {
Expand Down
17 changes: 16 additions & 1 deletion dag/gunbc/ci_workflow.dag
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import gunbc.ci_spec {
ci_release_bins_pack_script, ci_release_bins_unpack_verify_script,
ci_release_bins_archive_name,
gunbc_ci_heal_regen_invoke, gunbc_ci_heal_commit_push_script,
gunbc_ci_heal_binary_source_skew_guard_script,
}
import v2.workflow.ci_merge_admission_gate_emit {
ci_merge_admission_gate_script,
Expand Down Expand Up @@ -494,6 +495,20 @@ fn gunbc_ci_heal_job_backstop_timeout_minutes() -> Int {
gunbc_ci_artifact_transfer_step_timeout_minutes + gunbc_ci_aux_step_timeout_minutes + gunbc_ci_aux_step_timeout_minutes + gunbc_ci_aux_step_timeout_minutes + gunbc_ci_prelude_allowance_minutes
}

fn ci_heal_binary_source_skew_guard_step() -> Step {
RunStep {
name: Present { value: "Refuse a binary/source revision skew (the release binary is built from the PR merge ref; this job checked out the branch head)" },
id: none,
run: gunbc_ci_heal_binary_source_skew_guard_script(),
shell: none,
env: none,
working_directory: none,
if_condition: none,
continue_on_error: none,
timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes }
}
}

fn ci_heal_regen_step() -> Step {
RunStep {
name: Present { value: "Regenerate drifted generated artifacts (main_wet — writes each authority projection to the worktree)" },
Expand Down Expand Up @@ -531,7 +546,7 @@ fn ci_heal_generated_artifacts_job() -> Job {
runner: gunbc_ci_selected_runner_spec(),
steps: concat(
[ci_heal_branch_head_checkout_step()],
[ci_release_bins_download_step(), ci_release_bins_unpack_verify_step(), ci_heal_regen_step(), ci_heal_commit_push_step()]
[ci_heal_binary_source_skew_guard_step(), ci_release_bins_download_step(), ci_release_bins_unpack_verify_step(), ci_heal_regen_step(), ci_heal_commit_push_step()]
),
needs: ["build"],
env: none,
Expand Down
11 changes: 6 additions & 5 deletions src/v1/stage0/src/cli_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10042,18 +10042,19 @@ fn failure_receipt_companion(function: &str) -> Option<String> {
/// Empty string = no divergence detail (clean companion **or** companion not declared).
/// Non-empty refusal sentinel on wrong type / non-missing interpreter error — never silent
/// None when a companion *is* declared (review 41847, §5). A missing companion
/// (`NoSuchFunction` / `NoMainFunction` from the `_holds` → `_failure_receipt` naming
/// convention) is "not declared", not a refused receipt — the auto-derived name must not
/// invent a required loudness hook for every Bool(false) witness.
/// (`NoSuchFunction` from the `_holds` → `_failure_receipt` naming convention) is
/// "not declared", not a refused receipt — the auto-derived name must not invent a
/// required loudness hook for every Bool(false) witness. This arm used to name a second
/// variant, `NoMainFunction`, because `run_in_context` reported EVERY missing named
/// function that way; that variant is deleted and the one honest arm now covers it.
pub fn run_claim_failure_receipt(ctx: &v1_interpreter::InterpContext, function: &str) -> String {
match v1_interpreter::run_in_context(ctx, function, false) {
Ok(v1_interpreter::Value::Str(s)) => s,
Ok(other) => format!(
"failure_receipt_refused: {function} returned {}, expected String",
ctx.format_value(&other)
),
Err(v1_interpreter::InterpError::NoSuchFunction { .. })
| Err(v1_interpreter::InterpError::NoMainFunction) => String::new(),
Err(v1_interpreter::InterpError::NoSuchFunction { .. }) => String::new(),
Err(e) => format!("failure_receipt_refused: {function}: {e}"),
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/v1/stage0/src/v1_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,6 @@ pub enum InterpError {
NoSuchFunction {
name: String,
},
NoMainFunction,
NoSuchVariable {
name: String,
},
Expand Down Expand Up @@ -763,7 +762,6 @@ impl fmt::Display for InterpError {
InterpError::CallContractMismatch { callee, detail } => {
write!(f, "call contract mismatch calling '{}': {}", callee, detail)
}
InterpError::NoMainFunction => write!(f, "no main function found"),
InterpError::NoSuchVariable { name } => write!(f, "undefined variable: {}", name),
InterpError::NoSuchField { type_name, field } => {
write!(f, "no field '{}' on type '{}'", field, type_name)
Expand Down Expand Up @@ -1701,7 +1699,9 @@ pub fn run_in_context(
with_active_ctx(ctx, || {
let item_node = ctx
.lookup_fn(entry_fn)
.ok_or_else(|| InterpError::NoMainFunction)?
.ok_or_else(|| InterpError::NoSuchFunction {
name: entry_fn.to_string(),
})?
.clone();

let env = if eager_data_env {
Expand All @@ -1723,7 +1723,9 @@ pub fn run_in_context_with_args(
with_active_ctx(ctx, || {
let item_node = ctx
.lookup_fn(entry_fn)
.ok_or(InterpError::NoMainFunction)?
.ok_or_else(|| InterpError::NoSuchFunction {
name: entry_fn.to_string(),
})?
.clone();
let env = if eager_data_env {
build_initial_env(ctx)?
Expand Down
Loading