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) } }