textmachine/backend/internal/pipeline/bankdecisions_test.go

772 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/store"
)
// The two conventional suffixes, named once so the pins below read as the convention rather than as
// string literals that could drift from it.
const (
configMinedDeltaSuffix = config.MinedDeltaSuffix
configMinedRejectsSuffix = config.MinedRejectsSuffix
)
// bankdecisions_test.go: the WIRING contract of `bank-apply` — the lock, the writes and what is NOT
// written. The decision semantics are pinned one layer down (membank/decisions_test.go); everything here
// is about the file system and the refusal classes, which is where a partial write or a lost lock lives.
// decideProject writes a minimal book that declares NEITHER decision key, which is the case the
// convention default exists for and the one the platform produces.
func decideProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "source.txt"), "текст")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: decide-book
title: Т
source_lang: zh
target_lang: ru
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0 }
`)
return filepath.Join(dir, "book.yaml")
}
func decisionsDoc(t *testing.T, dir string, ds ...membank.Decision) string {
t.Helper()
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "decide-book", Decisions: ds})
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, fmt.Sprintf("decisions-%d.json", len(ds)))
writeFile(t, p, string(body))
return p
}
func approveDecision(src, dst string) membank.Decision {
return membank.Decision{Action: membank.ActionApprove, Src: src, Dst: dst}
}
// inode identifies a file across a rewrite. writeFileAtomic replaces through a temp file + rename, so a
// rewrite ALWAYS changes the inode — which makes this the exact test for "wrote nothing", stronger than
// comparing bytes (identical bytes written again would pass a byte comparison and fail this).
func inode(t *testing.T, path string) uint64 {
t.Helper()
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
sys, ok := st.Sys().(*syscall.Stat_t)
if !ok {
t.Skip("no inode on this platform")
}
return sys.Ino
}
func TestBankApplyWritesTheConventionalFiles(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !rep.Changed || rep.Version != membank.DecisionsReportVersion {
t.Fatalf("report = %+v", rep)
}
// Law п.3: the answer carries the document version. §4.2: it carries the DEPTH as an explicit field,
// because a caller must not assume a corrected term re-forms the draft.
if rep.Depth != membank.DecisionDepth {
t.Errorf("depth = %q, want %q", rep.Depth, membank.DecisionDepth)
}
// Both lists are lists, never null: a consumer has one shape to iterate.
if rep.Accepted == nil || rep.Rejected == nil {
t.Errorf("accepted/rejected must serialize as [] not null: %+v", rep)
}
if rep.Files.MinedDelta != filepath.Join(dir, "decide-book.mined-delta.yaml") {
t.Errorf("the report must name the file it wrote: %q", rep.Files.MinedDelta)
}
raw, err := os.ReadFile(rep.Files.MinedDelta)
if err != nil {
t.Fatalf("the conventional delta must exist after an accepted decision: %v", err)
}
if !strings.Contains(string(raw), "status: approved") || !strings.Contains(string(raw), "Фан Юань") {
t.Errorf("delta:\n%s", raw)
}
// The reject list was not touched by an approval, so it must not have been CREATED either: a file in
// advance is exactly what the convention default abolishes.
if _, err := os.Stat(rep.Files.MinedRejects); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a decision that decided nothing about rejects created the reject file: %v", err)
}
}
// TestBankApplyRepeatIsAByteNoOp is the mutation pin for "the repeat call writes the file again". Inode
// identity is what makes it a pin: an implementation that re-renders and re-writes identical bytes passes
// every content check and fails this one.
func TestBankApplyRepeatIsAByteNoOp(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
if _, err := ApplyBankDecisions(t.Context(), cfg, doc, false); err != nil {
t.Fatalf("first apply: %v", err)
}
delta := filepath.Join(dir, "decide-book.mined-delta.yaml")
before, beforeIno := readAll(t, delta), inode(t, delta)
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the repeat of an applied decision must succeed: %v", err)
}
if rep.Changed {
t.Error("a repeat call must report changed=false")
}
if len(rep.Accepted) != 1 || rep.Accepted[0].State != membank.StateAlreadyApplied {
t.Errorf("the report must say «already applied», got %+v", rep.Accepted)
}
if got := readAll(t, delta); got != before {
t.Errorf("the file changed:\n%s\n---\n%s", before, got)
}
if got := inode(t, delta); got != beforeIno {
t.Errorf("the file was REWRITTEN with identical bytes (inode %d → %d) — a no-op must not write", beforeIno, got)
}
// A hand-written head comment is the sharpest form of the same assertion, because it is the thing a
// re-render destroys and no content check would notice: the round-trip through the schema preserves
// every RECORD and no comment. This is the shape the incident took when a planted mutation was left
// in the tree — it is what this test caught, so it is what it asserts.
writeFile(t, delta, "# the owner's own note about this delta\n"+before)
handIno := inode(t, delta)
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the repeat over a hand-commented file must succeed: %v", err)
}
if rep.Changed {
t.Error("changed=true on a call that decided nothing — the report itself must not lie")
}
if got := readAll(t, delta); !strings.HasPrefix(got, "# the owner's own note") {
t.Errorf("the owner's comment was destroyed by a call that decided nothing:\n%s", got)
}
if got := inode(t, delta); got != handIno {
t.Errorf("the file was rewritten (inode %d → %d) by a call that decided nothing", handIno, got)
}
}
func readAll(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// TestBankApplyDryRunWritesNothing: the projection is obtained WITHOUT the mutation (law п.7). A verb
// that printed the projection and wrote in the same call would not have implemented the rule.
func TestBankApplyDryRunWritesNothing(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
if err != nil {
t.Fatalf("projection: %v", err)
}
if rep.Mode != "projection" || !rep.Changed {
t.Errorf("a projection must SAY it is one and still report what would change: %+v", rep)
}
if len(rep.Accepted) != 1 || rep.Accepted[0].State != membank.StateApplied {
t.Errorf("the projection must name what it would apply: %+v", rep.Accepted)
}
for _, p := range []string{rep.Files.MinedDelta, rep.Files.MinedRejects} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the projection wrote %s", p)
}
}
}
// TestBankApplyRefusesAPartialSet is the mutation pin for a PARTIAL write at the file level: one refused
// decision out of two and NEITHER file exists afterwards.
func TestBankApplyRefusesAPartialSet(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
doc := decisionsDoc(t, dir,
approveDecision("方源", "Фан Юань"),
membank.Decision{Action: membank.ActionApprove, Src: "花月"}) // no dst
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err == nil {
t.Fatal("a set with a refused decision must refuse as a whole")
}
// Its OWN class, not the config one: 10 means the operator fixes a deployment and the platform
// exempts it from the book's attempt budget as a host-wide condition; this means the END USER
// re-decides one document on one book.
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
t.Errorf("refusal class = %q, want %q", got, RefusalDecisionsRejected)
}
if rep.Changed || len(rep.Rejected) != 1 {
t.Errorf("the report must name the refusal and claim no change: %+v", rep)
}
if _, err := os.Stat(rep.Files.MinedDelta); !errors.Is(err, os.ErrNotExist) {
t.Error("nine accepted decisions out of ten are not nine decisions to write")
}
}
// TestADeclineDoesNotRewriteTheDelta: a decision touches ONE document, and the other must not be
// re-rendered as collateral. Found by review — a decline used to canonicalize a hand-formatted delta it
// had no opinion about, destroying an operator's comments on a call that never mentioned that file.
func TestADeclineDoesNotRewriteTheDelta(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
delta := filepath.Join(dir, "decide-book.mined-delta.yaml")
hand := "# the owner's own notes about this delta\nterms:\n - src: 老王\n dst: Лао Ван\n status: approved\n"
writeFile(t, delta, hand)
doc := decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源"})
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("apply: %v", err)
}
if got := readAll(t, delta); got != hand {
t.Errorf("the delta was rewritten by a decision that did not touch it:\n%s", got)
}
if !strings.Contains(readAll(t, rep.Files.MinedRejects), "方源") {
t.Error("the decline itself did not land")
}
}
// TestBankApplyRefusesABusyProject is the mutation pin for a BLOCKING lock, and for the class it must
// refuse with: exactly 12 (project_locked), never 10. The band distinguishes them non-cosmetically —
// 10 means a human has to fix a deployment, 12 means wait and retry (D39.134 п.2а).
func TestBankApplyRefusesABusyProject(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
held, err := store.LockProject(filepath.Join(dir, "decide-book.db"))
if err != nil {
t.Fatalf("hold the project: %v", err)
}
defer held.Release()
done := make(chan error, 1)
go func() {
_, aerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
done <- aerr
}()
// It must come back AT ONCE. A blocking take would sit here until the run that holds the project
// finishes — hours — which is the whole difference between "nothing happened, retry" and a wedged
// worker. The bound is what makes that a test rather than a hope.
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("the verb is still waiting for a project another process holds — the flock must be taken NON-blocking")
}
if err == nil {
t.Fatal("a busy project must be refused, not queued behind the run that holds it")
}
if got := refusalClassOf(err); got != RefusalProjectLocked {
t.Fatalf("refusal class = %q, want %q", got, RefusalProjectLocked)
}
// The projection takes the same lock: a projection computed against state another process is
// rewriting is not a projection of anything.
if _, derr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true); refusalClassOf(derr) != RefusalProjectLocked {
t.Errorf("the projection mode must respect the same arbiter, got %v", derr)
}
}
// TestBankApplyReleasesTheLock: a verb that kept the flock would block the very run its decisions are for.
func TestBankApplyReleasesTheLock(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
if _, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false); err != nil {
t.Fatalf("apply: %v", err)
}
l, err := store.LockProject(filepath.Join(dir, "decide-book.db"))
if err != nil {
t.Fatalf("the project is still held after the verb returned: %v", err)
}
l.Release()
}
func TestBankApplyRefusesDecisionsForAnotherBook(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "some-other-book",
Decisions: []membank.Decision{approveDecision("方源", "Фан Юань")}})
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "foreign.json")
writeFile(t, p, string(body))
if _, err := ApplyBankDecisions(t.Context(), cfg, p, false); err == nil {
t.Fatal("decisions computed for another book must be refused")
}
}
// TestBankApplyDeclineThenApproveConverges walks the replacement semantics end to end on real files.
func TestBankApplyDeclineThenApproveConverges(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
decline := decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源", Note: "n"})
if _, err := ApplyBankDecisions(t.Context(), cfg, decline, false); err != nil {
t.Fatalf("decline: %v", err)
}
rejects := filepath.Join(dir, "decide-book.mined-rejects.yaml")
if !strings.Contains(readAll(t, rejects), "方源") {
t.Fatalf("the decline was not recorded:\n%s", readAll(t, rejects))
}
// A second decision REPLACES the first; there is no "return to undecided" in between.
body := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
rep, err := ApplyBankDecisions(t.Context(), cfg, body, false)
if err != nil {
t.Fatalf("approve after decline: %v", err)
}
if got := readAll(t, rejects); strings.Contains(got, "方源") {
t.Errorf("the withdrawn decline is still on the reject list:\n%s", got)
}
if len(rep.Accepted) != 1 || len(rep.Accepted[0].Replaced) == 0 {
t.Errorf("the report must name what the decision displaced: %+v", rep.Accepted)
}
}
// TestTheRunReadsTheConventionalDelta closes the loop the whole pack exists for: the file `bank-apply`
// writes is the file `translate` reads. Both halves default the path by the same convention, and a book
// that declares NEITHER key still gets its owner's decisions into the bank.
//
// Without this the two sides could each be correct about their own path and still never meet — which is
// exactly the state the seam was in before D39.156, one level up.
func TestTheRunReadsTheConventionalDelta(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
dir := filepath.Dir(bookPath)
// Written the way the verb writes it, through the verb's own renderer.
res := membank.ApplyDecisions(membank.ApplyInput{Decisions: []membank.Decision{
{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}})
body, err := membank.RenderSeedFile(res.Delta)
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), string(body))
r := newRunner(t, bookPath)
defer r.Close()
if err := r.seedGlossary(context.Background()); err != nil {
t.Fatalf("seed the bank: %v", err)
}
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
for _, e := range rows {
if e.Src != "図書館" {
continue
}
if e.Dst != "библиотека" || e.Status != "approved" {
t.Fatalf("the decision reached the bank malformed: %+v", e)
}
// Source "mined" is what keeps the promise the report's `depth` field makes: the term folds into
// the ENRICHED bank the editor sees and NOT into the base the draft wave selects over, so a
// correction costs one edit-wave re-pin rather than the whole draft.
if e.Source != "mined" {
t.Fatalf("source = %q, want \"mined\" — a seed-sourced row would move the BASE snapshot and re-pay the draft wave", e.Source)
}
return
}
t.Fatalf("the conventional mined-delta was not read by the run; bank has %d rows", len(rows))
}
// refusalClassOf extracts the refusal class, or "" when the error is not one.
func refusalClassOf(err error) RefusalClass {
var r *Refusal
if errors.As(err, &r) {
return r.Class
}
return ""
}
// --- the dofix pack ---------------------------------------------------------------------------------
func TestBankApplyRefusesAProjectAN_RUNIsHolding(t *testing.T) {
// The arbiter, held the way a RUN holds it. The sibling test takes the lock with store.LockProject —
// the same function bank-apply calls — so it proved that LockProject conflicts with LockProject and
// nothing about the property the law states (17-seam-inbound-law п.2: «the SAME flock of the
// project»). Renaming the lock file inside LockProject left it green while the door and the run
// stopped excluding each other entirely, which is the state in which a decision lands inside a live
// run's snapshot non-deterministically.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
db := filepath.Join(dir, "decide-book.db")
run, err := store.Open(db) // exactly what a run does, and the only honest way to hold it
if err != nil {
t.Fatalf("open the project as a run does: %v", err)
}
defer run.Close()
done := make(chan error, 1)
go func() {
_, aerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
done <- aerr
}()
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("the verb is waiting for a project a RUN holds — the flock must be taken non-blocking")
}
if got := refusalClassOf(err); got != RefusalProjectLocked {
t.Fatalf("a project a run holds must refuse with %q, got %q (err=%v)", RefusalProjectLocked, got, err)
}
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
t.Error("the refused call wrote a decision file anyway")
}
}
func TestTheReportCarriesTheTwoFieldsThePackMitigatesItsRisksWith(t *testing.T) {
// Two published fields, each the whole mitigation of a named risk: canonical_rewrite is the only
// warning that applying will DESTROY an operator's comments (the rewrite is irreversible at the engine
// level), and preexisting_problems is the only thing that stops the verb answering «applied, exit 0»
// about a book whose next run is already going to die. Zeroing both left the battery green.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
// A hand-authored delta: an operator's comment and a term that ALREADY cannot load (approved, no dst).
writeFile(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix),
"# решения владельца, правлено вручную\nterms:\n - src: 甲\n dst: \"\"\n status: approved\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
if err != nil {
t.Fatalf("a pre-existing fault must not refuse an unrelated decision: %v (%+v)", err, rep.Rejected)
}
if !rep.CanonicalRewrite || !rep.CanonicalRewriteDelta {
t.Errorf("applying will re-render a hand-formatted file this call WRITES; the projection must say so: %+v", rep)
}
if rep.CanonicalRewriteRejects {
t.Error("the reject list is untouched by this call and must not raise the warning")
}
if len(rep.PreexistingProblems) == 0 {
t.Error("the fault the file already has must be reported, or exit 0 is a lie about the next run")
}
// …and a book whose files the ENGINE wrote raises neither, so the fields cannot read as noise.
clean := decideProject(t)
cleanDir := filepath.Dir(clean)
if _, err := ApplyBankDecisions(t.Context(), clean, decisionsDoc(t, cleanDir, approveDecision("方源", "Фан Юань")), false); err != nil {
t.Fatal(err)
}
rep2, err := ApplyBankDecisions(t.Context(), clean, decisionsDoc(t, cleanDir, approveDecision("花月", "Хуа Юэ")), true)
if err != nil {
t.Fatal(err)
}
if rep2.CanonicalRewrite || len(rep2.PreexistingProblems) != 0 {
t.Errorf("a book the engine wrote must raise neither field: %+v", rep2)
}
}
func TestAnAbsentDecisionFileIsUndecidedAndAnUnreadableOneIsAnError(t *testing.T) {
// The declared-path era is retired (§4.9(б)): every decision path is conventional, so ABSENT is one
// state only — «nobody has decided anything yet». What must NOT blur into it is UNREADABLE: a file
// that exists and cannot be stat-ed is a host problem, not an empty decision history.
missing := filepath.Join(t.TempDir(), "gone.yaml")
if present, err := decisionFilePresent(missing); err != nil || present {
t.Errorf("an absent conventional path is «nothing decided yet», got present=%v err=%v", present, err)
}
there := filepath.Join(t.TempDir(), "here.yaml")
writeFile(t, there, "rejects: []\n")
if present, err := decisionFilePresent(there); err != nil || !present {
t.Errorf("an existing file is present, got %v / %v", present, err)
}
locked := filepath.Join(t.TempDir(), "dir", "under.yaml")
if err := os.MkdirAll(filepath.Dir(locked), 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, locked, "rejects: []\n")
if err := os.Chmod(filepath.Dir(locked), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(filepath.Dir(locked), 0o755) })
if present, err := decisionFilePresent(locked); err == nil {
t.Errorf("an unreadable path must stay a loud error, got present=%v err=nil", present)
}
}
func TestTheDecisionDocumentIsCapped(t *testing.T) {
// The one input to this engine whose size a USER chooses. Uncapped, the engine's answer to running out
// of memory is `fatal error: out of memory` and exit 2 — and the platform reads 2 as «the command DID
// its work», so the death of the engine reads as a successful run. Measured on this machine:
// 20 000 decisions is ~1.7 MB of document, ~50 s and ~745 MB of RSS, already past the platform's
// 60-second budget for one engine call.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
var b strings.Builder
b.WriteString(`{"decisions_version":"tm-bank-decisions-v1","book_id":"decide-book","decisions":[`)
for i := 0; b.Len() <= maxDecisionsBytes; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"action":"approve","src":"t%d","dst":"d%d","note":"%s"}`, i, i, strings.Repeat("x", 200))
}
b.WriteString("]}")
p := filepath.Join(dir, "big.json")
writeFile(t, p, b.String())
_, err := ApplyBankDecisions(t.Context(), cfg, p, false)
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
t.Fatalf("an over-sized document must be refused IN THE BAND, got class %q err=%v", got, err)
}
if !strings.Contains(err.Error(), "split it") {
t.Errorf("the refusal must say what to do about it: %v", err)
}
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
t.Error("the refused call wrote a decision file")
}
// A document just UNDER the cap is applied, so the bound is a bound and not a ban.
small := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
if _, err := ApplyBankDecisions(t.Context(), cfg, small, false); err != nil {
t.Fatalf("an ordinary document must still apply: %v", err)
}
}
func TestTheDecisionCOUNTIsCappedBecauseTheBYTESCannotBound(t *testing.T) {
// The bound that actually holds the call inside a caller's timeout. A byte cap cannot: the cheapest
// lawful decision is ~35 bytes (`{"action":"decline","src":"t1"}`) against ~85 for an approval with a
// note, so 1 MiB admits ~29 000 declines — MEASURED at 308 s, five times the platform's 60-second
// budget for one engine call, and killed on every retry forever with no refusal ever printed. Found
// by review of the first version of this cap, which claimed the bytes bounded the time.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
var b strings.Builder
b.WriteString(`{"decisions_version":"tm-bank-decisions-v1","book_id":"decide-book","decisions":[`)
for i := 0; i <= maxDecisions; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"action":"decline","src":"t%d"}`, i)
}
b.WriteString("]}")
if b.Len() > maxDecisionsBytes {
t.Fatalf("test premise broken: %d minimal decisions are %d bytes, over the byte cap — the two bounds no longer overlap the way this pin needs", maxDecisions+1, b.Len())
}
p := filepath.Join(dir, "many.json")
writeFile(t, p, b.String())
_, err := ApplyBankDecisions(t.Context(), cfg, p, false)
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
t.Fatalf("a document over the decision COUNT must be refused in the band, got class %q err=%v", got, err)
}
if !strings.Contains(err.Error(), "split it") {
t.Errorf("the refusal must say what to do: %v", err)
}
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
t.Error("the refused call wrote a decision file")
}
}
func TestAStopRequestWritesNothing(t *testing.T) {
// SIGTERM means stop, and for an all-or-nothing $0 verb that is the whole of it: drop the work, let go
// of the lock, write nothing. Before this the verb ignored the signal entirely — it ran to completion
// and WROTE, which is the one thing a process asked to stop must not do. tmctl maps a cancelled
// context to exit 5 (graceful stop), which the platform records as `stopped` when it has a stop
// request on file and as `failed` otherwise — NOT `paused`, which is exit 4
// (platform/internal/runs/reconcile.go:700-712, :756).
cfg := decideProject(t)
dir := filepath.Dir(cfg)
// TWO cases, and only the second is about the check that matters. A context cancelled BEFORE the call
// is caught by the early check and never reaches the write at all — a pin built on it alone would go
// green with the last check removed, which is the state in which a stop request still writes. The
// double is what puts the cancellation exactly in the window between deciding and writing.
for _, tc := range []struct {
name string
ctx func() context.Context
}{
{"cancelled before the call", func() context.Context {
c, cancel := context.WithCancel(t.Context())
cancel()
return c
}},
{"cancelled while the decision is being computed", func() context.Context {
return &cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)}
}},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
_, err := ApplyBankDecisions(tc.ctx(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if !errors.Is(err, context.Canceled) {
t.Fatalf("a cancelled call must come back cancelled (exit 5), got %v", err)
}
for _, name := range []string{"decide-book" + configMinedDeltaSuffix, "decide-book" + configMinedRejectsSuffix} {
if _, serr := os.Stat(filepath.Join(dir, name)); serr == nil {
t.Errorf("a stopped call wrote %s", name)
}
}
})
}
_, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if err != nil {
t.Fatalf("an ordinary call must still work: %v", err)
}
}
func TestAStoppedCallDoesNotPRINTThatItApplied(t *testing.T) {
// The exit code was the ONLY thing separating «applied» from «stopped, wrote nothing». The report —
// which the CLI prints on this path too and whose own doc calls it «the whole of its answer» — went on
// saying mode:"apply", changed:true with every decision state:"applied". A consumer that keys on the
// report rather than on the code records the owner's approvals as delivered and never re-sends them.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
rep, err := ApplyBankDecisions(&cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)},
cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if !errors.Is(err, context.Canceled) {
t.Fatalf("want a cancelled call, got %v", err)
}
if rep.Mode == "apply" || rep.Changed {
t.Fatalf("a call that wrote nothing must not report that it applied: mode=%q changed=%v", rep.Mode, rep.Changed)
}
// …and the ACCEPTED LIST is what a consumer iterates. Leaving it full of entries stamped
// `state: applied` is the same claim in the field that actually carries it, and the refused path
// already clears it for exactly this reason.
if len(rep.Accepted) != 0 {
t.Fatalf("a stopped call must not list decisions as applied: %+v", rep.Accepted)
}
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
t.Error("…and it must not have written")
}
}
func TestARefusedCallDoesNotWarnAboutARewriteThatWillNotHappen(t *testing.T) {
// canonical_rewrite says «applying will re-render this file and destroy its comments», and its own doc
// says it is actionable only BEFORE the mutation. On a refused call there is no mutation and no moment
// — but the rollback leaves *Touched standing, so the warning fired anyway. A destructive-action dialog
// raised for a call that wrote nothing is how a real warning gets trained out of an operator.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
// A hand-formatted delta (so the warning WOULD be true of a writing call) plus a set that is refused:
// the second decision decides the same surface twice, which is ill-formed.
writeFile(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix),
"# правлено вручную\nterms:\n - src: 花月\n dst: Хуа Юэ\n status: approved\n")
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"), approveDecision("方源", "Другое"))
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("test premise broken: want the set refused, got %v", err)
}
if rep.CanonicalRewrite || rep.CanonicalRewriteDelta || rep.CanonicalRewriteRejects {
t.Fatalf("a refused call writes nothing, so it must warn about no rewrite: %+v", rep)
}
// …and the warning still fires for a call that WILL write.
ok, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
if err != nil {
t.Fatal(err)
}
if !ok.CanonicalRewriteDelta {
t.Fatalf("the warning must still be raised for a projection of a call that would re-render: %+v", ok)
}
}
// cancelAfterNChecks is a context that answers "not cancelled" a fixed number of times and "cancelled"
// after that — a stop arriving at a chosen point INSIDE the call.
//
// ApplyBankDecisions asks the context exactly once per decision point, so `after` selects which one the
// signal lands on. A test double rather than a race with a real goroutine: the window this is about is
// microseconds wide and a timing-based pin for it would be a flake, not a gate.
type cancelAfterNChecks struct {
context.Context
after int
seen *int
}
func (c *cancelAfterNChecks) Err() error {
*c.seen++
if *c.seen > c.after {
return context.Canceled
}
return nil
}
func TestChangedDocIsTheSecondFenceAndItsContractIsPinnedDirectly(t *testing.T) {
// ⚠ This pin is on the FUNCTION, not on a call path, and that is a statement about the design rather
// than a shortcut. The acceptance asked for a behavioural pin on the byte gate and there cannot be
// one, because the gate is UNREACHABLE behind the first one — proved, not assumed:
//
// deltaChanged = DeltaTouched && changedDoc(disk, next, empty)
//
// DeltaTouched is set only when a decision actually changed the document, and `next` is the canonical
// render of the CHANGED document. For changedDoc to return false the bytes on disk would have to
// already BE that render — but then the document parsed from them would already be the result and no
// decision could have changed anything, so DeltaTouched would be false. (Normalization does not open a
// gap either: a file whose values carry surrounding whitespace does not render back to itself.)
//
// So the second fence is defence in depth against a FUTURE `*Touched` that is coarser than today's,
// and it is kept for that. What can still rot is the function's own contract, and that is what this
// pins: in particular «an absent file plus a document that says nothing is NOT created», which is the
// file-in-advance this whole door exists to abolish.
body := []byte("rejects:\n - src: X\n")
for _, tc := range []struct {
name string
raw, next []byte
resultEmpty bool
want bool
}{
{"absent file, empty result: do not create it", nil, []byte("rejects: []\n"), true, false},
{"absent file, a result that says something", nil, body, false, true},
{"same bytes: a retry must not rewrite", body, body, false, false},
{"different bytes", body, []byte("rejects: []\n"), true, true},
} {
if got := changedDoc(tc.raw, tc.next, tc.resultEmpty); got != tc.want {
t.Errorf("%s: changedDoc = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestASignatureMapThatCannotBeReadDoesNotReportAsAllDecided(t *testing.T) {
// `signature.undecided == 0` is the one answer this field must never give by accident. A map that
// does not parse used to come back as {surfaces: 0, undecided: 0} — byte-identical to «everything is
// decided», which is exactly the reading the field exists to make honest. Found by review.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
writeFile(t, filepath.Join(dir, "decide-book.db.mined-signature.yaml"), "terms:\n - src: 方源\n bad indent\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
if err != nil {
t.Fatalf("a broken sidecar must not refuse the decisions: %v", err)
}
if !rep.Signature.Unreadable {
t.Fatalf("a map that cannot be read must say so: %+v", rep.Signature)
}
// A map that exists but cannot be READ is the same class and used to slip through: the read error
// returned the ZERO state, which is byte-identical to «no map at all» AND to «nothing left undecided».
sig := filepath.Join(dir, "decide-book.db.mined-signature.yaml")
writeFile(t, sig, "terms:\n - src: 方源\n dst: \"\"\n status: auto\n")
if err := os.Chmod(sig, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(sig, 0o644) })
if os.Geteuid() != 0 { // root reads it anyway, and then this case cannot be produced
unreadable, uerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
if uerr != nil {
t.Fatalf("an unreadable sidecar must not refuse the decisions: %v", uerr)
}
if !unreadable.Signature.Unreadable {
t.Fatalf("a map that cannot be read must say so rather than report «nothing undecided»: %+v", unreadable.Signature)
}
}
if err := os.Chmod(sig, 0o644); err != nil {
t.Fatal(err)
}
// …and a map that CAN be read still answers the real question.
writeFile(t, filepath.Join(dir, "decide-book.db.mined-signature.yaml"),
"terms:\n - src: 方源\n dst: \"\"\n status: auto\n - src: 花月\n dst: \"\"\n status: auto\n")
rep, err = ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
if err != nil {
t.Fatal(err)
}
if rep.Signature.Unreadable || rep.Signature.Surfaces != 2 || rep.Signature.Undecided != 1 {
t.Fatalf("one of two surfaces decided: %+v", rep.Signature)
}
}