From 5ae4e36b26a4758e881f100dad497d0a49777969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:04:15 +0000 Subject: [PATCH 1/2] fix(review): prevent post-burn lineage recreation --- bench/axis_terminal_burn.go | 272 ++++++++++++++++++ bench/axis_terminal_burn_test.go | 26 ++ internal/reviewtransaction/bench_fixture.go | 33 +++ .../finalize_attempt_journal.go | 14 + .../finalize_attempt_journal_test.go | 21 ++ 5 files changed, 366 insertions(+) create mode 100644 bench/axis_terminal_burn.go create mode 100644 bench/axis_terminal_burn_test.go diff --git a/bench/axis_terminal_burn.go b/bench/axis_terminal_burn.go new file mode 100644 index 0000000000..8ab7341f29 --- /dev/null +++ b/bench/axis_terminal_burn.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const terminalBurnAxis = "terminal-burn" +const terminalBurnLineage = "issue-3572-terminal-burn" +const terminalBurnContract = "gentle-ai.review-integration/v2" + +// This axis is deliberately non-portable: bench_fixture exposes only the +// admission barrier. Both contenders still run the shipped FINALIZE command; +// the harness controls ordering and checks the post-burn filesystem invariant. +func init() { + RegisterAxis(Axis{ + Name: terminalBurnAxis, Title: "Deterministic concurrent FINALIZE admission after terminal burn", BlackBox: false, + Review: reviewOptedIn, Properties: []string{ + "Requires a product binary built with -tags bench_fixture for the real contender admission barrier.", + "Both contenders are real gentle-ai review finalize subprocesses; no authority bytes are authored by the harness.", + "Opt-in and non-portable by declaration; the ordinary black-box corpus remains unchanged.", + }, Journeys: terminalBurnJourneys, + }) +} + +func terminalBurnJourneys() []Journey { + return []Journey{{ + ID: "tb01-concurrent-finalize-cannot-recreate-burned-lineage", + Title: "#3572: a late concurrent FINALIZE cannot recreate a burned lineage journal", + Source: "#3572: the winner burns authority while a real contender is held before journal admission; release proves no lineage or residue is recreated", + Steps: []Step{ + {Name: "fixture: repository", Fixture: baseRepo}, + {Name: "fixture: staged low-risk candidate", Fixture: stageDocs("issue-3572")}, + {Name: "start the exact active lineage", Requires: startNamedCapability, Args: productArgs("review", "start", "--lineage", terminalBurnLineage), After: func(sandbox *Sandbox, observation Observation) error { + if observation.ExitCode != 0 { + return fmt.Errorf("review start exited %d: %s", observation.ExitCode, firstLine(observation.Stderr)) + } + sandbox.Lineage = terminalBurnLineage + return nil + }}, + {Name: "concurrent FINALIZE burns before the held contender is admitted", Requires: finalizeCapability, Composite: runTerminalBurnConcurrency}, + }, + }} +} + +type terminalBurnProcess struct { + args []string + cmd *exec.Cmd + stdout, stderr *bytes.Buffer + done chan error + cancel context.CancelFunc + waitErr error + waited bool +} + +func startTerminalBurnFinalize(sandbox *Sandbox, lineage, barrier string, negotiated bool) (*terminalBurnProcess, error) { + args := []string{"review", "finalize"} + if negotiated { + args = append(args, "--contract", terminalBurnContract) + } + args = append(args, "--cwd", sandbox.Repo, "--lineage", lineage) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + cmd := exec.CommandContext(ctx, sandbox.Binary, args...) + cmd.Dir, cmd.Env = sandbox.Repo, sandbox.env() + if barrier != "" { + cmd.Env = append(cmd.Env, "GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER="+barrier) + } + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + process := &terminalBurnProcess{args: args, cmd: cmd, stdout: stdout, stderr: stderr, done: make(chan error, 1), cancel: cancel} + if err := cmd.Start(); err != nil { + cancel() + return nil, err + } + go func() { process.done <- cmd.Wait() }() + return process, nil +} + +func (process *terminalBurnProcess) wait() Observation { + if !process.waited { + process.waitErr, process.waited = <-process.done, true + process.cancel() + } + exitCode := 0 + var exitErr *exec.ExitError + if errors.As(process.waitErr, &exitErr) { + exitCode = exitErr.ExitCode() + } else if process.waitErr != nil { + exitCode = -1 + } + return Observation{Args: process.args, ExitCode: exitCode, Stdout: process.stdout.String(), Stderr: process.stderr.String(), StdoutCaptured: true, StderrCaptured: true} +} + +func waitTerminalBurnReady(process *terminalBurnProcess, path string) error { + ticker, timeout := time.NewTicker(10*time.Millisecond), time.NewTimer(10*time.Second) + defer ticker.Stop() + defer timeout.Stop() + for { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + select { + case err := <-process.done: + process.done <- err + return fmt.Errorf("held FINALIZE contender exited before admission barrier: %v", err) + case <-timeout.C: + return errors.New("timed out waiting for the FINALIZE contender admission barrier") + case <-ticker.C: + } + } +} + +type terminalBurnFailure struct { + Schema string `json:"schema"` + Contract string `json:"contract"` + Operation string `json:"operation"` + Phase string `json:"phase"` + Code string `json:"code"` + Message string `json:"message"` + MutationOutcome string `json:"mutation_outcome"` + AuthorityApplicability string `json:"authority_applicability"` + RetrySafe bool `json:"retry_safe"` + Replayability string `json:"replayability"` + LineageID string `json:"lineage_id"` + RequiredInputs []string `json:"required_inputs"` + NextAction string `json:"next_action"` +} + +func requireTerminalBurnLoser(observation Observation) error { + if observation.ExitCode == 0 { + return errors.New("late concurrent FINALIZE contender unexpectedly succeeded") + } + payload := strings.TrimSpace(observation.Stdout) + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(payload), &fields); err != nil { + return fmt.Errorf("parse negotiated FINALIZE loser: %w", err) + } + for _, name := range []string{"schema", "contract", "operation", "phase", "code", "message", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "lineage_id", "required_inputs", "next_action"} { + if _, ok := fields[name]; !ok { + return fmt.Errorf("negotiated FINALIZE loser omitted contract field %q", name) + } + } + var failure terminalBurnFailure + if err := json.Unmarshal([]byte(payload), &failure); err != nil { + return fmt.Errorf("decode negotiated FINALIZE loser: %w", err) + } + if failure.Schema != "gentle-ai.review-integration.failure/v2" || failure.Contract != terminalBurnContract || failure.Operation != "review.finalize" || + failure.Phase == "" || failure.Code == "" || failure.Message == "" || failure.MutationOutcome == "" || failure.MutationOutcome == "committed" || + failure.AuthorityApplicability == "" || (failure.Replayability != "not_replayable" && failure.Replayability != "exact_replay_safe" && failure.Replayability != "status_required" && failure.Replayability != "manual_action_required") || + failure.LineageID != terminalBurnLineage || failure.RequiredInputs == nil || failure.NextAction == "" { + return fmt.Errorf("negotiated FINALIZE loser classification = %+v", failure) + } + return nil +} + +func recordTerminalBurnObservation(run *journeyRun, observation Observation) error { + gitCalls := run.sandbox.gitCallsSince() + if gitCalls == nil { + return errors.New("Git subprocess count for direct FINALIZE observation was unavailable") + } + record := run.accumulator.observe(run.step, observation, gitCalls, false) + run.accumulator.records = append(run.accumulator.records, record) + return nil +} + +func runTerminalBurnConcurrency(run *journeyRun) (err error) { + ready := filepath.Join(run.sandbox.Root, "terminal-burn-contender.ready") + release := filepath.Join(run.sandbox.Root, "terminal-burn-contender.release") + barrier := strings.Join([]string{terminalBurnLineage, ready, release}, "|") + var contender, winner *terminalBurnProcess + released := false + defer func() { + if !released { + _ = os.WriteFile(release, []byte("release\n"), 0o600) + } + if winner != nil { + winner.cancel() + _ = winner.wait() + } + if contender != nil { + contender.cancel() + _ = contender.wait() + } + }() + contender, err = startTerminalBurnFinalize(run.sandbox, terminalBurnLineage, barrier, true) + if err != nil { + return fmt.Errorf("start held FINALIZE contender: %w", err) + } + if err = waitTerminalBurnReady(contender, ready); err != nil { + return err + } + winner, err = startTerminalBurnFinalize(run.sandbox, terminalBurnLineage, "", false) + if err != nil { + return fmt.Errorf("start FINALIZE winner: %w", err) + } + winnerObservation := winner.wait() + if err = recordTerminalBurnObservation(run, winnerObservation); err != nil { + return err + } + if err = requireBurnedApproval(terminalBurnLineage)(run.sandbox, winnerObservation); err != nil { + return fmt.Errorf("winner FINALIZE: %w", err) + } + if err = os.WriteFile(release, []byte("release\n"), 0o600); err != nil { + return fmt.Errorf("release held FINALIZE contender: %w", err) + } + released = true + contenderObservation := contender.wait() + if err = recordTerminalBurnObservation(run, contenderObservation); err != nil { + return err + } + if err = requireTerminalBurnLoser(contenderObservation); err != nil { + return err + } + return assertTerminalBurnResiduesAbsent(run.sandbox, terminalBurnLineage) +} + +func assertTerminalBurnResiduesAbsent(sandbox *Sandbox, lineage string) error { + common, err := terminalBurnGitCommonDir(sandbox) + if err != nil { + return err + } + base := filepath.Join(common, "gentle-ai", "review-transactions") + for _, path := range []string{filepath.Join(base, "v2", lineage), filepath.Join(base, "effect-markers", "v1", lineage), filepath.Join(base, "incidents", lineage)} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("terminal burn residue at %s: %v", path, err) + } + } + entries, err := os.ReadDir(base) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + prefix := ".review-burn-v2-" + lineage + "-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + return fmt.Errorf("terminal burn staging residue at %s", entry.Name()) + } + } + return nil +} + +func terminalBurnGitCommonDir(sandbox *Sandbox) (string, error) { + cmd := exec.Command("git", "-C", sandbox.Repo, "rev-parse", "--git-common-dir") + env := sandbox.env() + for index := range env { + if strings.HasPrefix(env[index], "GIT_TRACE=") { + env[index] = "GIT_TRACE=" + } + } + cmd.Env = env + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("resolve Git common directory: %w", err) + } + common := strings.TrimSpace(string(output)) + if !filepath.IsAbs(common) { + common = filepath.Join(sandbox.Repo, common) + } + return filepath.Clean(common), nil +} diff --git a/bench/axis_terminal_burn_test.go b/bench/axis_terminal_burn_test.go new file mode 100644 index 0000000000..c25b5d8a81 --- /dev/null +++ b/bench/axis_terminal_burn_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTerminalBurnAxisDeclaresItsFixtureBoundary(t *testing.T) { + for _, axis := range Axes() { + if axis.Name != terminalBurnAxis { + continue + } + if axis.BlackBox { + t.Fatal("terminal-burn uses the bench_fixture admission seam and is not black-box") + } + journeys := axis.Journeys() + if len(journeys) != 1 || !strings.HasPrefix(journeys[0].ID, "tb01-") { + t.Fatalf("terminal-burn journeys = %+v, want one tb01 journey", journeys) + } + if journeys[0].Review != reviewOptedIn { + t.Fatalf("terminal-burn review precondition = %q, want opted-in", journeys[0].Review) + } + return + } + t.Fatal("terminal-burn axis is not registered") +} diff --git a/internal/reviewtransaction/bench_fixture.go b/internal/reviewtransaction/bench_fixture.go index b892cddd9e..4cd46d809b 100644 --- a/internal/reviewtransaction/bench_fixture.go +++ b/internal/reviewtransaction/bench_fixture.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "strings" + "time" ) // benchCrashAtPhaseFired makes the deterministic crash-position hook below @@ -16,6 +17,7 @@ import ( // following resume run are always separate processes, so no explicit reset // between them is needed or possible. var benchCrashAtPhaseFired bool +var benchFinalizeAdmissionBarrierFired bool // init overrides compactReclaimPhaseHook — the exact seam // TestAuthorityDispositionResumeCrashPositionMatrix (authority_disposition_resume_test.go) @@ -47,4 +49,35 @@ func init() { } return original(ctx, current, record) } + + // GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER is a bench-only process + // barrier. It publishes readiness, then lets the harness release this real + // FINALIZE after a competing process has completed the terminal burn. + originalFinalizeAdmissionHook := finalizeAttemptAdmissionHook + finalizeAttemptAdmissionHook = func(ctx context.Context, lineageID string) error { + if target := os.Getenv("GENTLE_AI_BENCH_FINALIZE_ADMISSION_BARRIER"); target != "" && !benchFinalizeAdmissionBarrierFired { + parts := strings.SplitN(target, "|", 3) + if len(parts) == 3 && parts[0] == lineageID { + benchFinalizeAdmissionBarrierFired = true + if err := os.WriteFile(parts[1], []byte("ready\n"), 0o600); err != nil { + return err + } + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(parts[2]); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } + } + } + return originalFinalizeAdmissionHook(ctx, lineageID) + } } diff --git a/internal/reviewtransaction/finalize_attempt_journal.go b/internal/reviewtransaction/finalize_attempt_journal.go index 096a7a487d..cf30730609 100644 --- a/internal/reviewtransaction/finalize_attempt_journal.go +++ b/internal/reviewtransaction/finalize_attempt_journal.go @@ -19,6 +19,10 @@ const finalizeAttemptJournalSchema = "gentle-ai.review-finalize-attempt-journal/ var writeFinalizeAttemptAtomic = writeAtomic +// finalizeAttemptAdmissionHook is inert in product binaries. The bench_fixture +// build uses it only to hold a real concurrent FINALIZE before lock admission. +var finalizeAttemptAdmissionHook = func(context.Context, string) error { return nil } + // FinalizeAttemptRequest is a content-bound FINALIZE invocation. Input paths // intentionally do not appear here: paths are transport, their payloads are // the authority-relevant request. @@ -157,11 +161,21 @@ func (store CompactStore) ReconcileFinalizeAttempt(ctx context.Context, request if err := validateFinalizeAttemptRequest(store.lineageID, request); err != nil { return FinalizeAttempt{}, false, err } + if err := finalizeAttemptAdmissionHook(ctx, store.lineageID); err != nil { + return FinalizeAttempt{}, false, err + } lock, err := acquireStoreLock(store.lockPath) if err != nil { return FinalizeAttempt{}, false, err } defer lock.release() + // The shared maintenance and compact version locks are held here, so this + // absence check cannot race a terminal burn. A late contender must not treat + // a burned lineage's missing journal as a fresh attempt: writing it would + // recreate authoritative lineage state below the already-burned path. + if _, err := os.Stat(store.StatePath()); err != nil { + return FinalizeAttempt{}, false, err + } journal, err := store.loadFinalizeAttemptJournalLocked() if err != nil { return FinalizeAttempt{}, false, err diff --git a/internal/reviewtransaction/finalize_attempt_journal_test.go b/internal/reviewtransaction/finalize_attempt_journal_test.go index c4d5a7e359..a5c6378c58 100644 --- a/internal/reviewtransaction/finalize_attempt_journal_test.go +++ b/internal/reviewtransaction/finalize_attempt_journal_test.go @@ -267,6 +267,27 @@ func TestFinalizeAttemptCompletionSyncFailureIsSafelyReplayable(t *testing.T) { } } +func TestFinalizeAttemptLateAfterBurnCannotRecreateLineage(t *testing.T) { + repo := initSnapshotRepo(t) + writeSnapshotFile(t, repo, "tracked.txt", "candidate\n") + state, store, _ := approvedCompactRevisionFixture(t, repo, "finalize-late-after-burn") + record, err := store.Load() + if err != nil { + t.Fatal(err) + } + request := finalizeAttemptTestRequest(state.LineageID, record.Revision, "late attempt") + if err := BurnApprovedCompactAuthority(context.Background(), repo, state.LineageID, record.Revision); err != nil { + t.Fatalf("burn approved compact authority: %v", err) + } + + if _, _, err := store.BeginFinalizeAttempt(context.Background(), request); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("late finalize attempt after burn = %v, want absent authority", err) + } + if _, err := os.Lstat(store.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("late finalize attempt recreated burned lineage: %v", err) + } +} + func finalizeAttemptTestRequest(lineage, revision, evidence string) FinalizeAttemptRequest { request := FinalizeAttemptRequest{ LineageID: lineage, ExpectedRevision: revision, From 693f6dbb079ab55221119200703525674958906a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:43:22 +0000 Subject: [PATCH 2/2] test(bench): tighten terminal-burn evidence --- bench/axis_terminal_burn.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bench/axis_terminal_burn.go b/bench/axis_terminal_burn.go index 8ab7341f29..302e749091 100644 --- a/bench/axis_terminal_burn.go +++ b/bench/axis_terminal_burn.go @@ -112,7 +112,10 @@ func waitTerminalBurnReady(process *terminalBurnProcess, path string) error { select { case err := <-process.done: process.done <- err - return fmt.Errorf("held FINALIZE contender exited before admission barrier: %v", err) + if err != nil { + return fmt.Errorf("held FINALIZE contender exited before admission barrier: %w", err) + } + return errors.New("held FINALIZE contender exited before admission barrier") case <-timeout.C: return errors.New("timed out waiting for the FINALIZE contender admission barrier") case <-ticker.C: @@ -127,6 +130,7 @@ type terminalBurnFailure struct { Phase string `json:"phase"` Code string `json:"code"` Message string `json:"message"` + Cause string `json:"cause"` MutationOutcome string `json:"mutation_outcome"` AuthorityApplicability string `json:"authority_applicability"` RetrySafe bool `json:"retry_safe"` @@ -145,7 +149,7 @@ func requireTerminalBurnLoser(observation Observation) error { if err := json.Unmarshal([]byte(payload), &fields); err != nil { return fmt.Errorf("parse negotiated FINALIZE loser: %w", err) } - for _, name := range []string{"schema", "contract", "operation", "phase", "code", "message", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "lineage_id", "required_inputs", "next_action"} { + for _, name := range []string{"schema", "contract", "operation", "phase", "code", "message", "cause", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "lineage_id", "required_inputs", "next_action"} { if _, ok := fields[name]; !ok { return fmt.Errorf("negotiated FINALIZE loser omitted contract field %q", name) } @@ -155,9 +159,9 @@ func requireTerminalBurnLoser(observation Observation) error { return fmt.Errorf("decode negotiated FINALIZE loser: %w", err) } if failure.Schema != "gentle-ai.review-integration.failure/v2" || failure.Contract != terminalBurnContract || failure.Operation != "review.finalize" || - failure.Phase == "" || failure.Code == "" || failure.Message == "" || failure.MutationOutcome == "" || failure.MutationOutcome == "committed" || - failure.AuthorityApplicability == "" || (failure.Replayability != "not_replayable" && failure.Replayability != "exact_replay_safe" && failure.Replayability != "status_required" && failure.Replayability != "manual_action_required") || - failure.LineageID != terminalBurnLineage || failure.RequiredInputs == nil || failure.NextAction == "" { + failure.Phase == "" || failure.Code != "operation_outcome_unknown" || failure.Message == "" || failure.Cause == "" || failure.MutationOutcome != "unknown" || + failure.AuthorityApplicability != "not_evaluated" || failure.RetrySafe || failure.Replayability != "status_required" || + failure.LineageID != terminalBurnLineage || failure.RequiredInputs == nil || failure.NextAction != "review.status" { return fmt.Errorf("negotiated FINALIZE loser classification = %+v", failure) } return nil @@ -166,7 +170,7 @@ func requireTerminalBurnLoser(observation Observation) error { func recordTerminalBurnObservation(run *journeyRun, observation Observation) error { gitCalls := run.sandbox.gitCallsSince() if gitCalls == nil { - return errors.New("Git subprocess count for direct FINALIZE observation was unavailable") + return errors.New("git subprocess count for direct FINALIZE observation was unavailable") } record := run.accumulator.observe(run.step, observation, gitCalls, false) run.accumulator.records = append(run.accumulator.records, record)