package runs import ( "context" "errors" "os" "path/filepath" "strings" "sync" "testing" "time" "textmachine/platform/internal/ingest" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/pricing" "textmachine/platform/internal/runner" ) // The correction door's service half (bank.go): the verdict over the engine's exit band, and the // per-book serialization against the run lifecycle. type fakeBankApplier struct { mu sync.Mutex out runner.BankApplyOutcome err error calls int lastDoc []byte // entered/release, when set, gate the call so a test can hold the verb mid-flight. entered chan struct{} release chan struct{} } func (f *fakeBankApplier) BankApply(_ context.Context, _, _, decisionsPath string, _ bool) (runner.BankApplyOutcome, error) { f.mu.Lock() f.calls++ f.lastDoc, _ = os.ReadFile(decisionsPath) entered, release := f.entered, f.release f.mu.Unlock() if entered != nil { close(entered) <-release } return f.out, f.err } func okReport(mode string) ingest.BankReport { return ingest.BankReport{ Version: ingest.DecisionsReportVersion, BookID: "bk", Mode: mode, Depth: ingest.DepthEditWave, Changed: true, Accepted: []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", Src: "蛊", State: "applied"}}, } } // The verdict table: every exit class of the verb lands on ITS remedy and no two remedies merge. func TestBankVerdictKeepsTheRemediesApart(t *testing.T) { svc := service(t, &fakeRunner{}, nil, time.Now()) in := BankCorrectionsInput{BookID: "bk", Preview: false} refused := okReport("refused") refused.Rejected = []ingest.RejectedDecision{{Index: 0, Reason: "inert"}} cases := []struct { name string out runner.BankApplyOutcome want error }{ {"refused = the user re-decides", runner.BankApplyOutcome{ExitCode: ingest.ExitDecisionsRejected, Exited: true, Report: refused, Decoded: true}, &ErrBankRefused{}}, {"incomplete = re-send the same", runner.BankApplyOutcome{ExitCode: ingest.ExitWriteIncomplete, Exited: true, Report: okReport("write_incomplete"), Decoded: true}, ErrBankIncomplete}, // Class 12 from the VERB is never a run: the live run was excluded under the mutex before the // spawn, so the holder is transient and the word is «retry later» — `run_in_flight` comes only // from the run row's own check (PD-400). {"a held project = a transient holder, retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitProjectLocked, Exited: true}, ErrBankUnavailable}, {"an unmigrated schema = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitSchemaMismatch, Exited: true}, ErrBankUnavailable}, {"a stop mid-call = retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitStopped, Exited: true}, ErrBankUnavailable}, {"an unknown refusal class = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitRefusedOther, Exited: true}, ErrBankUnavailable}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := svc.bankVerdict(t.Context(), in, tc.out) var refusal *ErrBankRefused if wantRefusal := errors.As(tc.want, new(*ErrBankRefused)); wantRefusal { if !errors.As(err, &refusal) || len(refusal.Refusals) != 1 { t.Fatalf("got %v, want the refusal with its reasons", err) } return } if !errors.Is(err, tc.want) { t.Errorf("got %v, want %v", err, tc.want) } }) } // The config class and exit 1 are the deployment's own fault: an internal error, none of the // three user-facing remedies. for _, code := range []int{ingest.ExitFailure, ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable} { _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: code, Exited: true}) if err == nil || errors.Is(err, ErrBankUnavailable) || errors.Is(err, ErrBankIncomplete) || errors.Is(err, pgstore.ErrRunInFlight) { t.Errorf("exit %d answered %v, want a plain internal failure", code, err) } } // A clean exit whose report did not arrive is not the engine — never a receipt. if _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: 0, Exited: true}); err == nil { t.Error("a clean exit without a report was believed") } // The report's mode is cross-checked against the asked act: an APPLY report for a preview call // would tell the user their correction landed when nothing may have. preview := in preview.Preview = true if _, err := svc.bankVerdict(t.Context(), preview, runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}); err == nil { t.Error("an apply-mode report was served as a preview receipt") } } // The receipt's shaping: the signature block is null exactly when no stop exists to count against. func TestBankReceiptCarriesTheSignatureOnlyWhenAStopExists(t *testing.T) { svc := service(t, &fakeRunner{}, nil, time.Now()) in := BankCorrectionsInput{BookID: "bk", Preview: false} rep := okReport("apply") rep.PreexistingProblems = []string{"a delta that would not load"} rec, err := svc.bankReceipt(in, rep) if err != nil { t.Fatal(err) } if rec.Signature != nil { t.Errorf("no stop yet, and the receipt counts against %+v", rec.Signature) } if rec.PreexistingFaults != 1 || rec.Depth != ingest.DepthEditWave || !rec.Changed { t.Errorf("receipt: %+v", rec) } rep.Signature = ingest.SignatureState{Map: "/x/y.mined-signature.yaml", Surfaces: 3, Undecided: 1} if rec, err = svc.bankReceipt(in, rep); err != nil || rec.Signature == nil || rec.Signature.Surfaces != 3 { t.Errorf("with a stop: %+v (%v)", rec, err) } } // Under a live run the door refuses at once with the run's own word — the verb is never spawned, so // a transient flock can never masquerade as a translation (canon: wait for the stop or the end). func TestCorrectionsRefuseWhileTheBookIsBeingTranslated(t *testing.T) { f := newFixture(t, "10", 500) fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true}} f.svc.Bank = fake if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)}); err != nil { t.Fatal(err) } _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: true, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }) if !errors.Is(err, pgstore.ErrRunInFlight) { t.Fatalf("corrections under a live run answered %v, want run_in_flight", err) } if fake.calls != 0 { t.Error("the verb was spawned under a live run") } } // The signing stop's row closes one sweep AFTER its screen opens: the journal's bank_stop event // moves the status to awaiting_bank while finished_at waits for the exit marker. The door must // open on that window — refusing run_in_flight there refuses the very screen the platform just // announced (workflow finding, P9). The case above pins the other side: a live row NOT at the // stop still refuses. func TestTheDoorOpensOnTheSigningStopWindow(t *testing.T) { f := newFixture(t, "10", 500) run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, Chapters: order(10)}) if err != nil { t.Fatal(err) } if err := f.svc.Spawn(f.ctx, run.ID); err != nil { t.Fatal(err) } fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake // What the sink writes when the engine reports the bank stop: the status moves and the run // stays LIVE, because the attempt has not ended yet (pgstore/sink.go, TypeBankStop). if _, err := f.store.Pool().Exec(f.ctx, `update runs set status = 'awaiting_bank' where id = $1`, run.ID); err != nil { t.Fatal(err) } rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }) if err != nil { t.Fatalf("the door refused the signing stop's own window: %v", err) } if fake.calls != 1 { t.Fatalf("the verb ran %d times, want 1", fake.calls) } if rec.Preview { t.Error("an apply came back as a preview") } } // The engine's byte ceiling is measured on the RENDERED document BEFORE the verb is spawned, and // the refusal is the size word (413 upstream), never the engine's «re-decide» class (workflow // finding, P9: with HTML escaping off the band above the wire cap is the envelope's few bytes, // and this gate is what answers it). func TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn(t *testing.T) { f := newFixture(t, "10", 500) fake := &fakeBankApplier{} f.svc.Bank = fake _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: true, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1", Note: strings.Repeat("g", ingest.MaxDecisionsDocument)}}, }) if !errors.Is(err, ErrBankDocumentTooLarge) { t.Fatalf("an over-cap render answered %v, want ErrBankDocumentTooLarge", err) } if fake.calls != 0 { t.Error("the verb was spawned for a document it cannot read") } } // The happy path against a real store: the rendered document reaches the verb — versioned, with the // book's own id — and the receipt answers the report. A stranger's book stays a 404. func TestCorrectionsRenderTheSeamDocumentAndAnswerTheReceipt(t *testing.T) { f := newFixture(t, "10", 500) rep := okReport("projection") rep.BookID = f.bookID(t) fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: rep, Decoded: true}} f.svc.Bank = fake rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: true, Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}}, }) if err != nil { t.Fatal(err) } if !rec.Preview || len(rec.Accepted) != 1 { t.Errorf("receipt: %+v", rec) } doc := string(fake.lastDoc) if !strings.Contains(doc, `"decisions_version":"tm-bank-decisions-v1"`) || !strings.Contains(doc, `"book_id":"`+f.bookID(t)+`"`) { t.Errorf("the verb was handed %s", doc) } if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u2", BookID: f.bookID(t), Preview: true, Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}}, }); !errors.Is(err, pgstore.ErrNoBook) { t.Errorf("a stranger's book answered %v, want ErrNoBook", err) } } // The serialization the synchronous form stands on: a resume does not re-open the run while the // verb is mid-flight on the same book — the spawned engine would die on the verb's flock, a wasted // attempt (§3.2 of the pack; the mirror half is the run_in_flight refusal above). func TestAResumeWaitsOutALiveCorrectionCall(t *testing.T) { f := newFixture(t, "10", 500) runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, Chapters: order(100)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, entered: make(chan struct{}), release: make(chan struct{}), } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake corrections := make(chan error, 1) go func() { _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }) corrections <- err }() <-fake.entered // the verb is mid-flight and the book's lock is held resumed := make(chan error, 1) go func() { _, err := f.svc.Resume(f.ctx, "u1", runID) resumed <- err }() select { case err := <-resumed: t.Fatalf("the resume completed (%v) while the correction verb held the book", err) case <-time.After(150 * time.Millisecond): } close(fake.release) if err := <-corrections; err != nil { t.Fatalf("corrections: %v", err) } if err := <-resumed; err != nil { t.Fatalf("resume after the verb: %v", err) } } // An idle book keeps no lock entry: the per-book map must not grow with every book a process ever // touched — and a waiter that LEAVES on its context must not leak its entry either. func TestTheBookLockTableDoesNotLeak(t *testing.T) { svc := service(t, &fakeRunner{}, nil, time.Now()) unlock, err := svc.lockBook(t.Context(), "bk_1") if err != nil { t.Fatal(err) } second := make(chan struct{}) go func() { u, err := svc.lockBook(t.Context(), "bk_1") if err == nil { u() } close(second) }() unlock() <-second svc.booksMu.Lock() defer svc.booksMu.Unlock() if len(svc.books) != 0 { t.Errorf("%d lock entries survive their books", len(svc.books)) } } // A caller whose context ends while it WAITS leaves the queue with an error instead of holding a // place in it to do the work for a dead request — the wait, not just the hold, is bounded // (workflow finding, P9). func TestAWaiterWhoseContextEndsLeavesTheBookQueue(t *testing.T) { svc := service(t, &fakeRunner{}, nil, time.Now()) unlock, err := svc.lockBook(t.Context(), "bk_1") if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(t.Context()) waited := make(chan error, 1) go func() { u, err := svc.lockBook(ctx, "bk_1") if err == nil { u() } waited <- err }() cancel() select { case err := <-waited: if err == nil { t.Fatal("a cancelled waiter took the lock anyway") } case <-time.After(2 * time.Second): t.Fatal("a cancelled waiter is still in the queue") } unlock() svc.booksMu.Lock() defer svc.booksMu.Unlock() if len(svc.books) != 0 { t.Errorf("%d lock entries survive the cancelled waiter", len(svc.books)) } } // Only the book's LATEST run may be resumed: an older one re-opened over a newer would open its // bar on the neighbour's finished chapters, and the whole read surface (lastRun by started_at) // would keep quoting the finished neighbour while the resumed run burns money invisibly (workflow // finding, P9; PD-402 keeps the read half). func TestAnOlderRunCannotBeResumedOverANewerOne(t *testing.T) { f := newFixture(t, "10", 500) older := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) later := f.now.Add(time.Minute) f.svc.Now = func() time.Time { return later } newer := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: later.Add(time.Second)}) if _, err := f.svc.Resume(f.ctx, "u1", older); !errors.Is(err, ErrNotResumable) { t.Fatalf("resuming an older run answered %v, want ErrNotResumable", err) } if _, err := f.svc.Resume(f.ctx, "u1", newer); err != nil { t.Fatalf("the latest run must stay resumable: %v", err) } } // Boot sweeps decision documents an unclean death left in the state directory — nothing else // deletes by that mask, and each carries up to a megabyte of a user's own corrections (workflow // finding, P9; PD-409). Files outside the mask are not the sweep's to touch. func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) { svc := service(t, &fakeRunner{}, nil, time.Now()) orphan := filepath.Join(svc.Cfg.StateDir, "bank-corrections-123.json") stranger := filepath.Join(svc.Cfg.StateDir, "run-1.exit") for _, p := range []string{orphan, stranger} { if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { t.Fatal(err) } } svc.SweepCorrectionScratch() if _, err := os.Stat(orphan); !errors.Is(err, os.ErrNotExist) { t.Error("the orphaned decision document survived the boot sweep") } if _, err := os.Stat(stranger); err != nil { t.Error("the sweep touched a file outside its mask") } } // The bank-move fact is stamped by an APPLY from the door's OWN receipt and never by a preview: // the engine's status projection is blind to a correction until the next translate folds the bank // in (errata 28.08-к), so the receipt — changed, or already_applied on a retry — is the only // witness that cannot lie about what the door just did (P10 §3.1). func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) { f := newFixture(t, "10", 500) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("projection"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: true, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) if err != nil { t.Fatal(err) } if book.BankMoved { t.Fatal("a PREVIEW recorded a bank move") } fake.mu.Lock() fake.out.Report.Mode = "apply" fake.mu.Unlock() if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved { t.Fatalf("the apply left no fact: %+v (%v)", book, err) } // A retry of the same document answers changed:false with already_applied states — and still // proves the move, which is what makes a failed fact-write retryable (corrected()). retry := okReport("apply") retry.Changed = false retry.Accepted = []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", State: "already_applied"}} retry.BookID = f.bookID(t) if err := f.svc.Store.ClearBankMove(f.ctx, f.bookID(t)); err != nil { t.Fatal(err) } fake.mu.Lock() fake.out.Report = retry fake.mu.Unlock() if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved { t.Fatalf("an already_applied retry did not re-stamp the fact: %+v (%v)", book, err) } } // The bank-move fact outlives the client that asked for it. THE money case (PD-425): the correction // verb comes back CLEAN on an already-cancelled call — runner.BankApply returns (outcome, nil) // whenever the process exited, and the engine's last look at its context is before its writes — so a // correction whose files LANDED reaches the fact-write with the request context already dead. On // r.Context() the write is refused by pgx, `bank_moved_at` stays NULL forever (no second writer, no // sweep), and the next ordinary run is admitted without --resnapshot and dies on the engine's // snapshot guard AFTER its hold: the user pays for an attempt that does nothing. The remedy the door // names for a failed write — «re-send the same document» — is by construction addressed to a client // that is gone. // // The verb is held mid-flight and the cancellation lands WHILE it runs, because that is the only // shape that reaches the write: a context cancelled before the call never gets past lockBook, which // selects on ctx.Done and answers ErrBankUnavailable. // // Mutation caught: RecordBankMove on the caller's context. func TestTheBankMoveFactSurvivesAClientThatHungUp(t *testing.T) { f := newFixture(t, "10", 500) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, entered: make(chan struct{}), release: make(chan struct{}), } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake ctx, cancel := context.WithCancel(f.ctx) defer cancel() done := make(chan error, 1) go func() { _, err := f.svc.ApplyBankCorrections(ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }) done <- err }() <-fake.entered // the verb is mid-flight and the book lock is held cancel() // the tab closes; the engine's writes have already landed close(fake.release) if err := <-done; err != nil { t.Fatalf("the door refused a correction whose files landed: %v", err) } book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) if err != nil { t.Fatal(err) } if !book.BankMoved { t.Fatal("the bank-move fact died with the client: the next ordinary run is admitted without --resnapshot and dies on the engine's guard after taking its hold") } } // A start over a moved bank decides BOTH consents once, stores them on the row, and the spawn // renders them — the argv is the admission's decision, stable across respawns (P10 §3.1). The cap // is FUNDED: the run's own hold, never a projection (which does not exist at admission). func TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn(t *testing.T) { f := newFixture(t, "10", 500) f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}) if err != nil { t.Fatal(err) } if err := f.svc.Spawn(f.ctx, run.ID); err != nil { t.Fatal(err) } f.runner.mu.Lock() spec := f.runner.started[len(f.runner.started)-1] f.runner.mu.Unlock() joined := strings.Join(spec.Args, " ") if !strings.Contains(joined, "--resnapshot") { t.Errorf("the spawn carries no --resnapshot: %q", joined) } // The consent IS the hold, funded by construction — written as the arithmetic rather than as a // literal so the assertion says why it expects what it expects. if want := "--accept-rebill=" + fixtureHold(2).USD(); !strings.Contains(joined, want) { t.Errorf("the spawn's consent is not the run's own hold (%s): %q", want, joined) } } // The fact is retired EXPLICITLY and only by success: a run that died on the guard (exit 1, $0) // leaves it standing — so the next admission still carries the flags instead of looping the guard // forever (adversarial K6) — and a ready resnapshot run clears it. func TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt(t *testing.T) { f := newFixture(t, "10", 500) f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", At: f.now.Add(2 * time.Second)}) book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) if err != nil { t.Fatal(err) } if !book.BankMoved { t.Fatal("a FAILED run retired the fact: the next run would die on the guard with no flags") } f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "0", At: f.now.Add(3 * time.Second)}) if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || book.BankMoved { t.Fatalf("a READY resnapshot run did not retire the fact: %+v (%v)", book, err) } } // The re-pass door (P10 §3.2, the deferred-projection form): on a book whose bank moved, a // re-pass admission PASSES — a zero-chapter row whose hold is the whole book's chapter price // («up to your hold»; untouched units come back at $0 and the difference is released) — and on a // book without the move it refuses with its own word. func TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne(t *testing.T) { f := newFixture(t, "10", 5) if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); !errors.Is(err, ErrRePassUnavailable) { t.Fatalf("a re-pass with no moved bank answered %v, want ErrRePassUnavailable", err) } f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}) if err != nil { t.Fatalf("the re-pass door refused a moved bank: %v", err) } var chapters int var hold int64 if err := f.store.Pool().QueryRow(f.ctx, `select ceiling_chapters from runs where id = $1`, run.ID).Scan(&chapters); err != nil { t.Fatal(err) } if err := f.store.Pool().QueryRow(f.ctx, `select amount_micro_usd from credit_ledger where kind = 'hold' order by id desc limit 1`). Scan(&hold); err != nil { t.Fatal(err) } if chapters != 0 { t.Errorf("a re-pass bought %d chapters, want 0", chapters) } // The WHOLE book's projected price, held; the unspent part is released on settlement. ⚠ Priced // from the engine's projection now, not from a per-chapter constant — the re-pass may in the worst // case re-make everything the correction touched, so its hold is the whole book's own quote. if want := -int64(fixtureHold(5)); hold != want { t.Errorf("the re-pass hold is %d micro-USD, want the whole book's %d", hold, want) } } // A RESUME over a moved bank grants the consents on re-open — a correction after a ceiling pause // mid-edit is the mine's flagship shape — and they land on the ROW, where every later spawn of // every attempt reads them (P10 §3.1). func TestAResumeOverAMovedBankGrantsTheConsents(t *testing.T) { f := newFixture(t, "10", 500) runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil { t.Fatal(err) } var resnapshot bool var consent int64 if err := f.store.Pool().QueryRow(f.ctx, `select resnapshot, accept_rebill_micro from runs where id = $1`, runID). Scan(&resnapshot, &consent); err != nil { t.Fatal(err) } // The funded cap: the run's whole budget — what three chapters were sold for. if want := int64(fixtureHold(3)); !resnapshot || consent != want { t.Fatalf("the resumed row carries resnapshot=%v consent=%d, want true/%d", resnapshot, consent, want) } } // A re-pass is bought again, not resumed (P10, adversarial K4): its budget is not chapter-derived, // and an interrupted one leaves the fact standing — the purchase is simply available again. func TestARePassIsBoughtAgainNotResumed(t *testing.T) { f := newFixture(t, "10", 5) f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}) if err != nil { t.Fatal(err) } // The re-pass is interrupted: the row closes as stopped, the fact stays (only ready retires it). if _, err := f.store.Pool().Exec(f.ctx, `update runs set status = 'stopped', finished_at = $2, stop_requested_at = $2, settled_at = $2 where id = $1`, run.ID, f.now.Add(2*time.Second)); err != nil { t.Fatal(err) } if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrNotResumable) { t.Fatalf("resuming a re-pass answered %v, want ErrNotResumable (bought again)", err) } if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); err != nil { t.Fatalf("re-buying the interrupted re-pass was refused: %v", err) } } // The consent a RESUME grants is the figure the run was SOLD for, read from its first hold — not the // same chapters priced again at today's rate, which is the same figure only while the rate stands // still (PD-168's second site, the funded consent). // // Mutation caught: `consent = budget` in reopen replaced by a fresh quote of the order — the // per-chapter rate this line used to name is gone, and the drift it guards is the same one. func TestAResumeOverAMovedBankGrantsTheConsentTheRunWasSoldFor(t *testing.T) { f := newFixture(t, "10", 500) // Part of the hold is SPENT before the stop, so the consent — the run's whole budget — and the // remainder the resume re-holds are different numbers, and a consent set to the remainder is // caught rather than coinciding. runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 30_000, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } doubled, err := pricing.New(2 * pricing.DefaultHoldFactorPercent) if err != nil { t.Fatal(err) } f.svc.Pricing = doubled if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil { t.Fatal(err) } var consent int64 if err := f.store.Pool().QueryRow(f.ctx, `select accept_rebill_micro from runs where id = $1`, runID).Scan(&consent); err != nil { t.Fatal(err) } // Three chapters at the cushion of the PURCHASE — the hold the user agreed to, not what today's // setting would reserve. if want := int64(fixtureHold(3)); consent != want { t.Fatalf("the resumed row carries consent=%d, want %d (the run's own hold), not %d at today's setting", consent, want, int64(fixtureHoldAt(2*pricing.DefaultHoldFactorPercent, 3))) } } // An interrupted RE-PASS is restarted by the sweep with what is left of its hold. Its // `ceiling_chapters` is 0, so a rate-derived budget reads as «spent» and would pause a paid re-pass // as `credit_exhausted` — the defect PD-168 names for the downward rate move, in its purest form. // The user's resume stays refused (a re-pass is bought again, K4), because that door's reasons stand // on their own; the reconciler's automatic continuation is what this pins. // // Mutation caught: `budget, err := s.Store.RunBudget(...)` in reopen replaced by a fresh quote of the order (the run pauses). func TestAnInterruptedRePassIsRestartedWithWhatIsLeftOfItsHold(t *testing.T) { f := newFixture(t, "10", 5) f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) fake := &fakeBankApplier{ out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, } fake.out.Report.BookID = f.bookID(t) f.svc.Bank = fake if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ UserID: "u1", BookID: f.bookID(t), Preview: false, Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, }); err != nil { t.Fatal(err) } run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}) if err != nil { t.Fatal(err) } if err := f.svc.Spawn(f.ctx, run.ID); err != nil { t.Fatal(err) } hold := f.account(t).Reserved // the whole book's projected price if want := fixtureHold(5); hold != want { t.Fatalf("the re-pass holds %s, want %s", hold.USD(), want.USD()) } // The machine reboots with a third of the hold spent. spent := money.MicroUSD(50_000) f.engine.set(statusSpending(spent), nil) f.runner.alive = false f.svc.Now = func() time.Time { return f.now.Add(3 * time.Hour) } if err := f.svc.Sweep(f.ctx); err != nil { t.Fatal(err) } live, err := f.store.ListLiveRuns(f.ctx) if err != nil { t.Fatal(err) } if len(live) != 1 || live[0].RunID != run.ID || live[0].AttemptNo != 2 { t.Fatalf("the interrupted re-pass was not restarted: %+v", live) } if want := hold - spent; live[0].Ceiling != want { t.Errorf("the restarted re-pass holds %s, want %s (its hold less what it spent)", live[0].Ceiling.USD(), want.USD()) } if !live[0].Resnapshot || live[0].AcceptRebill != hold { t.Errorf("the consents did not travel to the restart: resnapshot=%v consent=%s", live[0].Resnapshot, live[0].AcceptRebill.USD()) } }