diff --git a/dag/gunbc/merge_admission.dag b/dag/gunbc/merge_admission.dag index a91781f33dc..27c37bc5160 100644 --- a/dag/gunbc/merge_admission.dag +++ b/dag/gunbc/merge_admission.dag @@ -495,6 +495,23 @@ fn classify_merge_admission_verdict_v2( } } +fn merge_admission_verdict_label(v: MergeAdmissionVerdict) -> String { + match v { + MergeAdmitted => "admitted" + MergeDeniedStaleBase => "stale base tree — re-run CI against current main" + MergeDeniedStaleRoster => "stale gate roster — re-run CI after criteria change" + MergeDeniedNotSuccess => "receipt conclusion is not success" + MergeDeniedWrongAttempt => "receipt belongs to a different walk attempt — not the subject; re-run CI" + MergeDeniedSubjectMismatch => "receipt describes a different head or base tree than this attempt captured — not the subject; re-run CI" + } +} + +data merge_admission_current_context_staging_note: String = "STAGED INVOCABLE ENTRY, NOT A LIVE CONSUMER (review 46238, incident 2026-08-01). tools.merge_admission_current_context can fetch the current merge target, read attempt-scoped TestedSubject and MergeAdmissionReceiptV2 wires, and classify them against current_gate_roster_hash, but no production path invokes the entry and no production path mints either V2 wire. It therefore closes no runtime admission gap today. What IS delivered and executing is the discovery-corpus control pair: classify_merge_admission_verdict_v2 refuses a receipt carrying a synthetic changed-roster hash against the current roster, and admits a receipt minted under the current roster. A correctly computed but uninvoked verdict is staging, not a warning and not a wall." + +data merge_admission_v2_production_wires_dissolution_trigger: String = "FIRST DISSOLVE-ON: production captures TestedSubject before the floor, mints MergeAdmissionReceiptV2 after the floor, and persists both through merge_admission_tested_subject_path and merge_admission_floor_receipt_path for the same WalkAttemptId. Until both producers execute, tools.merge_admission_current_context has no production subject or evidence to consume and cannot be called live." + +data merge_admission_required_check_binding_dissolution_trigger: String = "SECOND DISSOLVE-ON, after merge_admission_v2_production_wires_dissolution_trigger: gunbc.ci_failure_class.merge_freshness_gating_status changes from GatingComputedDeferred to the existing GatingEnforced arm after operator sign-off and the ci_failure_class prerequisites named by gunbc.plans.merge_admission_gate_shape_proposal. That transition binds the V2 current-context verdict into the approved merge-time consumption path so a denial refuses rather than merely being invocable. The later policy-content increment is already declared by gate_content_hash_variant_tag_scaffold: replacing gate_content_hash's variant-tag placeholder fires that SingleAuthority dissolution; it is not part of this staged increment." + fn merge_admission_verdict_would_block(v: MergeAdmissionVerdict) -> Bool { match v { MergeAdmitted => false diff --git a/dag/test/claim/merge_admission_attempt_witness_test.dag b/dag/test/claim/merge_admission_attempt_witness_test.dag index 198d813483a..c4e63a26def 100644 --- a/dag/test/claim/merge_admission_attempt_witness_test.dag +++ b/dag/test/claim/merge_admission_attempt_witness_test.dag @@ -17,6 +17,11 @@ import gunbc.merge_admission { MergeDeniedSubjectMismatch, classify_merge_admission_verdict_v2, merge_admission_verdict_would_block, + gate_roster_content_hash, + current_gate_roster_hash, +} +import gunbc.commit_workflow { + commit_gate_roster, } import gunbc.merge_admission_produce { merge_admission_receipt_schema_v2, @@ -96,6 +101,83 @@ test fn matching_attempt_and_subject_with_fresh_base_admits() -> Bool { ) { MergeAdmitted => true _ => false } } +fn synthetic_changed_roster_hash() -> ContentHash { + gate_roster_content_hash(roster: reverse(commit_gate_roster)) +} + +data roster_staleness_control_recut_note: String = "RECUT AFTER #7591 INVALIDATED #7583. The 2026-08-01 incident motivates the roster-identity law, but this control does not reconstruct that incident by execution. Its alternative revision reverses the exact live enrollments: identical members and surfaces, still unique and otherwise valid, but with a different order-sensitive roster hash. Order participates in roster identity even though it does not affect roster validity, so a source-only reordering is a real roster revision that refuses every in-flight admission minted under the prior order. It names no particular production gate and asserts no historical roster content. The prior control named PublicationPlacementGate; that coupled the regression control to one volatile roster member's continued existence, and the member's operator-ordered deletion invalidated the entire PR from a direction its original evidence did not watch. A control for a roster-identity law must exercise the SHAPE of a roster change, never a particular member, or its lifetime is bounded by the least stable thing it mentions. A description of a control's shape must live with that control or derive from it, so an implementation change cannot leave a remote carrier describing evidence that no longer executes. The discriminator proves staleness rather than malformedness: the alternative-revision receipt refuses against the live revision and the identical receipt admits when that alternative revision is supplied as current. The live-revision receipt also admits, so an always-admit or always-refuse implementation fails a control." + +fn synthetic_changed_roster_receipt() -> MergeAdmissionReceiptV2 { + MergeAdmissionReceiptV2 { + attempt_id: attempt_a(), + pr_number: Present { value: 7544 }, + tested_head_sha: head_sha_fx, + tested_base_tree: base_tree_fx(), + gate_roster_hash: synthetic_changed_roster_hash(), + conclusion: Success, + } +} + +test fn synthetic_changed_roster_receipt_refuses_against_live_roster() -> Bool { + (synthetic_changed_roster_hash() != current_gate_roster_hash) + && match classify_merge_admission_verdict_v2( + current_attempt_id: attempt_a(), + tested_subject: subject_fx(attempt: attempt_a(), tree: base_tree_fx(), head: head_sha_fx), + receipt: synthetic_changed_roster_receipt(), + merge_target_tree: base_tree_fx(), + current_roster_hash: current_gate_roster_hash, + ) { + MergeDeniedStaleRoster => true + MergeAdmitted => false + MergeDeniedStaleBase => false + MergeDeniedNotSuccess => false + MergeDeniedWrongAttempt => false + MergeDeniedSubjectMismatch => false + } +} + +test fn synthetic_changed_roster_receipt_admits_when_revision_is_current() -> Bool { + match classify_merge_admission_verdict_v2( + current_attempt_id: attempt_a(), + tested_subject: subject_fx(attempt: attempt_a(), tree: base_tree_fx(), head: head_sha_fx), + receipt: synthetic_changed_roster_receipt(), + merge_target_tree: base_tree_fx(), + current_roster_hash: synthetic_changed_roster_hash(), + ) { + MergeAdmitted => true + MergeDeniedStaleBase => false + MergeDeniedStaleRoster => false + MergeDeniedNotSuccess => false + MergeDeniedWrongAttempt => false + MergeDeniedSubjectMismatch => false + } +} + +test fn current_roster_receipt_admits_against_live_roster() -> Bool { + let receipt = MergeAdmissionReceiptV2 { + attempt_id: attempt_a(), + pr_number: Present { value: 7544 }, + tested_head_sha: head_sha_fx, + tested_base_tree: base_tree_fx(), + gate_roster_hash: current_gate_roster_hash, + conclusion: Success, + } + match classify_merge_admission_verdict_v2( + current_attempt_id: attempt_a(), + tested_subject: subject_fx(attempt: attempt_a(), tree: base_tree_fx(), head: head_sha_fx), + receipt: receipt, + merge_target_tree: base_tree_fx(), + current_roster_hash: current_gate_roster_hash, + ) { + MergeAdmitted => true + MergeDeniedStaleBase => false + MergeDeniedStaleRoster => false + MergeDeniedNotSuccess => false + MergeDeniedWrongAttempt => false + MergeDeniedSubjectMismatch => false + } +} + test fn foreign_attempt_refuses_as_wrong_attempt() -> Bool { let v = classify_merge_admission_verdict_v2( current_attempt_id: attempt_a(), diff --git a/dag/tools/merge_admission_current_context.dag b/dag/tools/merge_admission_current_context.dag new file mode 100644 index 00000000000..e885a8957a3 --- /dev/null +++ b/dag/tools/merge_admission_current_context.dag @@ -0,0 +1,138 @@ +module tools.merge_admission_current_context + +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.types { NonEmptyStr, GitRef } +import extdeps.git +import extdeps.git.inspect +import extdeps.git.object_store { + GitObjectIdTextDecoded, + GitObjectIdTextRefused, + git_decode_object_id_text, + git_object_id_format, +} +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.shell +import gunbc.merge_admission { + WalkAttemptId, + MergeAdmissionVerdict, + MergeAdmitted, + MergeDeniedStaleBase, + MergeDeniedStaleRoster, + MergeDeniedNotSuccess, + MergeDeniedWrongAttempt, + MergeDeniedSubjectMismatch, + classify_merge_admission_verdict_v2, + current_gate_roster_hash, + merge_admission_verdict_label, +} +import gunbc.merge_admission_produce { + compose_walk_attempt_id, + compose_explicit_walk_attempt_id, + merge_admission_tested_subject_path, + merge_admission_floor_receipt_path, + parse_tested_subject_wire, + parse_receipt_wire_v2, + merge_admission_merge_base_ref, + merge_admission_gate_fetch_parts, + shell_stdout_line, +} + +fn optional_env(name: NonEmptyStr) -> String? { + let observed = shell.Env.Get(name: name) + match observed.value { + Present { value: raw } => + let value = trim(raw) + if value == "" { none } else { Present { value: value } } + Absent => none + } +} + +fn current_walk_attempt_id() -> WalkAttemptId? { + match optional_env(name: "GUNBC_WALK_ATTEMPT_ID") { + Present { value: explicit } => compose_explicit_walk_attempt_id(explicit: explicit) + Absent => + compose_walk_attempt_id( + run_id: match optional_env(name: "GITHUB_RUN_ID") { + Present { value: value } => value + Absent => "" + }, + run_attempt: match optional_env(name: "GITHUB_RUN_ATTEMPT") { + Present { value: value } => value + Absent => "" + }, + job: match optional_env(name: "GITHUB_JOB") { + Present { value: value } => value + Absent => "" + }, + ) + } +} + +fn denied_exit(verdict: MergeAdmissionVerdict) -> ProcessExit { + exit_failure( + reason: concat( + "merge_admission_current_context: blocked (", + concat(merge_admission_verdict_label(v: verdict), ")") + ) + ) +} + +func main() -> ProcessExit { + let attempt = match current_walk_attempt_id() { + Present { value: value } => value + Absent => return exit_failure(reason: "merge_admission_current_context: walk attempt identity absent or invalid") + } + let root = git.Inspect.Toplevel().path as String + let subject_read = Filesystem.Read( + path: merge_admission_tested_subject_path(root: root, attempt_id: attempt) + ) + if !subject_read.success { + return exit_failure(reason: "merge_admission_current_context: tested-subject wire missing") + } + let subject = match parse_tested_subject_wire(text: subject_read.content) { + Present { value: value } => value + Absent => return exit_failure(reason: "merge_admission_current_context: tested-subject wire invalid") + } + let receipt_read = Filesystem.Read( + path: merge_admission_floor_receipt_path(root: root, attempt_id: attempt) + ) + if !receipt_read.success { + return exit_failure(reason: "merge_admission_current_context: floor-receipt wire missing") + } + let receipt = match parse_receipt_wire_v2(text: receipt_read.content) { + Present { value: value } => value + Absent => return exit_failure(reason: "merge_admission_current_context: floor-receipt wire invalid") + } + let fetch_parts = merge_admission_gate_fetch_parts() + let fetched = git.Core.FetchNoTags(remote: fetch_parts.remote, ref: fetch_parts.ref_name) + if !fetched.success { + return exit_failure(reason: "merge_admission_current_context: current merge target fetch refused") + } + let observed = git.Inspect.ShowTree(ref: merge_admission_merge_base_ref as GitRef) + if !observed.success { + return exit_failure(reason: "merge_admission_current_context: current merge target tree observation refused") + } + let target = match git_decode_object_id_text( + format: git_object_id_format(oid: receipt.tested_base_tree), + text: shell_stdout_line(raw: observed.tree), + ) { + GitObjectIdTextDecoded { oid: oid } => oid + GitObjectIdTextRefused { cause: _ } => + return exit_failure(reason: "merge_admission_current_context: current merge target object id refused") + } + let verdict = classify_merge_admission_verdict_v2( + current_attempt_id: attempt, + tested_subject: subject, + receipt: receipt, + merge_target_tree: target, + current_roster_hash: current_gate_roster_hash, + ) + match verdict { + MergeAdmitted => ExitSuccess + MergeDeniedStaleBase => denied_exit(verdict: verdict) + MergeDeniedStaleRoster => denied_exit(verdict: verdict) + MergeDeniedNotSuccess => denied_exit(verdict: verdict) + MergeDeniedWrongAttempt => denied_exit(verdict: verdict) + MergeDeniedSubjectMismatch => denied_exit(verdict: verdict) + } +} diff --git a/dag/tools/merge_admission_gate.dag b/dag/tools/merge_admission_gate.dag index 237800ae9b9..ba1f227e560 100644 --- a/dag/tools/merge_admission_gate.dag +++ b/dag/tools/merge_admission_gate.dag @@ -8,6 +8,7 @@ import extdeps.filesystem.filesystem_io { Filesystem } import gunbc.merge_admission { current_gate_roster_hash, classify_merge_admission_verdict, + merge_admission_verdict_label, MergeAdmissionVerdict, MergeDeniedStaleBase, MergeDeniedStaleRoster, @@ -27,17 +28,6 @@ import gunbc.merge_admission_produce { } import gunbc.ci_failure_class { merge_admission_blocks_merge } -fn verdict_reason(v: MergeAdmissionVerdict) -> String { - match v { - MergeAdmitted => "admitted" - MergeDeniedStaleBase => "stale base tree — re-run CI against current main" - MergeDeniedStaleRoster => "stale gate roster — re-run CI after criteria change" - MergeDeniedNotSuccess => "receipt conclusion is not success" - MergeDeniedWrongAttempt => "receipt belongs to a different walk attempt — not the subject; re-run CI" - MergeDeniedSubjectMismatch => "receipt describes a different head or base tree than this attempt captured — not the subject; re-run CI" - } -} - func main() -> ProcessExit { let root = git.Inspect.Toplevel().path as String let path = merge_admission_receipt_path(root: root) @@ -77,7 +67,7 @@ func main() -> ProcessExit { exit_failure( reason: concat( "merge_admission_gate: blocked (", - concat(verdict_reason(v: verdict), ")") + concat(merge_admission_verdict_label(v: verdict), ")") ) ) } else {