Land the honesty pack: consent numbers repriced at the current table, a panicking engine no longer reads as success, and the classifier gate re-based

This commit is contained in:
Claude (backend session) 2026-08-17 19:55:16 +03:00
parent 0ef049f492
commit f840867327
23 changed files with 1446 additions and 89 deletions

View file

@ -93,7 +93,11 @@ func (v *rebillConsentValue) IsBoolFlag() bool { return true }
// main()'s default branch → exit 1, leaving 2 exclusive to flagged chunks.
func parseInvocation(args []string, flagOut io.Writer) (invocation, error) {
if len(args) < 1 {
return invocation{}, fmt.Errorf("usage: tmctl <translate|report|status|export|redrive|manifest> --config book.yaml")
// The command list is the dispatch switch's, in full (row 176): `backup` and `migrate` are
// deploy-step commands and `seed-lint` is the $0 seed validator, and a usage line that omits them
// tells the operator they do not exist. seed-lint is on its own clause because it takes --seed
// and no --config — folding it into the first clause would be the same class of lie.
return invocation{}, fmt.Errorf("usage: tmctl <translate|report|status|export|redrive|manifest|backup|migrate> --config book.yaml | tmctl seed-lint --seed glossary.yaml")
}
cmd, rest := args[0], args[1:]

View file

@ -4,7 +4,11 @@ import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io"
"strconv"
"strings"
"testing"
@ -17,12 +21,21 @@ import (
func TestParseNoArgsUsage(t *testing.T) {
_, err := parseInvocation(nil, &bytes.Buffer{})
// The command list gained `export` (D39 слой 6 read-only surface) and then `manifest` (backlog row 100
// — the $0 producer of the chapter/chunk manifest); both are deliberate contract extensions and the
// rest of the usage text stays frozen.
if err == nil || err.Error() != "usage: tmctl <translate|report|status|export|redrive|manifest> --config book.yaml" {
// The command list gained `export` (D39 слой 6 read-only surface), then `manifest` (backlog row 100
// — the $0 producer of the chapter/chunk manifest), and then the three the line had silently never
// learned: `backup`/`migrate` (the deploy step, row 174) and `seed-lint`, which gets its own clause
// because it takes --seed rather than --config (row 176, sanctioned by D39.134 п.3). Every one is a
// deliberate contract extension and the rest of the usage text stays frozen.
if err == nil || err.Error() != "usage: tmctl <translate|report|status|export|redrive|manifest|backup|migrate> --config book.yaml | tmctl seed-lint --seed glossary.yaml" {
t.Fatalf("usage error text is frozen, got: %v", err)
}
// The line and the dispatch switch are the same list: a command reachable in run() and missing here
// is exactly the defect row 176 recorded, and it grew back three times.
for _, cmd := range dispatchCommands {
if !strings.Contains(err.Error(), cmd) {
t.Errorf("the usage line must name every dispatchable command; %q is missing from: %s", cmd, err.Error())
}
}
}
func TestParseMissingConfig(t *testing.T) {
@ -237,3 +250,50 @@ func TestParseCeilingUSD(t *testing.T) {
}
}
}
// TestDispatchCommandsCoversTheSwitch closes the direction the usage-line test cannot see. That test
// walks dispatchCommands and checks each name appears in the usage string, so DROPPING a command from
// the list keeps it green while the usage line silently stops mentioning a command tmctl still runs —
// exactly the defect of row 176, which grew back three times. This reads the dispatch switch itself.
func TestDispatchCommandsCoversTheSwitch(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
inList := map[string]bool{}
for _, c := range dispatchCommands {
inList[c] = true
}
found := 0
ast.Inspect(f, func(n ast.Node) bool {
sw, ok := n.(*ast.SwitchStmt)
if !ok {
return true
}
sel, ok := sw.Tag.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "cmd" {
return true
}
for _, stmt := range sw.Body.List {
for _, expr := range stmt.(*ast.CaseClause).List {
lit, ok := expr.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
name, err := strconv.Unquote(lit.Value)
if err != nil {
t.Fatal(err)
}
found++
if !inList[name] {
t.Errorf("run() dispatches %q but dispatchCommands does not list it — the usage line will not name it", name)
}
}
}
return false
})
if found != len(dispatchCommands) {
t.Errorf("the dispatch switch has %d commands, dispatchCommands lists %d — the two must be one list", found, len(dispatchCommands))
}
}

View file

@ -1,5 +1,5 @@
// tmctl is the TextMachine CLI: translate / report / status / export / redrive / manifest / backup /
// migrate.
// migrate / seed-lint (dispatchCommands).
// main.go — thin wiring (package №4): argument parsing — invocation.go,
// output renderers — render.go, .env — dotenv.go; here just the
// «parse → env → ctx → fetch → render» wiring and exit-code mapping.
@ -9,9 +9,11 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"textmachine/backend/internal/membank"
@ -21,11 +23,29 @@ import (
)
func main() {
err := run()
os.Exit(exitOf(os.Stderr, run))
}
// exitOf runs the command behind a recover so a panic of the MAIN goroutine leaves through the exit
// contract rather than the runtime's handler (obs.PanicError). Worker goroutines carry their own recover
// at the wave seam (pipeline.runWave); this covers everything else, from parsing to the renderers.
//
// `diag` is the diagnostics sink (main passes os.Stderr, tests a buffer) for the same reason
// parseInvocation takes one: what reaches stderr on a crash is the contract, so it is asserted rather
// than assumed. os.Exit is the CALLER's — it skips deferred functions, including this recover.
func exitOf(diag io.Writer, body func() error) (code int) {
defer func() {
if p := recover(); p != nil {
err := obs.NewPanicError("tmctl", p)
fmt.Fprintln(diag, "tmctl:", err)
code = exitCode(err)
}
}()
err := body()
if err != nil {
fmt.Fprintln(os.Stderr, "tmctl:", err)
fmt.Fprintln(diag, "tmctl:", err)
}
os.Exit(exitCode(err))
return exitCode(err)
}
// The refusal band. A code in [refusalFirst, refusalLast] means the invocation was TURNED DOWN before
@ -61,7 +81,8 @@ var refusalExit = map[pipeline.RefusalClass]int{
// exitCode maps a run() error onto the ratified shell contract (Milestone 2 / R1-FL-A):
//
// 0 clean
// 1 infra failure and everything else, including a flag-parse error
// 1 infra failure and everything else, including a flag-parse error and a recovered PANIC
// (*obs.PanicError, row 176)
// 2 completed-with-flags (acceptance allows N flags; the typed *pipeline.CompletedWithFlags,
// which survives %w-wraps via errors.As)
// 3 bank-mining signature stop (*pipeline.WaveSignatureStop — the run paused before the edit wave
@ -89,9 +110,15 @@ func exitCode(err error) int {
var sigStop *pipeline.WaveSignatureStop
var ceiling *pipeline.CeilingHalt
var refusal *pipeline.Refusal
var panicked *obs.PanicError
switch {
case err == nil:
return 0
case errors.As(err, &panicked):
// A crash is 1, not a new number: both bands are frozen seams and a fresh code would be a word
// added to a ratified dictionary. Checked FIRST so a panic cannot be read as one of the
// deliberate stops below even if a layer joined it with one.
return 1
case errors.As(err, &sigStop):
return 3
case errors.As(err, &flagged):
@ -141,6 +168,13 @@ func traceID() string {
return v
}
// dispatchCommands is the list the switch in run() dispatches, in usage order. It exists as ONE list
// because the usage line and the unknown-command hint were two hand-maintained copies of it, and the
// usage line fell three commands behind (row 176) while the hint stayed current.
var dispatchCommands = []string{
"translate", "report", "status", "export", "redrive", "manifest", "backup", "migrate", "seed-lint",
}
func run() error {
inv, err := parseInvocation(os.Args[1:], os.Stderr)
if err != nil {
@ -191,7 +225,7 @@ func run() error {
case "seed-lint":
return seedLint(inv.seedPath)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|manifest|backup|migrate|seed-lint)", inv.cmd)
return fmt.Errorf("unknown command %q (want %s)", inv.cmd, strings.Join(dispatchCommands, "|"))
}
}

View file

@ -0,0 +1,100 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
)
// panic_exit_test.go: a crash must leave through a FAILING exit code (row 176). Unrecovered, a Go panic
// is the runtime's own exit 2 — the number this contract gives to "completed with flags" — and the
// platform's intake reads 2 as a finished run with flagged chapters, so a process that died mid-book was
// recorded as `ready` with its money read from a `status --json` that knows nothing about the crash.
func TestPanicOnTheMainGoroutineExitsAsFailure(t *testing.T) {
var diag bytes.Buffer
code := exitOf(&diag, func() error { panic("something ate a nil map") })
if code != 1 {
t.Fatalf("a panic must exit 1 (infra failure), got %d", code)
}
// 2 is the one number that would be actively harmful, so it gets its own sentence.
if code == 2 {
t.Fatal("a panic must never exit 2 — the platform records that as completed-with-flags")
}
out := diag.String()
for _, want := range []string{"tmctl:", "PANIC in tmctl", "something ate a nil map", "goroutine"} {
if !strings.Contains(out, want) {
t.Errorf("the panic and its stack must reach the diagnostics sink; %q missing from: %s", want, out)
}
}
}
func TestPanicFromAWaveWorkerExitsAsFailure(t *testing.T) {
// The shape the driver actually returns: the wave's error, wrapped on its way up through translate().
err := fmt.Errorf("pipeline: draft wave: %w", obs.NewPanicError("wave worker", "index out of range"))
if code := exitCode(err); code != 1 {
t.Fatalf("a wrapped worker panic must exit 1, got %d", code)
}
var diag bytes.Buffer
if code := exitOf(&diag, func() error { return err }); code != 1 {
t.Fatalf("exitOf must map it the same way, got %d", code)
}
if !strings.Contains(diag.String(), "goroutine") {
t.Errorf("the worker's stack must reach the diagnostics sink; got: %s", diag.String())
}
}
// TestPanicTakesNoNonFailingCodeOfTheDictionary is the trap: the fix moves no number, so the only way a
// panic could still be read as success is by MATCHING one of the typed sentinels the dictionary is read
// through. Each case panics with the very sentinel it must not become.
func TestPanicTakesNoNonFailingCodeOfTheDictionary(t *testing.T) {
for _, tc := range []struct {
name string
value any
notCode int
}{
{"completed with flags", &pipeline.CompletedWithFlags{Flagged: 1, Total: 3}, 2},
{"signature stop", &pipeline.WaveSignatureStop{Terms: 2}, 3},
{"context cancellation", context.Canceled, 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := obs.NewPanicError("wave worker", tc.value)
code := exitCode(err)
if code == tc.notCode {
t.Fatalf("a panic carrying %s leaked into exit %d", tc.name, code)
}
if code != 1 {
t.Fatalf("a panic must exit 1, got %d", code)
}
})
}
// And the refusal band, whose promise is the opposite of a crash: "nothing reached a provider,
// nothing was spent, nothing was written".
code := exitCode(obs.NewPanicError("wave worker", pipeline.RefuseConfig(errors.New("bad"))))
if code >= refusalFirst && code <= refusalLast {
t.Fatalf("a panic must never land in the refusal band, got %d", code)
}
}
// TestPanicWinsWhenJoinedWithASentinel pins the ORDER of the switch, not just its content: errors.Join
// puts a panic and a sentinel in one chain, and both errors.As calls would match. The panic must win, or
// a crash joined with a ceiling halt would leave as exit 4 — which the platform records as `paused`.
func TestPanicWinsWhenJoinedWithASentinel(t *testing.T) {
for _, sentinel := range []error{
&pipeline.CompletedWithFlags{Flagged: 1, Total: 2},
&pipeline.WaveSignatureStop{Terms: 1},
context.Canceled,
pipeline.RefuseConfig(errors.New("bad config")),
} {
joined := errors.Join(sentinel, obs.NewPanicError("wave worker", "boom"))
if code := exitCode(joined); code != 1 {
t.Errorf("a panic joined with %T must still exit 1, got %d", sentinel, code)
}
}
}

View file

@ -398,6 +398,10 @@ job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`
2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd`
(честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка
`ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**».
**ЭРРАТА 17.08 (строка 181, пак честности):** «прошлая фактическая цена» перестала быть честной,
когда провайдер сменил таблицу (D39.137): единица, купленная по старым ценам, при пере-покупке стоит
до ×4.4 больше. Реализация теперь пере-прайсит ЗАПИСАННЫЕ ТОКЕНЫ текущей таблицей — тем же швом,
которым считает резерв и сеттл (`internal/pipeline/reprice.go`).
3. **Порог + флаг (форма ратифицирована D20.2-Q2).** Порог `rebill_consent_usd` (конфиг книги) с
дефолтом **`min($0.50, 5% × ProjectedBookUSD)`**. **Fallback-флор при `ProjectedBookUSD=0`** (книга
ещё не считалась / нет обработанных чанков → 5%-ветка даёт $0 и заблокировала бы даже центовый

View file

@ -358,16 +358,12 @@ func (b *Bank) Voices() []store.VoiceProfile { return b.voices }
func (b *Bank) Pairs() []store.AddressPair { return b.pairs }
// ComputeVersion is the F1 content-hash: the frozen rows (ORDER BY-stable) plus the
// normalization and matcher algorithm versions. Approved-only per D8/§8 when the post-check
// hard gate is OFF (auto/draft injected-content changes are caught at the per-chunk
// content_hash level; their decl changes affect only the recomputed retrieval-state).
// When the gate is ON, the resolved chunk disposition depends on the decl forms of ALL
// injected records (approved AND auto/draft), and those are in NEITHER content_hash (decl
// is not injected) NOR the approved-only hash — so a decl edit would silently flip a
// resumed chunk's disposition (self-review #4). So gateOn folds every row's content
// (incl. decl + status) into the hash, making any such edit a loud --resnapshot. The
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
// normalization and matcher algorithm versions. EVERY row folds whatever its status, and
// its whole content (incl. decl and status) with it — see the loop below for why the
// approved-only fold of D8/§8 was retired by pack-20 (D39.42 п.3). gateOn is folded as a
// FIELD rather than as a scope switch: it changes how the same rows resolve a chunk's
// disposition, so the two gate settings must not share a hash. The glossary.id
// autoincrement is deliberately EXCLUDED (fresh each replace); only content columns are hashed.
func ComputeVersion(rows []store.GlossaryEntry, gateOn bool) string {
return ComputeVersionScoped(rows, gateOn, false)
}

View file

@ -0,0 +1,38 @@
package obs
import (
"fmt"
"runtime/debug"
)
// panic.go is SafeGo's opposite number, and the contrast is the point: SafeGo swallows so a background
// telemetry write dies alone, while on the PAID path a swallowed crash is how a run that died mid-book
// gets reported as a success. THE defect (row 176): unrecovered, a panic leaves through the Go runtime's
// handler, which exits 2 — the number tmctl's contract gives to "completed with flags" — and the
// supervisor records the crashed run as `ready`. Here a panic becomes an ordinary error instead.
// PanicError is a recovered runtime panic, carrying the stack of the goroutine that panicked.
//
// It deliberately has NO Unwrap: the recovered value may itself be an error (a panicked context.Canceled,
// a *pipeline.CeilingHalt thrown by accident), and exposing it to errors.Is/As would let a crash match a
// sentinel of the exit-code dictionary and read as a deliberate stop.
type PanicError struct {
// Where names the goroutine that died, since the message is often read without the stack.
Where string
Value any
Stack []byte
}
// Error renders the panic WITH its stack. The stack is part of the message rather than something a
// caller has to fish out with errors.As, because the one guaranteed reader is `tmctl: <err>` on stderr,
// and the wrapping between here and there is not under this type's control: a crash whose diagnosis
// depends on nobody using %v on the way up is a diagnosis that will be missing when it is needed.
func (e *PanicError) Error() string {
return fmt.Sprintf("PANIC in %s: %v\n%s", e.Where, e.Value, e.Stack)
}
// NewPanicError converts a recover() value into an error. Call it only when recover() returned non-nil;
// `where` names the goroutine ("wave worker", "tmctl").
func NewPanicError(where string, v any) *PanicError {
return &PanicError{Where: where, Value: v, Stack: debug.Stack()}
}

View file

@ -99,11 +99,23 @@ func TestLiveClassifierHarmSet(t *testing.T) {
// Default 5, not 1. The comment above condemns n=1 and the first version of this rig then DEFAULTED to
// it — so the next person to run it would have got exactly the reading it warns against. A run costs
// ~$0.0002-0.0009, so five samples are free at the scale of any probe that would bother running this.
runs := 5
runs := classify6SampleN
if v := os.Getenv("TM_CLASSIFY6_N"); v != "" {
if n, cerr := strconv.Atoi(v); cerr == nil && n > 0 {
runs = n
// A malformed override is REFUSED, not ignored: silently falling back to the ratified 5 would buy
// five paid calls for an operator who believes they asked for something else, one line above the
// guard that exists to stop exactly that.
n, cerr := strconv.Atoi(v)
if cerr != nil || n <= 0 {
t.Fatalf("TM_CLASSIFY6_N=%q is not a positive integer — refusing rather than quietly running the default N=%d and billing for it", v, classify6SampleN)
}
runs = n
}
// The size guard fires BEFORE the first paid call, not after: an override the threshold does not
// cover must cost nothing. The refusal is LOUD rather than a skip — a watch run that quietly did not
// measure is indistinguishable from one that passed.
if ok, why := classify6SampleRatified(runs); !ok {
t.Fatalf("TM_CLASSIFY6_N=%d: %s. Nothing was called and nothing was billed. Run the ratified N=%d, or ask the owner for a threshold at your N",
runs, why, classify6SampleN)
}
effort := st.Reasoning
if effort == "" {
@ -177,16 +189,16 @@ func TestLiveClassifierHarmSet(t *testing.T) {
t.Errorf("persist probe evidence: %v", werr)
}
// ⚠ THE THRESHOLD AND THE SAMPLE DISAGREE, and that is an OWNER decision, not something to soften here.
// D39.69 §2 ratified "6/6" against a SINGLE call; nobody defined it over a sample. Measured 02.08 on
// live 0731 weights: effort `low` reaches 6/6 on 4 of 5 runs, `high` on 5 of 5 — a difference n=5 cannot
// separate — while `high` costs 2.30x per call. So the level this engine now recommends for the bank
// roles makes this gate RED. Reporting that honestly is the point; relaxing the gate to fit the
// recommendation would be fitting the acceptance criterion to the result.
if full != runs {
t.Fatalf("the ratified acceptance threshold is 6/6 term (D39.69 §2) and it was reached on %d of %d runs "+
"at effort %q — see classifier-6of6-%s.json. ⚠ The threshold was defined for ONE call, not for a "+
"sample: decide with the owner whether it means \"every run\" or \"the median run\" before treating "+
"this as a regression.", full, runs, effort, effort)
// The threshold is the SAMPLED form ratified by D39.136 п.6б (row 116 closed by delegation): «≥4 of 5
// runs at N=5 reach 6/6». It replaced D39.69 §2's "6/6", which had been defined against a SINGLE call
// and which this rig used to apply per run — the open question the rig declared here is CLOSED, and
// the closure was the owner's, not a softening by whoever ran the probe. What the sampled form buys:
// the 02.08 measurement on live 0731 weights (effort `low` 4 of 5, `high` 5 of 5, a difference n=5
// cannot separate, `high` 2.30x per call) is a PASS for the level the engine actually recommends for
// the bank roles, instead of a red gate that says nothing about the model.
if ok, why := classify6Accepted(runs, full); !ok {
t.Fatalf("classifier acceptance FAILED: %s — 6/6 was reached on %d of %d runs at effort %q "+
"(ratified threshold D39.136 п.6б: ≥%d of %d). Raw evidence: classifier-6of6-%s.json",
why, full, runs, effort, classify6RunsAtFullOK, classify6SampleN, effort)
}
}

View file

@ -0,0 +1,66 @@
package pipeline
import "testing"
// live_reprobe_threshold_test.go: the ARITHMETIC of the classifier acceptance threshold, lifted out of
// the `live` build tag so it is exercised by the ordinary battery. The rig that spends money on it lives
// in live_reprobe_test.go; this file carries no build tag on purpose, so both tag sets compile it and the
// paid rig and the free unit test can never disagree about what "accepted" means.
// The ratified acceptance threshold of the §2 type-classifier (D39.136 п.6б, closing row 116): the
// SAMPLED form — «≥4 of 5 runs at N=5 reach 6/6» — replacing D39.69 §2's "6/6" read over a single call.
//
// The form is ratified for N=5 ONLY. A different sample size has no ratified threshold at all, and
// DERIVING one (a ratio, a binomial bound) would be a session inventing an acceptance criterion — the
// quiet bypass the rig exists to prevent — so classify6Accepted refuses it instead of scaling.
const (
classify6SampleN = 5
classify6RunsAtFullOK = 4
)
// classify6SampleRatified reports whether the threshold covers a sample of this size at all. It is a
// separate question from the verdict because it must be answerable BEFORE any call is bought.
func classify6SampleRatified(runs int) (ok bool, why string) {
if runs != classify6SampleN {
return false, "the ratified threshold (D39.136 п.6б) is defined ONLY for N=5 — a sample of another size has no acceptance criterion, and deriving one here would be inventing the gate rather than applying it"
}
return true, ""
}
// classify6Accepted resolves a finished probe run against the ratified threshold. `ok` is the verdict;
// `why` is the operator-facing reason, non-empty whenever ok is false.
func classify6Accepted(runs, runsAtFull int) (ok bool, why string) {
if ok, why := classify6SampleRatified(runs); !ok {
return false, why
}
if runsAtFull < classify6RunsAtFullOK {
return false, "fewer than 4 of the 5 runs reached 6/6"
}
return true, ""
}
func TestClassify6ThresholdIsTheRatifiedSampledForm(t *testing.T) {
// 4/5 is the case the pre-ratification rig failed on: D39.136 п.6б exists precisely because effort
// `low` measured 4-of-5 on 02.08 and the "every run" form called that a regression.
for _, c := range []struct {
runs, atFull int
want bool
}{
{5, 5, true},
{5, 4, true},
{5, 3, false},
{5, 0, false},
{4, 4, false}, // N≠5: refused, never scaled to "4/4 is also ≥80%"
{10, 9, false},
{1, 1, false},
{6, 6, false}, // not even a SUPERSET of the ratified sample is derived from it
} {
got, why := classify6Accepted(c.runs, c.atFull)
if got != c.want {
t.Errorf("classify6Accepted(runs=%d, atFull=%d) = %v (%s), want %v", c.runs, c.atFull, got, why, c.want)
}
if !got && why == "" {
t.Errorf("a refusal must carry its reason (runs=%d, atFull=%d)", c.runs, c.atFull)
}
}
}

View file

@ -1595,7 +1595,7 @@ func TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches(t *testing.T) {
if err != nil {
t.Fatal(err)
}
proj, err := rp.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil })
proj, err := rp.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, rp))
if err != nil {
t.Fatal(err)
}

View file

@ -17,7 +17,8 @@ import (
// or is not the current one) and a snapshot move misses EVERY checkpoint of its wave. So consent is
// built at the granularity the engine actually re-pays at — the SNAPSHOT — and the projection is the
// honest reading of that: "the units already paid for under a superseded snapshot will be paid for
// again; the sum of their stored chunk_status.cost_usd is $X".
// again; their recorded tokens at TODAY'S price table come to $X" (reprice.go, row 181 — until then it
// summed the historical cost_usd, which is a different table the moment a vendor moves its prices).
//
// Two consequences of that granularity, both deliberate:
// - the spec's §7.1-бис editor-cascade fix ("a stage strictly below a re-billed unit is itself
@ -25,8 +26,8 @@ import (
// cascade rule adds nothing. Its ratified ordering ("the cascade lands BEFORE the flag semantics")
// is therefore not violated but inapplicable;
// - the spec's EstimateUSD fallback ("for cascade units with no past price") has no subject either:
// every unit counted here HAS a stored price, because it is counted precisely for having been paid.
// A paid row whose stored cost is $0 is a genuinely $0 model (local/priced-zero), so 0 is its honest
// every unit counted here HAS stored usage, because it is counted precisely for having been paid.
// A paid row that re-prices to $0 is a genuinely $0 model (local/priced-zero), so 0 is its honest
// contribution rather than a gap to estimate around.
//
// WHAT IS NOT PROJECTED (documented, not silent): the CONTENT axis. A source edit that leaves the chunk
@ -52,10 +53,16 @@ type RebillConsent struct {
}
// RebillProjection is what a run would re-pay: the chunk×stage units resolved under a superseded
// snapshot, and the sum of what they cost the first time.
// snapshot, and what buying them again would cost AT THE CURRENT PRICE TABLE (reprice.go).
type RebillProjection struct {
Rows int
USD float64
// HistoricalRows counts the units inside USD that could not be fully re-priced and carry the amount
// they were billed at instead: a billed-decode row with no usage on file, a row whose calls no longer
// account for its money, or one whose newest calls are provably not its own (reprice.go). It exists so
// the operator-facing text can name what the number is made of rather than claim a re-pricing it did
// not achieve.
HistoricalRows int
// Repinned counts the units the run will serve for $0 despite a moved snapshot (pack-20 point 5): the
// move was bank-only and their rendered bytes are unchanged. It is not part of the amount — it is the
// number that makes the amount believable, because before pack-20 every one of these was counted as a
@ -79,7 +86,9 @@ type RebillProjection struct {
// chunk, an edit row at its unit's leader chunk — so one membership test covers both.
//
// Over-estimating is the safe direction for a consent gate, but a number the operator is asked to
// approve and then not charged is exactly what makes such a number stop being read.
// approve and then not charged is exactly what makes such a number stop being read. Which is why the
// amount is not a safety margin either way: it is the stored tokens re-priced through the same seam the
// reservation will use, so the two answer the same question with the same table.
//
// CONTENT AWARENESS (pack-20 point 5, closing the phase-1 P3 finding). Snapshot divergence alone
// over-counts: since the bank is folded per wave, signing ONE term marked the whole edit wave as re-billed
@ -99,7 +108,7 @@ type RebillProjection struct {
// re-render injected bytes and therefore genuinely needs the text. Splitting the two is what lets a
// status read skip a 1.4 s re-chunk it almost never needs, without ever computing a content hash over an
// empty string.
func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Chunk, withText func() ([]chunk.Chunk, error)) (RebillProjection, error) {
func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Chunk, withText func() ([]chunk.Chunk, error), rp *repricer) (RebillProjection, error) {
var p RebillProjection
decider := newRepinDecider(r)
var contentHashes map[chunkKey]map[string]string
@ -163,29 +172,44 @@ func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Ch
}
}
p.Rows++
p.USD += cs.CostUSD
usd, fromHistory := rp.usd(cs)
p.USD += usd
if fromHistory {
p.HistoricalRows++
}
}
return p, nil
}
// projectBookUSD extrapolates the book's total cost from the units already FULLY attempted (done or
// flagged), which is the base of the 5% consent threshold. It is the SINGLE definition of the number
// `tmctl status` reports as projected_book_usd — status used to compute it inline, and a threshold
// computed from a second, drifting definition of the same quantity is exactly the class of bug the
// memberDrops helper was extracted to remove.
// projectBookUSD extrapolates what the WHOLE book costs, from the units already FULLY attempted (done or
// flagged), at the CURRENT price table. It is the SINGLE definition of the number `tmctl status` reports
// as projected_book_usd — status used to compute it inline, and a threshold computed from a second,
// drifting definition of the same quantity is exactly the class of bug the memberDrops helper was
// extracted to remove.
//
// It re-prices for the same reason the re-payment amount does (row 181), and specifically so the consent
// gate cannot end up half-historical: the threshold is 5% of THIS, so leaving the base in old money
// while the amount moves to the new table would compare two different currencies and quietly change how
// often the operator is asked at all.
//
// It deliberately does NOT use book-committed spend: committed also carries partial spend on
// IN-PROGRESS units, which are outside the denominator and would over-estimate the book.
func projectBookUSD(units []editUnit, byChunk map[chunkKey][]store.ChunkStatus, nDraftStages, nEditStages int) float64 {
// IN-PROGRESS units, which are outside the denominator and would over-estimate the book. Nor does it
// re-use resolveChunkState's CostUSD, which is the HISTORICAL sum the per-chapter passports report —
// what a chapter has cost is a fact, not a projection.
func projectBookUSD(units []editUnit, byChunk map[chunkKey][]store.ChunkStatus, nDraftStages, nEditStages int, rp *repricer) float64 {
var processedCost float64
processed := 0
for _, u := range units {
expected := len(u.Members)*nDraftStages + nEditStages
res := resolveChunkState(unitRows(u, byChunk), expected)
rows := unitRows(u, byChunk)
res := resolveChunkState(rows, expected)
if res.State != ChunkDone && res.State != ChunkFlagged {
continue
}
processedCost += res.CostUSD
for _, cs := range rows {
usd, _ := rp.usd(cs)
processedCost += usd
}
processed++
}
if processed == 0 {
@ -210,6 +234,18 @@ func (r *Runner) rebillConsentThreshold(projectedBookUSD float64) (usd float64,
return rebillConsentFloorUSD, fmt.Sprintf("the $0.50 cap, under 5%% of the projected book cost $%.6f", projectedBookUSD)
}
// projectionBasis says, inside the consent text, what the amount is made of. The parenthetical used to
// read "the sum of their stored cost_usd" and became a lie the day the projection stopped summing it
// (row 181) — in the single most visible place, the sentence the operator's consent is given to. It
// names the un-re-priced remainder rather than rounding it away: an amount partly quoted in last
// season's money is still a fact about the number, and hiding it is how the previous text went stale.
func projectionBasis(p RebillProjection) string {
if p.HistoricalRows > 0 {
return fmt.Sprintf("their recorded tokens at the CURRENT price table; %d of the %d unit(s) carry, wholly or in part, the amount they were originally billed at — that much of their usage could not be re-priced", p.HistoricalRows, p.Rows)
}
return "their recorded tokens at the CURRENT price table"
}
// checkRebillConsent is the gate: it refuses BEFORE any reservation when the run would re-pay more than
// the book's consent threshold and the operator has not consented to that amount.
//
@ -228,9 +264,13 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e
if err != nil {
return fmt.Errorf("pipeline: read chunk_status for the re-bill projection: %w", err)
}
rp, err := r.newRepricer()
if err != nil {
return err
}
// A write path already holds the real split, so the lazy provider just hands it back — no second
// ingest, and no branch where the consent gate could see text-free chunks.
proj, err := r.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil })
proj, err := r.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, rp)
if err != nil {
return err
}
@ -243,7 +283,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
bookUSD := projectBookUSD(r.outputUnits(chunks), byChunk,
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)))
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)), rp)
threshold, source := r.rebillConsentThreshold(bookUSD)
// A NAMED ceiling is an instruction, not merely a consent form: it is honoured even below the
@ -256,6 +296,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e
if r.AcceptRebill.Given {
r.Log.WarnContext(ctx, "accepting a projected re-payment of already-billed work (--accept-rebill)",
"rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD),
"units_not_repriced", proj.HistoricalRows,
"repinned_free", proj.Repinned, "threshold_usd", fmt.Sprintf("%.6f", threshold))
return nil
}
@ -265,6 +306,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e
// money and it goes to the log.
r.Log.InfoContext(ctx, "re-paying already-billed work under the consent threshold; continuing without asking",
"rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD),
"units_not_repriced", proj.HistoricalRows,
"repinned_free", proj.Repinned, "threshold_usd", fmt.Sprintf("%.6f", threshold))
return nil
}
@ -277,6 +319,6 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e
if proj.Repinned > 0 {
repin = fmt.Sprintf(" (%d further unit(s) are re-pinned for $0 — the bank moved but their injected bytes did not)", proj.Repinned)
}
return fmt.Errorf("pipeline: this run would RE-PAY for work already billed: %d chunk×stage unit(s) are resolved under a superseded snapshot and would be paid for again, ~$%.6f (the sum of their stored cost_usd)%s. That is over this book's consent threshold $%.6f (%s), and Р6 requires consent to a CONCRETE spend, not a blanket one (D20.2-Q2). NOTHING was reserved and no row was touched. Re-run with --accept-rebill to accept the whole projected amount, or --accept-rebill=<usd> to accept it only up to a ceiling (a ceiling below the projection refuses).%s",
proj.Rows, proj.USD, repin, threshold, source, hint)
return fmt.Errorf("pipeline: this run would RE-PAY for work already billed: %d chunk×stage unit(s) are resolved under a superseded snapshot and would be paid for again, ~$%.6f (%s)%s. That is over this book's consent threshold $%.6f (%s), and Р6 requires consent to a CONCRETE spend, not a blanket one (D20.2-Q2). NOTHING was reserved and no row was touched. Re-run with --accept-rebill to accept the whole projected amount, or --accept-rebill=<usd> to accept it only up to a ceiling (a ceiling below the projection refuses).%s",
proj.Rows, proj.USD, projectionBasis(proj), repin, threshold, source, hint)
}

View file

@ -290,7 +290,7 @@ func TestRebillProjectionIsPerWave(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }); err != nil || proj.Rows != 0 {
if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 {
t.Fatalf("a freshly-run book projects no re-bill; each row must be judged against ITS OWN wave, got %+v (err=%v)", proj, err)
}
r1.Close()
@ -312,7 +312,7 @@ func TestRebillProjectionIsPerWave(t *testing.T) {
if err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil })
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
@ -358,7 +358,7 @@ func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) {
t.Fatal("setup: the refusal fixture must leave a skipped edit row")
}
// No drift yet: nothing is superseded, so nothing is projected.
if proj, err := r1.projectRebill(statuses, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }); err != nil || proj.Rows != 0 || proj.USD != 0 {
if proj, err := r1.projectRebill(statuses, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 || proj.USD != 0 {
t.Fatalf("an undrifted book must project no re-bill, got %+v (err=%v)", proj, err)
}
r1.Close()
@ -370,7 +370,7 @@ func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) {
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil })
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
@ -408,7 +408,7 @@ func TestRebillProjectionIgnoresRetiredStages(t *testing.T) {
if err != nil {
t.Fatal(err)
}
proj, err := r.projectRebill(statuses, chunksOf(t, r), func() ([]chunk.Chunk, error) { return chunksOf(t, r), nil })
proj, err := r.projectRebill(statuses, chunksOf(t, r), func() ([]chunk.Chunk, error) { return chunksOf(t, r), nil }, newRepricerT(t, r))
if err != nil {
t.Fatal(err)
}
@ -450,7 +450,7 @@ func TestRebillProjectionIgnoresVanishedChunks(t *testing.T) {
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil })
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
@ -608,13 +608,16 @@ func TestProjectBookUSDExtrapolates(t *testing.T) {
{Stage: "edit", Disposition: string(DispOK), CostUSD: 1.0},
},
}
// An empty repricer holds no checkpoint usage, so every row falls back to its stored amount — which
// is exactly the arithmetic this test is about (the re-pricing itself is reprice_test.go's subject).
noUsage := &repricer{}
// $2.00 over 1 processed unit × 3 units in the book = $6.00.
if got := projectBookUSD(units, byChunk, 1, 1); got != 6.0 {
if got := projectBookUSD(units, byChunk, 1, 1, noUsage); got != 6.0 {
t.Fatalf("projected book cost = %v, want 6.0 ($2.00/processed unit × 3 units)", got)
}
// Nothing processed → no basis to extrapolate from → $0 (which is what makes the threshold fall back
// to its $0.50 floor rather than to 5%×0 = $0).
if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1); got != 0 {
if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1, noUsage); got != 0 {
t.Fatalf("with nothing processed the projection is 0, got %v", got)
}
}
@ -650,7 +653,7 @@ func TestProjectBookUSDMatchesStatus(t *testing.T) {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
got := projectBookUSD(r.outputUnits(chunks), byChunk,
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)))
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)), newRepricerT(t, r))
if got != rep.ProjectedBookUSD {
t.Fatalf("projectBookUSD = %v but status reports %v — the threshold base and the published projection diverged", got, rep.ProjectedBookUSD)
}

View file

@ -0,0 +1,135 @@
package pipeline
import (
"encoding/json"
"fmt"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// reprice.go: what already-billed work would cost IF BOUGHT AGAIN TODAY (row 181). A projection of
// FUTURE spend that adds up past `cost_usd` quotes a price list that no longer exists — DeepSeek's table
// moved 16.08.2026 (D39.137) — while reserve and settle price with the current one (stagerun.go).
//
// Scope: only spend that has NOT happened — the re-payment amount, its consent threshold and
// projected_book_usd. Committed/reserved and the per-chapter passport costs are historical fact.
//
// The recorded TOKENS are re-priced through settle's own seam (ledger.CostUSD + Pricer.PriceForResponse)
// rather than re-estimated with ledger.EstimateUSD: the estimate reserves the whole max_tokens budget and
// would overshoot by multiples, it needs rendered messages the read path deliberately does not have, and
// going through the settle seam is what keeps the quote and the booking from drifting apart.
//
// NOT projected (like the content axis in rebill.go): ROUTING. A row is priced by the model that
// ANSWERED, not by the one its stage resolves to now — predicting the token mix of a model that has never
// seen these chunks is a different question, and it is why an escalation hop is priced at its own model.
// repricedCall is one stored provider call: what it was billed, and what the same tokens cost today.
type repricedCall struct {
then float64
now float64
// priced is false when the checkpoint carries no usable token count, and `now` is the billed amount.
priced bool
}
// repricer answers "what would this stored row cost today" for every disposition row of a book. A cell
// holds its calls OLDEST FIRST, because which of them still back the row is decided from the newest end
// (see usd).
type repricer struct {
cells map[chunkKey]map[string][]repricedCall
}
// newRepricer reads the book's checkpoint usage once. It is EAGER rather than lazy on purpose: both
// callers (the consent gate and the status read-model) need it for the amounts they publish, and a
// lazily-loading variant would have to smuggle a store error out of an arithmetic helper.
func (r *Runner) newRepricer() (*repricer, error) {
rows, err := r.Store.CheckpointUsageForBook(r.Book.BookID)
if err != nil {
return nil, fmt.Errorf("pipeline: read checkpoint usage for the re-pricing of already-billed work: %w", err)
}
rp := &repricer{cells: make(map[chunkKey]map[string][]repricedCall)}
for _, cu := range rows {
usd, priced := r.repriceCheckpoint(cu)
key := chunkKey{cu.Chapter, cu.ChunkIdx}
byStage := rp.cells[key]
if byStage == nil {
byStage = map[string][]repricedCall{}
rp.cells[key] = byStage
}
byStage[cu.Stage] = append(byStage[cu.Stage], repricedCall{then: cu.CostUSD, now: usd, priced: priced})
}
return rp, nil
}
// repriceCheckpoint prices one stored call at today's table. `priced` is false when the checkpoint
// carries no usable token count and the historical amount is returned instead — the real case being the
// billed-decode-failure checkpoint, which settles the RESERVATION ESTIMATE against a `{}` usage
// (stagerun.go): re-pricing that to $0 would quietly delete money from the consent number.
func (r *Runner) repriceCheckpoint(cu store.CheckpointUsage) (usd float64, priced bool) {
var u llm.Usage
if err := json.Unmarshal([]byte(cu.UsageJSON), &u); err != nil {
return cu.CostUSD, false
}
if u == (llm.Usage{}) {
// No tokens to price. A genuinely $0 row (a local model, a $0 derived export checkpoint) is
// re-priced to the same $0 and is not a gap; only a row that COST something is. The test is on
// the whole struct rather than on selected fields, so a new usage axis cannot make it stale.
if cu.CostUSD == 0 {
return 0, true
}
return cu.CostUSD, false
}
return ledger.CostUSD(r.Pricer.PriceForResponse(cu.ModelRequested, cu.ModelActual), u), true
}
// usd is what re-buying this disposition row would cost at the current price table; `fromHistory` says
// the answer is partly the amount it was billed at instead.
//
// GENERATION MEMBERSHIP, and why it has to be DERIVED (this is an inference, not an identity — the one
// place in here worth distrusting). Checkpoints are append-only for the life of the book and carry no
// snapshot: re-buying a unit, or editing the source under it, leaves the old call on file beside the new
// one. chunk_status.cost_usd is the opposite — OVERWRITTEN each run with the CURRENT generation's cost.
// So pricing every call of a position answers "what has this position ever cost" in place of the question
// the operator is consenting to. The row's own money is the only per-generation authority in the store,
// so membership is taken from it: walk the calls NEWEST FIRST, keeping them while they fit inside
// cs.CostUSD.
//
// TWO PRECONDITIONS, and what happens when each fails:
// - the generation's calls SUM to the row's cost. Short of it means calls were lost (a restore, a
// legacy row) — the remainder is carried at its billed value, the conservative direction and the
// answer the projection gave before re-pricing existed. Not a routine branch: the one path that
// deletes checkpoints, ResetChunkStages, deletes the disposition row with them in one transaction.
// - they are the NEWEST calls of the cell. A config REVERT breaks this: the re-run addresses an old
// request_hash, the settle is a no-op (ON CONFLICT DO NOTHING, store/ledger.go) and the row is
// rewritten with an OLDER call's cost while newer, superseded ones stay on file. The walk then
// overshoots — which is detectable, so it is detected, and the row falls back to its billed amount
// marked NOT re-priced. That quotes stale money, but it quotes it out loud, which is the whole
// point of the row this file closes.
//
// Recovering the revert case EXACTLY needs a generation marker on `checkpoints` (they carry neither
// snapshot_id nor run id) — a money-table schema change, and a question for ratification rather than a
// third patch here.
func (rp *repricer) usd(cs store.ChunkStatus) (usd float64, fromHistory bool) {
calls := rp.cells[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]
var accounted float64
for i := len(calls) - 1; i >= 0 && accounted < cs.CostUSD-residueEpsilonUSD; i-- {
accounted += calls[i].then
usd += calls[i].now
if !calls[i].priced {
fromHistory = true
}
}
if accounted > cs.CostUSD+residueEpsilonUSD {
return cs.CostUSD, true // the newest calls are not this row's — see the revert case above
}
if residue := cs.CostUSD - accounted; residue > residueEpsilonUSD {
usd += residue
fromHistory = true
}
return usd, fromHistory
}
// residueEpsilonUSD is far below any amount this engine prints (%.6f): it separates money a row really
// lost from the float noise of adding the same terms in a different order.
const residueEpsilonUSD = 1e-9

View file

@ -0,0 +1,498 @@
package pipeline
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// reprice_test.go: the consent number after a VENDOR PRICE CHANGE (row 181). The scenario is the one
// that made the row material — DeepSeek's table moved on 16.08.2026 and the projection was still adding
// up what the units had cost before it, so the operator consented to a figure the reservation would beat
// by multiples. Every assertion here is on money the operator is SHOWN, not on money that moves: the
// gate still refuses before any reservation, and the ledger is untouched.
func newRepricerT(t *testing.T, r *Runner) *repricer {
t.Helper()
rp, err := r.newRepricer()
if err != nil {
t.Fatal(err)
}
return rp
}
// bumpModelPrices multiplies every price in the fixture models.yaml by factor — the vendor moving its
// table under a book that is already paid for.
func bumpModelPrices(t *testing.T, bookPath string, factor float64) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
old := "price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }"
next := fmt.Sprintf("price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g }",
1.0*factor, 0.1*factor, 2.0*factor)
changed := strings.ReplaceAll(string(raw), old, next)
if changed == string(raw) {
t.Fatal("setup: the fixture price line was not found in models.yaml")
}
writeFile(t, path, changed)
}
// TestRebillProjectionUsesTheCurrentPriceTable is the row-181 headline: units bought under the old table
// must be quoted at the NEW one, because that is the table the reservation and the settle will use.
func TestRebillProjectionUsesTheCurrentPriceTable(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
committedBefore, _, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
if committedBefore <= 0 {
t.Fatalf("setup: the first run must bill something, got %v", committedBefore)
}
const factor = 4.0 // DeepSeek's own move was ×3.0-4.7 per axis (D39.137)
bumpModelPrices(t, bookPath, factor)
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r2)
proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 2 {
t.Fatalf("setup: both stages must be superseded, got %d row(s)", proj.Rows)
}
if proj.HistoricalRows != 0 {
t.Errorf("every unit here has its usage on file; %d were quoted from history instead", proj.HistoricalRows)
}
// The whole point: the projection is what the units cost TODAY, not what they cost when bought.
if want := factor * committedBefore; !nearUSD(proj.USD, want) {
t.Fatalf("re-payment projected at $%.6f but the current price table makes that work $%.6f — the operator would consent to the old table's number (row 181)",
proj.USD, want)
}
// The threshold's base moves with it, or the gate goes half-historical: 5% of a book costed in last
// season's money against an amount costed in this one.
rep, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if want := factor * committedBefore; !nearUSD(rep.ProjectedBookUSD, want) {
t.Fatalf("status projected_book_usd = $%.6f, want $%.6f at the current table", rep.ProjectedBookUSD, want)
}
if !nearUSD(rep.RebillUSD, proj.USD) {
t.Fatalf("status quotes $%.6f where the consent gate projects $%.6f — the two must be one number", rep.RebillUSD, proj.USD)
}
// And the operator-facing sentence: it must name the table it used, and must not still promise a sum
// of stored cost_usd — the most visible place the old text would have gone on lying.
r2.Resnapshot = true
_, terr := r2.TranslateBook(ctx)
if terr == nil {
t.Fatal("the re-payment is over the threshold — the run must refuse")
}
msg := terr.Error()
if !strings.Contains(msg, fmt.Sprintf("~$%.6f", proj.USD)) {
t.Errorf("the refusal must quote the re-priced amount ~$%.6f; got: %s", proj.USD, msg)
}
if strings.Contains(msg, "stored cost_usd") {
t.Errorf("the consent text still describes the amount as the sum of stored cost_usd: %s", msg)
}
if !strings.Contains(msg, "CURRENT price table") {
t.Errorf("the consent text must say what the amount is priced with; got: %s", msg)
}
// The threshold printed in that same sentence is 5% of the RE-PRICED book, not of the old one.
if !strings.Contains(msg, fmt.Sprintf("projected book cost $%.6f", rep.ProjectedBookUSD)) {
t.Errorf("the threshold's base must be the re-priced book cost $%.6f; got: %s", rep.ProjectedBookUSD, msg)
}
// Nothing was bought to learn any of this.
committedAfter, reservedAfter, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committedAfter != committedBefore || reservedAfter != 0 {
t.Errorf("a refused run must move no money: committed %v → %v, reserved %v", committedBefore, committedAfter, reservedAfter)
}
}
// driftPromptVersion moves the fixture's prompt_version from one token to another, so a test can drift
// the book a SECOND time (driftPipelineVersion only knows the first hop).
func driftPromptVersion(t *testing.T, bookPath, from, to string) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
changed := strings.ReplaceAll(string(raw), "prompt_version: "+from, "prompt_version: "+to)
if changed == string(raw) {
t.Fatalf("setup: prompt_version %q not found in the fixture pipeline", from)
}
writeFile(t, path, changed)
}
// TestRebillProjectionAfterAReBuyQuotesOneGeneration is the end-to-end form of the regression: a book
// that has ALREADY been re-bought once (the very flow the consent gate governs) must still be quoted the
// cost of ONE re-payment, not the sum of everything it has ever been billed. Prices are never touched
// here, so the projection has to equal the historical per-generation cost exactly.
func TestRebillProjectionAfterAReBuyQuotesOneGeneration(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Generation 2: drift both waves and consent to the re-payment, so the book now carries two
// checkpoints per unit while each chunk_status row records only the newer one's cost.
driftPromptVersion(t, bookPath, "v-test", "v-test-g2")
r2 := newRunner(t, bookPath)
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
committed, _, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r2.Close()
if !nearUSD(committed, 4*fakeCallUSD) {
t.Fatalf("setup: two generations of draft+edit must bill 4 calls, got $%.6f", committed)
}
// Generation 3 is only PROJECTED: what would one more re-payment cost?
driftPromptVersion(t, bookPath, "v-test-g2", "v-test-g3")
r3 := newRunner(t, bookPath)
defer r3.Close()
statuses, err := r3.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r3)
proj, err := r3.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r3))
if err != nil {
t.Fatal(err)
}
if want := 2 * fakeCallUSD; !nearUSD(proj.USD, want) {
t.Errorf("a re-bought book projects the cost of ONE more re-payment ($%.6f), got $%.6f — the projection is counting superseded generations", want, proj.USD)
}
if proj.HistoricalRows != 0 {
t.Errorf("every row here has its usage on file; %d were quoted from history", proj.HistoricalRows)
}
rep, err := r3.Status(ctx)
if err != nil {
t.Fatal(err)
}
if want := 2 * fakeCallUSD; !nearUSD(rep.ProjectedBookUSD, want) {
t.Errorf("projected_book_usd is what the book costs ONCE ($%.6f), got $%.6f", want, rep.ProjectedBookUSD)
}
}
// addModel appends a model to the fixture catalogue at `factor` times the fixture price.
func addModel(t *testing.T, bookPath, name string, factor float64) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
writeFile(t, path, string(raw)+fmt.Sprintf(`
%s:
provider: fake
price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g }
`, name, 1.0*factor, 0.1*factor, 2.0*factor))
}
// TestRepriceCheckpointFallsBackToTheBilledAmount pins the one place where re-pricing is impossible and
// the honest answer is the historical figure: the billed-decode checkpoint, which settles the
// RESERVATION ESTIMATE against a `{}` usage (stagerun.go). Re-pricing that to $0 would delete real money
// from the consent number.
func TestRepriceCheckpointFallsBackToTheBilledAmount(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
addModel(t, bookPath, "fake-dear", 10.0) // a second, dearer model so "priced by which one" is decidable
r := newRunner(t, bookPath)
defer r.Close()
for _, tc := range []struct {
name string
cu store.CheckpointUsage
wantUSD float64
wantPriced bool
}{
{
name: "usage on file is re-priced",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01},
wantUSD: fakeCallUSD,
wantPriced: true,
},
{
name: "billed 2xx with an unreadable body keeps its settled estimate",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0.0042},
wantUSD: 0.0042,
wantPriced: false,
},
{
// Money is priced by the model that ANSWERED, not the one that was asked — the same
// PriceForResponse ordering settle uses, and the axis an escalation hop rides.
name: "the model that answered sets the price",
cu: store.CheckpointUsage{ModelRequested: "fake-model", ModelActual: "fake-dear", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01},
wantUSD: 10 * fakeCallUSD,
wantPriced: true,
},
{
name: "a genuinely $0 row is not a gap",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0},
wantUSD: 0,
wantPriced: true,
},
{
name: "unparseable usage keeps its billed amount",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `not json`, CostUSD: 0.0007},
wantUSD: 0.0007,
wantPriced: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
usd, priced := r.repriceCheckpoint(tc.cu)
if !nearUSD(usd, tc.wantUSD) || priced != tc.wantPriced {
t.Fatalf("repriceCheckpoint = ($%.6f, priced=%v), want ($%.6f, priced=%v)", usd, priced, tc.wantUSD, tc.wantPriced)
}
})
}
}
// TestRepricerCountsOnlyTheCurrentGeneration is the regression an adversarial review of this pack found.
// Checkpoints are append-only across a book's whole life and carry no snapshot: a re-bought or
// source-edited unit accumulates a checkpoint per generation, while chunk_status.cost_usd is OVERWRITTEN
// with the current generation's cost alone. Re-pricing every checkpoint of the cell therefore quoted the
// operator a number that grew by a whole extra generation with each re-payment — the failure the pack
// exists to remove, reintroduced by its own fix.
func TestRepricerCountsOnlyTheCurrentGenerationOfCheckpoints(t *testing.T) {
rp := &repricer{cells: map[chunkKey]map[string][]repricedCall{
{chapter: 1, chunkIdx: 0}: {
// Two generations, oldest first, DELIBERATELY ASYMMETRIC: an expensive first generation and a
// cheap second one, so walking the wrong end gives a different answer instead of the same one.
"draft": {{then: 0.60, now: 2.40, priced: true}, {then: 0.05, now: 0.20, priced: true}},
// One generation, two attempts.
"edit": {{then: 0.01, now: 0.04, priced: true}, {then: 0.02, now: 0.08, priced: true}},
},
}}
for _, tc := range []struct {
name string
cs store.ChunkStatus
wantUSD float64
wantFromHistory bool
}{
{
// Walking from the oldest end would answer $2.40 here — the whole inference is the direction.
name: "only the newest generation is priced",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.05},
wantUSD: 0.20,
},
{
// A config REVERT rewrites the row with an OLDER call's cost while the newer, superseded call
// stays on file. The walk then overshoots the row's money, which proves the newest calls are
// not this row's — the amount falls back to what was billed, and says so.
name: "a reverted row overshoots and is quoted as billed, disclosed",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.60},
wantUSD: 0.60,
wantFromHistory: true,
},
{
name: "every attempt of the current generation counts",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03},
wantUSD: 0.12,
},
{
// A skipped row never reached a provider and costs $0 — even where the same position was
// billed under an earlier generation whose checkpoints are still on file.
name: "a $0 row takes nothing from the orphans at its position",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0},
wantUSD: 0,
},
{
// Money the surviving checkpoints do not account for is carried at its billed value rather
// than dropped: the conservative direction, and what the projection answered before re-pricing.
name: "unexplained money is carried as billed",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.05},
wantUSD: 0.12 + 0.02,
wantFromHistory: true,
},
{
name: "a row with no checkpoint at all keeps its billed amount",
cs: store.ChunkStatus{Chapter: 9, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03},
wantUSD: 0.03,
wantFromHistory: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
usd, fromHistory := rp.usd(tc.cs)
if !nearUSD(usd, tc.wantUSD) || fromHistory != tc.wantFromHistory {
t.Fatalf("usd = ($%.6f, fromHistory=%v), want ($%.6f, fromHistory=%v)", usd, fromHistory, tc.wantUSD, tc.wantFromHistory)
}
})
}
}
func nearUSD(got, want float64) bool {
d := got - want
return d < 1e-9 && d > -1e-9
}
// TestProjectionBasisNamesWhatTheAmountIsMadeOf pins the operator-facing sentence itself: the parenthetical
// the consent is given to must say which table priced the amount, and must disclose any part of it that
// could not be re-priced instead of quietly rounding that away.
func TestProjectionBasisNamesWhatTheAmountIsMadeOf(t *testing.T) {
clean := projectionBasis(RebillProjection{Rows: 4})
if !strings.Contains(clean, "CURRENT price table") || strings.Contains(clean, "originally billed") {
t.Errorf("a fully re-priced amount names the table and claims nothing else; got: %s", clean)
}
mixed := projectionBasis(RebillProjection{Rows: 4, HistoricalRows: 1})
if !strings.Contains(mixed, "CURRENT price table") || !strings.Contains(mixed, "1 of the 4") {
t.Errorf("a partly un-re-priced amount must disclose how much of it is old money; got: %s", mixed)
}
}
// TestCheckpointsOfOneGenerationSumToTheRowsCost pins the PREMISE the generation rule stands on: within a
// single generation, a disposition row's cost_usd is exactly the sum of its own checkpoints' costs. The
// rule reads membership off that identity — walk the newest calls while they fit inside the row's money —
// so a future path that bills into cost_usd without a checkpoint, or checkpoints money the row never
// counts, would not break loudly; the projection would just start quoting the wrong number.
//
// The shapes it runs are a plain draft+edit book and an escalation hop (a different model billed under the
// SAME stage name, so both calls land in one cell). It does NOT build a retried attempt, a repair call or
// a billed-decode row — those are covered at unit level in repriceCheckpoint's own table.
func TestCheckpointsOfOneGenerationSumToTheRowsCost(t *testing.T) {
for _, tc := range []struct {
name string
setup func(t *testing.T, url string) string
reply func(body string) (string, string)
}{
{"plain draft+edit", func(t *testing.T, url string) string { return setupProject(t, url) }, draftEdit},
{"an escalation hop under the same stage name", func(t *testing.T, url string) string {
return setupEscalationProject(t, url, 1.0, nil)
}, echoOrClean},
} {
t.Run(tc.name, func(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, tc.reply)
defer srv.Close()
bookPath := tc.setup(t, srv.URL)
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
if len(statuses) == 0 {
t.Fatal("setup: the run wrote no disposition rows")
}
usage, err := r.Store.CheckpointUsageForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
stored := map[chunkKey]map[string]float64{}
for _, cu := range usage {
k := chunkKey{cu.Chapter, cu.ChunkIdx}
if stored[k] == nil {
stored[k] = map[string]float64{}
}
stored[k][cu.Stage] += cu.CostUSD
}
rp := newRepricerT(t, r)
for _, cs := range statuses {
if cs.Disposition == string(DispSkipped) {
continue // never reached a provider, so it has no checkpoints to sum
}
if got := stored[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]; !nearUSD(got, cs.CostUSD) {
t.Errorf("ch%d/chunk%d/%s: checkpoints sum to $%.6f but the row records $%.6f — the generation rule reads membership off this identity",
cs.Chapter, cs.ChunkIdx, cs.Stage, got, cs.CostUSD)
}
// And the rule consumes all of them: a residue here means the walk stopped early.
if _, fromHistory := rp.usd(cs); fromHistory {
t.Errorf("ch%d/chunk%d/%s: a single-generation row must be fully re-priced, not partly carried from history",
cs.Chapter, cs.ChunkIdx, cs.Stage)
}
}
})
}
}
// TestProjectionCountsRowsItCouldNotRePrice wires the disclosure end to end: rp.usd reporting an
// un-re-priced row must reach RebillProjection.HistoricalRows and therefore the operator's sentence.
// Without this the increment can be deleted and the whole suite stays green.
func TestProjectionCountsRowsItCouldNotRePrice(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r2)
// An EMPTY repricer is the store that lost its calls: every counted row falls back to what it was
// billed, and the projection has to say so rather than present the total as freshly re-priced.
proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, &repricer{})
if err != nil {
t.Fatal(err)
}
if proj.Rows == 0 {
t.Fatal("setup: the drift must supersede both stages")
}
if proj.HistoricalRows != proj.Rows {
t.Fatalf("every row was quoted from history; the projection discloses %d of %d", proj.HistoricalRows, proj.Rows)
}
if basis := projectionBasis(proj); !strings.Contains(basis, fmt.Sprintf("%d of the %d", proj.Rows, proj.Rows)) {
t.Errorf("the consent sentence must disclose the un-re-priced part; got: %s", basis)
}
}

View file

@ -177,15 +177,16 @@ func (r *Runner) repairSnapshot() (*repairSnap, error) {
}
// memoryVersion is the content-hash of the deterministically materialized injected
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm
// memory (the frozen glossary rows + the normalization/matcher algorithm
// versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the
// Version() of the bank materialized once before the loop, so a change to the approved
// Version() of the bank materialized once before the loop, so a change to the
// glossary — or to the deterministic machinery (trad→simp table, matcher) — fires the
// resnapshot gate LOUDLY instead of a stale checkpoint re-paying a diverged translation.
// nil bank (report path / a book with no glossary) → the empty-materialization hash, a
// stable constant. STM is excluded (rebuilt from checkpoints, §3.2). Auto/draft rows are
// excluded per D8 (their injected-content changes are caught at the per-chunk
// content_hash level; a future autopopulation milestone revisits this).
// stable constant. STM is excluded (rebuilt from checkpoints, §3.2). EVERY row folds
// whatever its status since pack-20 (D39.42 п.3 — an auto/draft row changes the injection
// and hence the re-payment, which the per-chunk content_hash alone never reached);
// the approved-only fold this comment used to describe is gone. See membank.ComputeVersion.
func (r *Runner) memoryVersion() string {
if r.memory != nil {
return r.memory.Version()

View file

@ -150,9 +150,9 @@ type StatusReport struct {
// RebillUnits/RebillUSD turn the drift BOOLEAN into the number the operator actually decides on
// (spec D15.2 §9, taken 25.07): "the config drifted" says nothing about whether continuing costs a
// cent or the whole book — these say "N chunk×stage units already billed under a superseded snapshot
// would be paid for again, ~$X". Same projection the consent gate refuses on (projectRebill), so
// status can never quote a different number than the one translate enforces. Both omitempty: a book
// with no drift keeps its exact prior bytes.
// would be paid for again, ~$X at the CURRENT price table" (row 181). Same projection the consent
// gate refuses on (projectRebill), so status can never quote a different number than the one
// translate enforces. Both omitempty: a book with no drift keeps its exact prior bytes.
RebillUnits int `json:"rebill_units,omitempty"`
RebillUSD float64 `json:"rebill_usd,omitempty"`
@ -188,11 +188,14 @@ type StatusReport struct {
// disposition — a nonzero count is "attention worth a human glance", not a failed chunk.
StyleFlags int `json:"style_flags"`
CommittedUSD float64 `json:"committed_usd"`
ReservedUSD float64 `json:"reserved_usd"`
BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
ProjectedBookUSD float64 `json:"projected_book_usd"` // committed extrapolated over the whole book
CommittedUSD float64 `json:"committed_usd"`
ReservedUSD float64 `json:"reserved_usd"`
BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
// ProjectedBookUSD extrapolates the per-processed-unit cost over the whole book at TODAY'S price
// table — deliberately NOT the book's committed spend, which is historical money and also carries
// partial spend on in-progress units (projectBookUSD).
ProjectedBookUSD float64 `json:"projected_book_usd"`
ETASeconds float64 `json:"eta_seconds,omitempty"` // secondary: mean fresh-call throughput × remaining
// ContentLabels / Routing are the content-label PROVENANCE (B6): what the book declares and which
@ -603,12 +606,19 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
}
}
// One re-pricing read serves both money projections below (reprice.go): the re-payment amount and
// projected_book_usd, which is also the base of the consent threshold — computing them from two
// price tables is precisely how the gate would go half-historical.
rp, err := r.newRepricer()
if err != nil {
return nil, err
}
// Re-payment projection (spec §9): what the drift above would COST. Computed whenever rows exist —
// not only under ConfigDrift — because SnapshotDrift (rows split across snapshots within one wave)
// re-bills too, and that is precisely the case the boolean pair leaves unpriced. $0 and read-only:
// projectRebill only re-renders the wave snapshots and sums stored costs.
// projectRebill re-renders the wave snapshots and re-prices stored usage; it reaches no provider.
if len(statuses) > 0 {
if proj, perr := r.projectRebill(statuses, chunks, withText); perr != nil {
if proj, perr := r.projectRebill(statuses, chunks, withText, rp); perr != nil {
// Same discipline as the drift check above: a failed projection is reported, never
// silently rendered as "nothing to re-pay".
r.Log.WarnContext(ctx, "re-bill projection failed; the re-payment cost of the drift is unknown (reported as none)", "err", perr)
@ -631,12 +641,14 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD
}
// Projected book cost: extrapolate the per-PROCESSED-unit average over the whole book (done +
// flagged = a unit fully attempted). NOT book committed: committed also carries partial spend on
// IN-PROGRESS units (excluded from the denominator), which would over-estimate (finding #7). The
// arithmetic lives in projectBookUSD (rebill.go) because the consent threshold is 5% of THIS number
// — one definition, so the threshold can never be computed from a drifted copy of it.
// flagged = a unit fully attempted), AT TODAY'S PRICES (row 181). NOT book committed: committed also
// carries partial spend on IN-PROGRESS units (excluded from the denominator), which would
// over-estimate (finding #7) — and committed is historical money besides, which is the other half of
// why this number is not it. The arithmetic lives in projectBookUSD (rebill.go) because the consent
// threshold is 5% of THIS number — one definition, so the threshold can never be computed from a
// drifted copy of it.
processed := rep.Done + rep.Flagged
rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages))
rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages), rp)
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
// DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain

View file

@ -0,0 +1,158 @@
package pipeline
import (
"context"
"errors"
"fmt"
"strings"
"sync/atomic"
"testing"
"textmachine/backend/internal/obs"
)
// wavepanic_test.go: a panic of a WAVE WORKER must leave the engine as a failing error, never as the
// process-level exit 2 the Go runtime gives an unrecovered panic — which is the code the shell contract
// reserves for "completed with flags", so a run that died mid-book was recorded as `ready` (row 176).
//
// The wave seam is where this has to be tested: the paid path runs inside runWave's goroutine pool
// (waverun.go), and a recover on the main goroutine alone would leave every real crash uncovered.
func TestWaveWorkerPanicSurfacesAsAFailingError(t *testing.T) {
const workers = 4
var cancelled atomic.Int32
inFlight := make(chan struct{}, workers-1)
r := &Runner{}
// The siblings do what real stage work does — wait on the wave context — so this also pins the
// masking trap: the panic cancels the wave, every other worker then fails with context.Canceled, and
// if THAT became the wave's error the run would leave as exit 5, a graceful stop. The panicking
// worker waits until the others are in flight, so the outcome does not depend on the scheduler.
err := r.runWave(context.Background(), workers, workers, func(ctx context.Context, i int) error {
if i == 0 {
for k := 0; k < workers-1; k++ {
<-inFlight
}
panic("worker exploded mid-chunk")
}
inFlight <- struct{}{}
<-ctx.Done()
cancelled.Add(1)
return ctx.Err()
})
if err == nil {
t.Fatal("a panicking worker must fail the wave; a nil error here is the run reported as success")
}
var panicked *obs.PanicError
if !errors.As(err, &panicked) {
t.Fatalf("the wave error must be a *obs.PanicError, got %T: %v", err, err)
}
if panicked.Where != "wave worker" {
t.Errorf("the panic must name the goroutine it died in, got %q", panicked.Where)
}
// The stack has to survive to whoever prints the error: on the paid path the only guaranteed reader
// is `tmctl: <err>` on stderr, and diagnosis of a crash mid-book is worth the verbosity.
msg := err.Error()
for _, want := range []string{"worker exploded mid-chunk", "goroutine", "runWave"} {
if !strings.Contains(msg, want) {
t.Errorf("the panic error must carry %q so the stack reaches stderr; got: %s", want, msg)
}
}
if errors.Is(err, context.Canceled) {
t.Error("the panic must not be masked by the cancellation it caused (that reads as exit 5, a graceful stop)")
}
// The crash stops the wave rather than letting it grind on: every sibling it cancelled did return.
if got := cancelled.Load(); got != workers-1 {
t.Errorf("a panicking worker must cancel the wave: %d of %d siblings saw the cancellation", got, workers-1)
}
}
// TestWaveWorkerPanicOutranksAnEarlierWaveError is the race an adversarial review of this pack found:
// the first error to arrive cancels the wave, and a panic in code nobody expected to run under
// cancellation arrives SECOND. Routed through first-wins it was discarded with its stack, and the run
// departed as the sibling's error — a ceiling halt (exit 4, which the platform records as `paused`) or a
// cancellation (exit 5). A process that crashed is neither.
func TestWaveWorkerPanicOutranksAnEarlierWaveError(t *testing.T) {
for _, tc := range []struct {
name string
first error
}{
{"ceiling halt (exit 4 = paused)", &CeilingHalt{Scope: "book", err: errors.New("book USD ceiling reached")}},
{"cancellation (exit 5 = graceful stop)", context.Canceled},
{"an ordinary infra error", errors.New("provider unreachable")},
} {
t.Run(tc.name, func(t *testing.T) {
const workers = 2
inFlight := make(chan struct{})
r := &Runner{}
// Item 0 is dispatched first, so the panicking worker is parked and holding its item before
// the sibling fails — otherwise the wave's cancellation would drop item 1 and no panic would
// happen at all. It then waits for ctx.Done(), which runWave fires only AFTER recording the
// sibling's error, so the panic is strictly second without a sleep.
err := r.runWave(context.Background(), workers, workers, func(ctx context.Context, i int) error {
if i == 0 {
close(inFlight)
<-ctx.Done()
panic("worker exploded under cancellation")
}
<-inFlight
return tc.first
})
var panicked *obs.PanicError
if !errors.As(err, &panicked) {
t.Fatalf("the crash must outrank the earlier wave error; wave returned %T: %v", err, err)
}
if !strings.Contains(err.Error(), "goroutine") {
t.Errorf("the stack must survive the race; got: %s", err.Error())
}
})
}
}
// TestPanicErrorMatchesNoExitSentinel is the trap the fix is one line away from: an error born of
// recover that matched a dictionary sentinel would leave through a NON-failing exit code with the
// dictionary formally untouched — 2 flags, 3 signature stop, 4 ceiling (the platform reads `paused`),
// 5 graceful stop, 10-19 refusal ("nothing was spent"). The panicked VALUE is deliberately one of those
// types in each case, because that is the shape that would slip through an Unwrap.
func TestPanicErrorMatchesNoExitSentinel(t *testing.T) {
for _, tc := range []struct {
name string
value any
}{
{"plain string", "boom"},
{"runtime error", fmt.Errorf("nil map write")},
{"context.Canceled", context.Canceled},
{"CompletedWithFlags", &CompletedWithFlags{Flagged: 1, Total: 2}},
{"WaveSignatureStop", &WaveSignatureStop{Terms: 3}},
{"CeilingHalt", &CeilingHalt{Scope: "book", err: errors.New("ceiling")}},
{"Refusal", refuse(RefusalBadConfig, errors.New("bad config"))},
} {
t.Run(tc.name, func(t *testing.T) {
// Wrapped, because that is how it reaches main: through the wave, the driver and translate().
err := fmt.Errorf("pipeline: wave draft: %w", obs.NewPanicError("wave worker", tc.value))
var flagged *CompletedWithFlags
var sigStop *WaveSignatureStop
var ceiling *CeilingHalt
var refusal *Refusal
if errors.As(err, &flagged) {
t.Error("a panic must not read as completed-with-flags (exit 2)")
}
if errors.As(err, &sigStop) {
t.Error("a panic must not read as a signature stop (exit 3)")
}
if errors.As(err, &ceiling) {
t.Error("a panic must not read as a ceiling halt (exit 4 — the platform records `paused`)")
}
if errors.As(err, &refusal) {
t.Error("a panic must not read as a refusal (10-19 — the band promises nothing was spent)")
}
if errors.Is(err, context.Canceled) {
t.Error("a panic must not read as a graceful stop (exit 5)")
}
var panicked *obs.PanicError
if !errors.As(err, &panicked) {
t.Error("the panic must stay recognisable through the wrap")
}
})
}
}

View file

@ -12,6 +12,7 @@ import (
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/runevents"
"textmachine/backend/internal/store"
)
@ -247,6 +248,15 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
// A crash gets its OWN slot rather than competing for firstErr, and outranks it on the way out. Both
// halves are load-bearing. Unrecovered, a worker panic takes the process down through the runtime's
// handler, which exits 2 — the code the shell contract reserves for "completed with flags", so a run
// that died mid-book was recorded as `ready` (row 176). But routing it through first-wins would only
// move the lie: a sibling that already failed cancels the wave, and a panic in the code nobody expected
// to run under cancellation would then be discarded with its stack, leaving the run to depart as the
// sibling's ceiling halt (exit 4, `paused`) or cancellation (exit 5). A process that crashed is not
// paused and did not stop gracefully.
var panicErr error
fail := func(err error) {
mu.Lock()
if firstErr == nil {
@ -255,10 +265,24 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c
}
mu.Unlock()
}
failPanic := func(err error) {
mu.Lock()
if panicErr == nil {
panicErr = err
}
mu.Unlock()
cancel()
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
// NOT obs.SafeGo: this recover does not let the run continue, it makes the run fail loudly.
defer func() {
if p := recover(); p != nil {
failPanic(obs.NewPanicError("wave worker", p))
}
}()
for i := range idxCh {
if err := work(ctx, i); err != nil {
fail(err)
@ -275,6 +299,9 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c
}
close(idxCh)
wg.Wait()
if panicErr != nil {
return panicErr
}
if firstErr != nil {
return firstErr
}

View file

@ -339,6 +339,45 @@ func (s *Store) RoleResponsesForBook(bookID, role, mustContain string) ([]RoleRe
}, args...)
}
// CheckpointUsage is one billed call's TOKENS and routing, addressed the way chunk_status is —
// (chapter, chunk, stage) — rather than by request_hash.
//
// It is the bridge a re-payment projection needs (row 181). chunk_status carries only a summed
// cost_usd in the currency of the day it was billed, and final_hash reaches a single checkpoint only on
// the ok path, so a flagged row has no route back to its own usage at all. The job join has one: jobs are
// (book, chapter, stage) and checkpoints carry chunk_idx, which is exactly the chunk_status key, for
// every attempt of every disposition.
type CheckpointUsage struct {
Chapter int
ChunkIdx int
Stage string
ModelRequested string
ModelActual string
UsageJSON string
CostUSD float64 // what it was billed AT THE TIME — the historical figure, kept for the fallback
}
// CheckpointUsageForBook returns every checkpoint's usage for a book in INSERTION order. The order is
// load-bearing, not cosmetic: the table is append-only for the life of the book, so a position
// accumulates one call per re-purchase, and the newest can be told from a superseded one only by when it
// was written. `attempt` cannot do it — it restarts at 0 every run — so the ordering rides rowid.
//
// response_text is deliberately NOT selected: it is the megabytes of the table and no pricing question
// needs a byte of it.
func (s *Store) CheckpointUsageForBook(bookID string) ([]CheckpointUsage, error) {
return queryAll(s.r, `
SELECT j.chapter, c.chunk_idx, c.stage, c.model_requested, c.model_actual, c.usage_json, c.cost_usd
FROM checkpoints c JOIN jobs j ON j.id = c.job_id
WHERE j.book_id = ?
ORDER BY c.rowid`,
func(rows *sql.Rows) (CheckpointUsage, error) {
var u CheckpointUsage
err := rows.Scan(&u.Chapter, &u.ChunkIdx, &u.Stage, &u.ModelRequested, &u.ModelActual,
&u.UsageJSON, &u.CostUSD)
return u, err
}, bookID)
}
// SpentUSD reports (committed, reserved) for a book across all days.
func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error) {
ctx, cancel := opContext()

View file

@ -0,0 +1,51 @@
package store
import "testing"
// TestCheckpointUsageForBookReturnsInsertionOrder pins the ordering the re-payment projection reads
// generation membership from (pipeline/reprice.go). Checkpoints are append-only for the life of a book,
// so telling this run's calls from a superseded run's is possible only by WHEN each was written.
// `attempt` cannot do it — it restarts at 0 every run — which is exactly what this fixture makes visible:
// the attempt numbers run 0,1,0,1 while the insertion order is g1a0, g1a1, g2a0, g2a1.
func TestCheckpointUsageForBookReturnsInsertionOrder(t *testing.T) {
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
for _, cp := range []struct {
hash string
attempt int
cost float64
}{
{"g1a0", 0, 0.01}, {"g1a1", 1, 0.02}, // generation 1
{"g2a0", 0, 0.03}, {"g2a1", 1, 0.04}, // generation 2, written later, attempts restart
} {
res, _, err := s.Reserve("book", cp.cost, Ceilings{BookUSD: 100, DayUSD: 100})
if err != nil {
t.Fatal(err)
}
if err := s.SettleWithCheckpoint(res, cp.cost, Checkpoint{
RequestHash: cp.hash, JobID: job.ID, ChunkIdx: 0, Attempt: cp.attempt, Stage: "draft",
Role: "translator", ModelRequested: "m", ModelActual: "m", UsageJSON: "{}", CostUSD: cp.cost,
}, nil); err != nil {
t.Fatal(err)
}
}
rows, err := s.CheckpointUsageForBook("book")
if err != nil {
t.Fatal(err)
}
var got []float64
for _, r := range rows {
got = append(got, r.CostUSD)
}
want := []float64{0.01, 0.02, 0.03, 0.04} // ordering by `attempt` would give 0.01, 0.03, 0.02, 0.04
if len(got) != len(want) {
t.Fatalf("got %d rows, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("checkpoint order = %v, want insertion order %v", got, want)
}
}
}

View file

@ -1,6 +1,6 @@
# Журнал прогресса
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-17, голова D39.148КУРС: движок и платформа до «работает и отдаёт результат», фронт морозится ДАЛЬШЕ лендинга P7; гейт доков усилен линтом якорей в хуке (D39.148). ОЧЕРЕДЬ №18 (единственный носитель — здесь): (а) приёмка живого P7 → лендинг **БЕЗ S5 и без разморозки** (D39.147: P7 зону не размораживает) · (а2) промт бэкенд-пака честности ВЫДАН — `BACKEND_HONESTY_PACK_SESSION_PROMPT.md` (176 · 181 · 172-г · 187; п.5 живой вахты — только при названной владельцем сумме), запуск по слову владельца; рубежи D39.141 закрыты двумя проходами опровергателя + сверкой вливания (⚠ урок в норму: первый опровергатель САМ принёс ложную атрибуцию кодов 03 ноте D39.131, поймал её автор сверкой с телом ноты — рубежи не заменяют друг друга, они ловят разное) · (б) лендинг петель полигона — ⚠ в дереве НЕзадокументированная работа 16.08 (`eval/dovodka/PLAN-16-08.md`, `ja6.py`, `naklon.py`, свежие фризы) без записи в журнале, состав выяснить у полигона · (в) пере-съём классификаторного гейта с порогом 116 в риге — санкция владельца 17.08, потолок $0.20, исполняется п.5 пака честности; ⚠ весов **pro** он НЕ проверяет и строку 172-г не закрывает (разбор — в её теле) · (г) свободные бэкенд: 160 Этап 0, 181 (дедлайн — первый пере-прогон) · (д) строка 148 после фраз владельца; лента нот эры — реестр `architecture/05-decisions-index.md`). Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей после инцидента отката чужих файлов — D39.113, гардрейлы в CLAUDE.md.
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-17, голова D39.149КУРС: движок и платформа до «работает и отдаёт результат», фронт морозится ДАЛЬШЕ лендинга P7; гейт доков усилен линтом якорей в хуке (D39.148). ОЧЕРЕДЬ №18 (единственный носитель — здесь): (а) приёмка живого P7 → лендинг **БЕЗ S5 и без разморозки** (D39.147: P7 зону не размораживает) · (а2) пак честности **ИСПОЛНЕН, ПРИНЯТ С ФИКС-ЛИСТОМ и ЗАЛЕНДЕН** (D39.149; строки 176/181/187 закрыты, 172-г НЕ закрыта — риг меряет flash); открыто: фикс-лист ФЧ-1…ФЧ-8 (носитель — запись приёмки в секции «Бэкенд») и два решения владельца — ось МОДЕЛИ в числе согласия (ФЧ-5) и маркер поколения на `checkpoints` (строка 195); рубежи выдачи промта D39.141 были закрыты двумя проходами опровергателя + сверкой вливания (⚠ урок в норму: первый опровергатель САМ принёс ложную атрибуцию кодов 03 ноте D39.131, поймал её автор сверкой с телом ноты — рубежи не заменяют друг друга, они ловят разное) · (б) лендинг петель полигона — ⚠ в дереве НЕзадокументированная работа 16.08 (`eval/dovodka/PLAN-16-08.md`, `ja6.py`, `naklon.py`, свежие фризы) без записи в журнале, состав выяснить у полигона · (в) пере-съём классификаторного гейта с порогом 116 в риге — санкция владельца 17.08, потолок $0.20, исполняется п.5 пака честности; ⚠ весов **pro** он НЕ проверяет и строку 172-г не закрывает (разбор — в её теле) · (г) свободные бэкенд: 160 Этап 0, 181 (дедлайн — первый пере-прогон) · (д) строка 148 после фраз владельца; лента нот эры — реестр `architecture/05-decisions-index.md`). Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей после инцидента отката чужих файлов — D39.113, гардрейлы в CLAUDE.md.
> - **Эра №15 закрыта — семь приёмок, все ПРИНЯТЫ и залендены**; лента, коммиты и разборы — D39.109123 (D-лог) и реестр нот `architecture/05-decisions-index.md`; снимок прежних бюллетеней этой шапки — архив-слайс `-08-02-04`.
> - **ЖИВОЕ:** полигон — **фаза Д ИДЁТ** (заказ 10.08; деньги санкционированы 15.08 напрямую полигону — ⚠ числа потолка в носителях расходятся, фактическую цепь сверить при лендинге петель; ja-книга `enkan_no_hate_ja`; при ратификации фазы Д в D-ноту: декой-правило + обязательный кросс-семейный опровергатель приёмки — одобрены 10.08; свежие фриз-коммиты полигона в дереве — НЕ трогать) · **платформа — P7 ЗАПУЩЕН 16.08** (промт `platform/docs/PLATFORM_P7_SESSION_PROMPT.md`, строит по канону 0.3.0; приёмка — очередь №18; первым шагом пака — рантбук migrate end-to-end, предусловие выката) · **фронт ЗАМОРОЖЕН** (D39.136 п.2 + **D39.147: морозится ДАЛЬШЕ лендинга P7 — слово владельца 17.08; P7 зону НЕ размораживает, S5-промт не выдаётся, разморозка отдельным словом по достижении сквозного пути**; перечень первого касания зоны — зеркало 0.3.0 + перегенерация типов + моки + гейт утечки конвейера + ФС-1..12 + Ф-63/Ф-28 + фразы В-11 по словарю кодов — не отменён, ждёт разморозки) · **контракт 0.3.0 ФИНАЛЕН в каноне** (батч+дофикс D39.142/143 · модель подписи «один ОК» D39.144 · дочистка D39.145; зеркало фронта отстаёт ратифицированно) · закрытые стройки эры — лентой нот: эмиттер D39.131 · P5/P6 D39.130/132 · migrate D39.134 · S4+0.2.3 D39.135 · DeepSeek-репин D39.137 (тела — D-лог и слайсы).
> - **Открыто на владельце:** **развязка git с origin** (локальная линия ИСТИННА, force-push его рукой; не пуллить) · Приложение А контракта (148: фразы — по словарю кодов 0.3.0, структура готова) · продуктовое слово «остановлена: лимиты» (В-3) · В-4/Ф-30 (глава без заголовка — движковая половина строка 160) · мини-проба флора 44 (одобрена, промт не выдан; число — после ре-пробы 188) · подпись денежного шага 46 · лист В-3+К-6 · авто-резюм paused (вопрос платформы) · **схема time-based DeepSeek** (доклад — архив-слайс `PROGRESS-2026-08-14-15`; рекомендация: пик оставить + операционное правило «прогоны в долины», scheduler не начинать без ответа вендора об отметке тарификации) · **вход ратификации фазы Д:** посылка «dspro дешевле glm» в пике ПЕРЕВЁРНУТА (×1.26 дороже, D39.137 п.4) · **возражение Sol по проходу `tier` эксп-23** (пере-судить починенным ригом или оставить с записанным возражением — секция «Полигон») · фронт-вопросы зонного журнала: В-7 (плотность; держит Ф-54) · В-8 (слово состояния в дереве) · В-9 (языки интерфейса — механизм готов, ПТ-36) · Ф-38 (вкладка «Замечания»). Закрытые пункты листа (PD-104 · В-10 · В-11-форма · Ф-56/57/61/62/63 · Ф-28 · ПТ-33-граница · 116 · 126 · 172-пин · PD-203 · санкции фазы Д · дизайн 160/161 · title) — в нотах D39.136145, здесь не держатся.
@ -8,7 +8,7 @@
> - **Курс:** ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.5978) → **ФРОНТ-ЭРА** (D39.81100: зоны живые, контракт API ратифицирован) → **шов/платформа/движковые блокеры построены** (D39.106123). Хвосты курса живут строками: 16 (edit-волна не гонялась НИ РАЗУ — держит и оси голоса 13б/24) · 46 (дизайн заморожен D39.92/93, промт ждёт выдачи) · coldrun-b фаза C заморожена чекпойнтом легитимно (D39.86; эталон денег/поведения — coldrun-a, read-only); развилка 0731 решена и исполнена (D39.87/91, код `553f1a3`).
> - **Горизонт (D39.62/67, освежён D39.95):** **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · цена 16; жильцы ролей решаются ДО прогона эксп-22 — строка 149 · веса K1K12 13а · вне-претрейн чекпоинт 55; остаток арбитража банка = рецензент спорных кластеров при ре-пробе 74 — D39.102) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 6268, 85) → Ф3 ридер-IDE (6971). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 (⚠ + x/text Unicode 17 тем же тулчейном — строка 119, реестр §Б-108 справочника якорей) · платные прогоны разблокированы (проба провода — D39.97, конфиги 112 залендены).
> - **Стек (полная карта роль→модель→конфиг→квирки — [STACK.md](STACK.md), D39.126):** draft deepseek-v4-flash thinking-ON `low` **⚠0731** → терминолог (та же модель) → editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (топология ПОДТВЕРЖДЕНА при неразличимости жильцов — D39.117; закон-блок ОБЯЗАТЕЛЕН — строка 134; glm-5 резерв; вахта маппинга эффорта pro — §Б-108) → судья gemini (Ф2, в движке НЕ построен — строка 33); канал B Mistral+grok; ~$0.85/ранобэ (D30.4, пере-калибровка при следующем платном прогоне). ⚠ **Вендор-факты 1315.08 (пере-пин ИСПОЛНЕН и ПРИНЯТ, D39.137):** таблица цен запинена ПИКОМ (flash 0.44/1.32 · pro 1.32/3.96/кэш-хит 0.044 за 1M; счёт шиппинг-c1 = 100% DeepSeek ⇒ ×4.24.4 в пике / ×2.12.2 в долине — замер по трём прогонам); у pro эффорт стал настраиваемым low/high/max (квирк 3а устарел — вахта §Б-108 сработала); ⚠ ВЕСА pro сменились под тем же слагом (V4-Pro-0813, класс D39.61) — вахта-риг готов (остаток 172); ⚠ посылка интерим-редактора «dspro дешевле glm» в пике ПЕРЕВЁРНУТА (×1.26 дороже — вход ратификации фазы Д, D39.137 п.4); покупки фазы Д на deepseek до 16.08 16:00 UTC — по старым ценам. ⚠ **ВЕСЬ банковый контур (банкнота+терминолог+классификатор) в shipping-c1 НЕ включён** — жив ран-локальным конфигом книги (строка 140; сверка STACK.md 09.08 — факт шире прежней декларации «одна банкнота»); эскалация в shipping за `budget_usd: 0` (STACK.md §примечания).
> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 17.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **150** строк · зона бэкенд **73** строго / **104** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183187 — контракт-ревью 28, D39.138; 188190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **36** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже).
> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 17.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **153** строк · зона бэкенд **74** строго / **107** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183187 — контракт-ревью 28, D39.138; 188190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147; 194196 — приёмка пака честности, D39.149) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **38** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже).
> - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `-10-13` (D39.6-гигиена) · `-13-25` (стройка паков 1116, rerun2) · **`-25-31` (паки 1720 · мини-прогон · полигон-пакеты 58 · ToS · холодный прогон; D39.2658)** · **`-08-01-02` (сессии №9/№10: общность · качество банка · coldrun-b · открытие фронта/платформы; D39.5990, срез D39.105)** · **`-08-02-04` (сессии №11№13: ручки эффорта · стандарты · контракт API · платформа P0 · банк-арбитраж; D39.91105 + снимок шапки эры №15)** · `-08-04-09` (пинги закрытых паков эры №15) · **`-08-14-15` (закрытые бэкенд-записи №1617: эмиттер шва · migrate · пере-пин DeepSeek; вынесено D39.139)**. Записи ниже — живой хвост (№16+, эра D39.124+; подрезка D39.139).
## Бэклог (ЕДИНЫЙ, собран 26.07, актуализация 04.08 D39.99/D39.101; правки — только через оркестратора)
@ -135,6 +135,9 @@
| 191 | **Подпись банка = ОДИН ОК всему банку (модель владельца 16.08, D39.144)** — пер-термный полный набор решений НЕ гейт нигде: (а) контракт поправлен (409-полноты снят с resume, `pending_decisions` информационный — исполнено D39.144); (б) **движок**: verify_bank-пауза снимается командой resume с решениями «как есть», нерешённое едет авто-строками с пометкой — авто-провод D39.42 п.3 УЖЕ так работает, проверить/ослабить только границу самой паузы; (в) **платформа**: канал решений P7 строится накопительным БЕЗ гейта полноты + проводка «resume = снятие стопа» до движка; (г) пост-ридинговый цикл правок — выселен строкой **192** (носитель думания) | бэкенд+платформа | скоро (**P7 запущен** — (в) исполняется им; (б) сверка границы паузы движка — внутри P7 чтением кода, правка движка при расхождении = пинг №18) | (б) малое касание движка · (в) внутри P7 | слово владельца 16.08, D39.144, D39.42 п.3 |
| 192 | **Пост-ридинговый цикл правок банка — «наше подумать» владельца 16.08 (записано, чтобы не потерялось; точную механику «не продумал ещё никто и нигде» — его слово).** UX-модель целиком: юзер нажал перевод → черновая волна + майнинг вместе → на фронте появляются ЧЕРНОВОЙ перевод и ЧЕРНОВОЙ банк · подписывается ВЕСЬ банк одним ОК (D39.144) → перевод уходит в доработку · дальше юзер ЧИТАЕТ, и его опции: поправить термин в банке ЛИБО добавить свой → кнопка «поправить» → пере-генерация, которая по ВХОЖДЕНИЯМ правит запрошенное. Открытые вопросы дизайна: что именно перегенерируется (вхождения ключа · затронутые юниты · со сметой и согласием?) · нужен ли движковый ТОЧЕЧНЫЙ РЕДАКТОР (полигон над этим работает — фаза Д) · форма в контракте (ручки нет) и на экране. Уже лежит под ногами: движковый re-edit по ключу банка со сметой и $0-пере-пином ПОСТРОЕН (строка 49: `repin.go`/`rebill.go`, D39.42 п.5/D39.45) · пометка неподписанных строк · дельта-чтение банка (0.3.0). НЕ проектировать до полигонных итогов; затем дизайн-пак на их результатах → слово владельца → стройка | владелец+полигон → бэкенд/контракт/фронт | когда-нибудь (триггер: итоги фазы Д по точечному редактору) | дизайн-пак после полигона → слово владельца → стройка | слово владельца 16.08, D39.144 п.1 |
| 193 | **Дыры выдачи МОЛЧАТ в самом тексте книги** (инвентарь №18, всё грунтовано кодом): выпавший c-lite-член юнита исчезает из отгруженного текста БЕЗ маркера — читатель получает склейку без пропущенного куска и признака в тексте нет (`internal/pipeline/export.go:211-223`) · флагнутый юнит отдаётся пустым `final_text` (`:324-329`) · платформа поверх сознательно срезает `flag_reason`/`detail` до трёх значений (`platform/internal/ingest/export.go:21-24`) ⇒ на читательской поверхности дыра есть, а причины нет · и ни один гейт не отказывается выдать дырявую книгу: `export` возвращает успех даже при 100% pending (`cmd/tmctl/render.go:474-475` — «export is an audit projection, not a run verdict»), лестницы `run_complete/structurally_complete/publishable` в коде 0 вхождений. Родня: 141 (сноска без слова-заголовка уезжает дырой) и «политика красных» строки 49 — брать ОДНИМ касанием с ассемблером, врозь не имеет смысла | бэкенд (+платформа) | скоро (вместе с 49) | маркер дыры в тексте + причина на провод + отказ отдавать дырявое как готовое | инвентарь №18, **D39.147** п.2б |
| 194 | **Деньги банковых ролей вне контура согласия** (приёмка №18, клейм сессии подтверждён двумя линзами и признан ПРЕЕXISTING): `UpsertChunkStatus` зовётся только волновыми путями, а терминолог и классификатор чекпоинтятся под стадией `terminology` в главе 0 и строк `chunk_status` НЕ пишут ⇒ их деньги не входят НИ в число согласия, НИ в `projected_book_usd`, при этом на сдвиге снапшота они реально пере-покупаются. Дыра ровно того же класса, что чинила строка 181, и это вторая половина дыры Р6 (родня 160/185, 38). Паком не введено и в его заказ не входило | бэкенд | скоро (следующее касание денежного пути) | расширение проекции на банковые роли + тест | приёмка №18, отчёт сессии 17.08 |
| 195 | **Маркер поколения на `checkpoints` — СХЕМНОЕ решение, не третья заплата** (вопрос сессии оркестратору, приёмка №18): членство чекпоинта в текущем поколении сегодня ВЫВОДИТСЯ из денег строки (обход с нового конца, пока вызовы помещаются в `cs.CostUSD`) — это вывод, а не тождество, и он ломается на откате конфига (детектор перебора ловит только одну сторону — ФЧ-2). Точное решение = `snapshot_id`/`run_id` на денежной таблице: миграция + ратификация. ⚠ До неё каждая правка репрайсера — заплата поверх вывода | владелец → бэкенд | скоро (перед следующим касанием репрайсера) | ратификация схемы → миграция | приёмка №18 |
| 196 | **Остаточный класс строки 176: неперехватываемые `fatal error` Go** (конкурентная запись в map, OOM, стек-оверфлоу) по-прежнему дают exit 2, то есть читаются платформой как «завершено с флагами» — `recover` их не ловит по устройству языка (подтверждено линзой шва исполнением). Строка 176 закрыта для ПАНИК, не для fatal. Лечение — вне exit-контракта: сторож процесса у платформы либо маркер живости в артефакте прогона | бэкенд/платформа | когда-нибудь | отдельное решение | приёмка №18, линза шва |
| 49 | **Этапы Б+В спеки D15.2** (content-addressed resume / `guard_hash` — D39.31 сознательно не строил) ; этап В = tmctl export-контракт D29.1 (annot-v1 · цвет-мап+rollup · политика красных fail-closed) + операторский протокол-документ релиза гендер-твиста (D5.1): сам механизм УЖЕ построен — until_ch-правка → точечный re-edit со сметой и $0-пере-пином (`repin.go`/`rebill.go`, D39.42 п.5/D39.45) | бэкенд | скоро (ПОДТЯНУТА D39.81: annot-v1 = источник замечаний для фронта — критический путь подключения) | отдельное решение (annot-половина — по контракту 14, D39.99) | D39.34(4), D33 п.5, D39.81 |
| 49а | **ALTER-шаги миграций v8v14 не идемпотентны вопреки шапке `migrate.go:9-11`** (полу-применённая БД не сходится; счётчик версий скрывает) — находка критика полноты пака-19 | бэкенд | когда-нибудь | отдельное решение | D39.56, PACK19_BUILD §6.9 |
| 50 | F3-остаток идемпотентности | бэкенд | когда-нибудь | отдельное решение | D39.34(4) |
@ -192,6 +195,67 @@
**⟶ Состояние бэкенда — CURRENT-STATE (один носитель, D39.80); здесь только пинги живых сессий.**
### Пак «честные числа и статусы» (строки 181 · 176 · 172-г · 187) — ИСПОЛНЕН, дерево не закоммичено (17.08)
**Записка-план (пункт заказа → где).** 1) 181 — `internal/pipeline/reprice.go` (нов.) + `store/ledger.go` (`CheckpointUsageForBook`) + `rebill.go`/`status.go`; тест `reprice_test.go`. 2) 176 — `internal/obs/panic.go` (нов.), recover воркера `waverun.go:260-268`, `exitOf` в `cmd/tmctl/main.go`, ветка `*obs.PanicError`→1 в `exitCode`; usage-строка `invocation.go:96` + её замороженный тест; тесты `wavepanic_test.go`, `panic_exit_test.go`. 3) 172-г`live_reprobe_threshold_test.go` (нов., без тега) + риг `live_reprobe_test.go`. 4) 187 — `snapshot.go:179-190`, `membank/memory.go:359-365`. 5) живой прогон — исполнен, $0.005582.
**181, развилка источника чисел (решение).** Пере-прайс идёт ФАКТИЧЕСКИМИ токенами чекпоинтов через тот же шов, что и сеттл (`Pricer.PriceForResponse` + `ledger.CostUSD`), а не оценкой `EstimateUSD`. Причины: оценка резервирует весь `max_tokens` + reasoning-буфер и завысила бы кратно (лечим «число врёт вниз» числом, врущим вверх); оценке нужны отрендеренные сообщения, которых read-путь сознательно не имеет; через шов сеттла проекция не может разъехаться с тем, что забронирует леджер. Мост к usage — НЕ `FinalHash` (он только на ok-пути, как и предупреждал заказ), а джойн `checkpoints ⋈ jobs` по (chapter, chunk_idx, stage): он достаёт usage и для flagged-строк, и для всех попыток. **Обе половины гейта починены вместе:** `projectBookUSD` (база 5%-порога и `projected_book_usd`) пере-прайсится тем же репрайсером — иначе порог сравнивал бы две разные валюты. Историческими остаются `committed/reserved` и `CostUSD` глав-паспортов: потраченное — факт, а не проекция. **Не проецируется (задокументировано, как и контент-ось):** РОУТИНГ — строка прайсится моделью, которая ОТВЕТИЛА, а не той, в которую стадия резолвится сейчас. `HistoricalRows` считает строки, чей usage недостижим (чекпоинт снесён redrive'ом; billed-decode с `{}`-usage) — они несут историческую сумму, и операторский текст это НАЗЫВАЕТ.
**176, выбор кода.** Паника (любой горутины) → **1**, «infra failure and everything else». Не новый номер: обе полосы — замороженный шов, а слово в ратифицированном словаре не заводится сессией. Ветка `*obs.PanicError` стоит ПЕРВОЙ в `exitCode`. `obs.PanicError` намеренно БЕЗ `Unwrap`: паника значением `context.Canceled`/`*CeilingHalt` иначе уехала бы в 5/4 при формально нетронутом словаре (покрыто тестом на 7 значений). `obs.SafeGo` НЕ применён — он глотает. Стек едет в `Error()`, значит на stderr при любой обёртке.
**Команды и результаты.** `make battery` → EXIT=0; SKIP ровно два: `TestHelperEventsRun`, `TestHelperKillLoop`оба helper-процессы подпроцессных харнессов (`t.Skip("helper process only")` без env-ключа), не пропуск покрытия. Корпусные четыре (`TestMinerFullBookParity`, `TestCheckerLabels*`, `TestK6LabelsBaseline`) на этом стенде ПРОШЛИ, не скипнулись. Голден: `go test ./internal/pipeline/ -run Golden -count=1` → ok, бит-в-бит. `go vet -tags live ./...` → чисто. `grep -rh '^func Test' backend --include='*_test.go' | wc -l` = 819; в диффе `^-func Test` = 0 удалённых, +9 добавленных. ⚠ **Испр. приёмкой №18:** финальное число — **827** (пере-считано командой отчёта после последних правок; 819/823 — промежуточные снимки), удалённых имён 0 подтверждено.
**Красное до фикса — исполнением** (откат в рабочем дереве, без git): 181 — `re-payment projected at $0.003640 but the current price table makes that work $0.014560`; отдельно откат только базы порога — `status projected_book_usd = $0.003640, want $0.014560`. 176 — при снятом recover тест-бинарь падает паникой (`panic: worker exploded mid-chunk`), а реальный бинарь при непойманной панике выходит **2** (замерено: собранная программа-однострочник, `exit=2`). Usage-строка — `usage error text is frozen, got: usage: tmctl <translate|report|status|export|redrive|manifest> --config book.yaml`. Правок тестов ради зелени нет: изменены только `invocation_test.go` (санкция тела 176 + D39.134 п.3) и механические сайты вызова после смены сигнатур `projectRebill`/`projectBookUSD` — утверждения не тронуты.
**Пункт 5, живой прогон (санкция владельца 17.08, потолок $0.20).** Стенд `~/books/gu-zhenren/coldrun-b/reprobe/classify6/` был на схеме v14 против v15 — `status` дал ратифицированный `exit 13 schema_mismatch`, прогнан `tmctl migrate` (v14→v15, restore point взят). Замер: **4/5 прогонов достигли 6/6**, `finish=stop` на всех пяти, `bad_lines=0`, модель `deepseek-v4-flash`, `max_tokens=8000`, эффорт `unset`. **Итог: ГЕЙТ ПРОЙДЕН по ратифицированной форме — и это ровно тот 4/5, на котором старая форма «6/6 на КАЖДОМ» упала бы ложно.** Трата **$0.005582424**; леджер пробы 0.00038997→0.00597239, дельта совпала с числом рига до последнего знака, `reserved_usd=0`. Ревью исполнением: запросы прочитаны глазами (дамп `classifierMessages` в `classifier-request.json``reasoning:""`, то есть reasoning-off НЕ шлётся и thinking у DeepSeek остаётся ON, эхо-мина не взведена), сырые ответы прочитаны из `classifier-6of6-unset.json`, стоимость двух прогонов пересчитана вручную по новой таблице flash и сошлась (run0 = 1143·0.44 + 869·1.32 за 1M = $0.001650). ⚠ **Строку 172-г НЕ закрываю:** риг меряет классификатор на flash, а сменившиеся веса — у pro (редактор).
**Диспозиция строки 164 — ОСТАВЛЕНА ОТКРЫТОЙ, и она по-прежнему верна.** Сверено кодом: `Status()` (`status.go:388`) не зовёт `seedGlossary`, поэтому `r.baseMemory` на read-пути nil (`seeding.go:157-159` — единственное место присвоения), `precomputeSticky` даёт пустую инъекцию и `renderedContentHashes` не опознаёт repin (`rebill.go:166`). Пак этого не двигает: он чинит ЦЕНУ строки, а 164 — про то, какие строки вообще попадают в счёт. Разделение честнее, потому что лечение 164 — материализация банка на КАЖДОМ `status` (его платформа зовёт перед каждым спавном), то есть решение о цене read-пути, а не правка текста. Пере-прайс её не маскирует — он масштабирует завышение той же таблицей.
**Находки вне заказа (не чинил, оркестратору на завод строк).** (1) Промт §5 называет `LOG_LLM_BODIES=1`+`LOG_LEVEL=debug` носителем тел запросов для рига — **не работает**: ключ читается из `obs.ReqInfo` контекста (`cmd/tmctl/main.go:196-200` → гейт `obs/logging.go:77`), а `live_reprobe_test.go:119` строит ctx без `WithReqInfo`, поэтому тела не логируются ни при каких env. Использован второй разрешённый носитель (дамп). (2) `docs/experiments/00-provider-quirks.md` до сих пор пишет «цены DeepSeek не изменились (flash $0.14/$0.28)» — протухло со сменой 16.08 (D39.137), зона не моя. (3) Якоря строки 181 ПРОВЕРЕНЫ и оказались точными (не расхождение, называю потому что сверял): в HEAD `rebill.go:166` = `p.USD += cs.CostUSD`, `:252` = сравнение с потолком согласия, `stagerun.go:431/:613` = `EstimateUSD(PriceFor(...))` и `PriceForResponse(...)`. (4) Прежний артефакт стенда `classifier-6of6.json` — старого одно-сэмплового формата; текущий риг пишет `classifier-6of6-<effort>.json`, старый файл рядом никем не перезаписывается и вводит в заблуждение. (5) Список команд usage-строки и подсказки `unknown command` были двумя ручными копиями; сведены в один `dispatchCommands` (байты подсказки не изменились). (6) Строки 146/173/177 по пути видел — не трогал (подписанный пропуск заказа).
**Адверсариальное ревью §4 — ПРОВЕДЕНО (17.08, воркфлоу `wf_6f671334-f69`: 23 агента, 0 ошибок, 1.95M токенов, 37 мин).** Три независимые линзы (fable — деньги/алгоритм/контракт; opus — ремесло/проза/идиоматика; opus — шов exit-кодов + верность §3/§4), по скептику-опровергателю на каждую находку, критик полноты финалом. 20 находок → **11 опровергнуто**, 8 выжило, +5 от критика.
| Находка (линзы) | Вердикт | Диспозиция |
|---|---|---|
| **Репрайсер суммирует чекпоинты ВСЕХ поколений**: `checkpoints` append-only на всю жизнь книги и не несут снапшота, а `chunk_status.cost_usd` ПЕРЕЗАПИСЫВАЕТСЯ текущим поколением ⇒ после первой же пере-покупки число согласия и `projected_book_usd` завышены ×N | CONFIRMED ×3 линзы, воспроизведено исполнением | **ПРИНЯТА — регресс МОЕГО же фикса, исправлен.** Членство поколения выводится из единственного пер-поколенного авторитета в сторе — денег самой строки: обход вызовов С НОВОГО КОНЦА, пока они помещаются в `cs.CostUSD` (`reprice.go`). Потребовало порядка вставки: `ORDER BY c.rowid` (по `attempt` нельзя — он перезапускается каждый прогон) |
| **Пропущенные (`skipped`) строки прайсились по осиротевшим чекпоинтам** своей позиции — фантомные деньги в базе порога, без всякого `--resnapshot` | PARTIAL (тот же корень) | **ПРИНЯТА, закрыта тем же фиксом:** у `skipped`-строки `cs.CostUSD=0` ⇒ обход не берёт ни одного вызова |
| **Паника воркера, проигравшая гонку другой ошибке волны, исчезала бесследно**`fail` first-wins, и прогон уходил кодом сиблинга: 4 (платформа пишет `paused`) или 5 (graceful stop), стек уничтожался | PARTIAL, воспроизведено на трёх видах первой ошибки | **ПРИНЯТА, исправлена.** У краха свой слот `panicErr`, возвращается ПРЕИМУЩЕСТВЕННО перед `firstErr`; first-wins для обычных ошибок не тронут. Разошёлся со вторым скептиком, советовавшим только печатать стек: упавший процесс не «пауза» — врать в пользу возобновляемости хуже |
| **Обоснование правила остатка ложно**: докстринг ссылался на redrive, а `ResetChunkStages` сносит строку диспозиции ВМЕСТЕ с чекпоинтами в одной транзакции — сценарий недостижим | CONFIRMED / PARTIAL | **ПРИНЯТА:** остаток пере-описан честно (пол для стора, потерявшего вызовы — рестор, легаси), тест пере-написан |
| **Спека-проект `backend/docs/D15.2-*.md:398` всё ещё определяет проекцию как Σ хранимого `cost_usd`** (критик; моя зона, заказ требовал «правда или прочь») | найдено критиком | **ПРИНЯТА:** эррата в теле спеки |
| Три непокрытые мутации (цена по ОТВЕТИВШЕЙ модели · раскрытие непере-прайсенной части в тексте согласия · порядок panic-first при `errors.Join`) | найдено критиком | **ПРИНЯТЫ:** три пина добавлены; последний показан красным (перенос ветки паники ниже сентинелов даёт 2/3/10) |
| Проза: история про exit 2 пересказана в шести блоках; 33-строчная шапка `reprice.go` | ОПРОВЕРГНУТЫ по метрике (плотность файлов сдвинулась на 12 пункта, норма репо такая же) | **ЧАСТИЧНО ПРИНЯТЫ ВОПРЕКИ ОПРОВЕРЖЕНИЮ** — слово владельца о лаконичности выше довода «дом так пишет»: шапка `reprice.go` 33→14 строк. ⚠ **Испр. по раунду 2 (находка F6): моё утверждение «история про exit 2 рассказана ОДИН раз» было НЕВЕРНО** — она изложена на четырёх сайтах (`obs/panic.go`, `waverun.go`, `wavepanic_test.go`, `panic_exit_test.go`) и упомянута ещё на двух в `cmd/tmctl`. Ревьюер рекомендовал НЕ резать: каждый текст говорит про свой шов и все четыре фактически верны; исправлено утверждение, не код |
| `status` жёстко падает при ошибке загрузки репрайсера · `dispatchCommands` не выводится из switch · порядок в `CheckpointUsageForBook` избыточен · `TM_CLASSIFY6_N` мусорный игнорируется · плоская карта вместо вложенной · и ещё 6 | ОПРОВЕРГНУТЫ исполнением | Действий нет. По одной опровергнутой находке действие всё же сделал: `EXPLAIN QUERY PLAN` показал лишнюю temp-B-tree сортировку по колонкам, которых логика не использует → `ORDER BY c.rowid` |
**РАУНД 2 адверсариального ревью — ПРОВЕДЁН по дельте первого (воркфлоу `wf_bb22ee8a-a4b`: 15 агентов, 0 ошибок, 1.22M токенов, 70 мин).** Скоуп узкий и намеренный: свежий, никем не смотренный фикс. Линзы — fable по правилу членства поколения (с прямым указанием, что это ВЫВОД, а не тождество), opus по слоту `panicErr` + **мутационная проверка тестов**. 12 находок → 5 опровергнуто, 7 выжило, +3 от критика. **Вердикт критика: HAND OVER WITH NAMED CAVEATS.**
| Находка | Вердикт | Диспозиция |
|---|---|---|
| **R2-1: обход ломается на ОТКАТЕ конфига.** Правило считает, что вызовы текущего поколения — самые НОВЫЕ в ячейке. Откат промпт-конфига адресуется старым `request_hash`, сеттл становится no-op (`ON CONFLICT DO NOTHING`), строка пере-записывается стоимостью СТАРОГО вызова, а новый остаётся на файле. Воспроизведено сквозным прогоном: тихое ×2 завышение при `HistoricalRows=0` | PARTIAL major. ⚠ Скептик снял главное обвинение: **это НЕ регресс моего фикса** — до-фиксный код даёт то же число в этом потоке; направление «занижение» ново, но показано только синтетически | **ПРИНЯТА.** Добавлен детектор: перебор `accounted > cs.CostUSD` **доказывает**, что новейшие вызовы не принадлежат строке ⇒ откат к сумме счёта с `fromHistory=true`. Тихое ×2 меняется на устаревшую цену, названную вслух. Точное решение требует маркера поколения на `checkpoints` (схема денежной таблицы) — **вопрос на ратификацию, а не третья заплата** |
| **F1 (blocker): направление обхода НЕ ПРИБИТО** — переворот на «от старых» переживал всю зелень: мой фикстур был симметричен (два одинаковых поколения) | PARTIAL | **ПРИНЯТА:** поколения сделаны асимметричными ($0.60 против $0.05); мутация теперь убита |
| **F2: `ORDER BY c.rowid` не прибит** — и `ORDER BY c.attempt`, и полное удаление переживали зелень. Плюс: планировщик показывает, что клауза temp-B-tree не убирает, а ДОБАВЛЯЕТ | PARTIAL major | **ПРИНЯТА:** store-тест с немонотонными `attempt` (0,1,0,1 против порядка вставки). ⚠ Моё обоснование через `EXPLAIN QUERY PLAN` в записи выше было неверно: клауза нужна для КОРРЕКТНОСТИ, а не ради плана |
| **F4: счётчик раскрытия `HistoricalRows` не прибит** — удаление инкремента переживало зелень | PARTIAL minor | **ПРИНЯТА:** тест ведёт флаг из `rp.usd` через реальную `projectRebill` до текста согласия |
| **F8/R2-2: докстринг guard-теста называл формы, которых фикстуры не строят** (ретрай, производные экспорты) — дрейф док-против-кода в паке про честность | PARTIAL | **ПРИНЯТА:** комментарий говорит ровно то, что тест гоняет |
| **C2 (критик): текст согласия звал ЧАСТИЧНО пере-прайсенную единицу целиком исторической** («their usage is no longer on file») | найдено критиком | **ПРИНЯТА:** формулировка исправлена |
| **C1 (критик): `dispatchCommands` прибит не в ту сторону** — выброс команды ИЗ списка оставлял зелень | найдено критиком | **ПРИНЯТА:** AST-тест читает сам switch `run()` и сверяет со списком в обе стороны |
| **C3 (критик): мусорный `TM_CLASSIFY6_N` тихо игнорировался** и проба покупала 5 вызовов — прямо над громким гейтом | найдено критиком | **ПРИНЯТА:** нечисловой/неположительный — громкий отказ до первого вызова |
| F6 (проза), и 5 опровергнутых: $0-строка не пере-прайсится вверх · $0-вызов теряется · при победе паники теряется ошибка сиблинга · `Status` жёстко падает · «крах не может уйти кодом 2» неверно для `fatal error` | ОПРОВЕРГНУТЫ | Кода не трогал. Последнее — честное уточнение: неперехватываемые `fatal error` (конкурентная запись в map, OOM) по-прежнему дают 2; `recover` их не ловит по устройству Go. **Назвать оркестратору как остаточный класс** |
**Мутационная приёмка исполнением.** Четыре мутации, которые у ревьюера ПЕРЕЖИЛИ зелень, теперь убиты: обход от старых → `TestRepricerCountsOnlyTheCurrentGenerationOfCheckpoints`; `ORDER BY c.attempt``TestCheckpointUsageForBookReturnsInsertionOrder`; снятие `HistoricalRows++``TestProjectionCountsRowsItCouldNotRePrice`; снятие детектора перебора → тот же генерационный тест.
**Что критик проверил САМ и подтвердил** (не мои клеймы, его прогоны): батарея EXIT=0 с ровно двумя SKIP; голден сверяется с ФАЙЛОМ `testdata/golden/capture.golden`, не сам с собой, и файл не в diff; wire-нейтральность доказана трижды (голден · оба хеш-несущих файла в дельте изменены ТОЛЬКО в комментариях · под `prompts/`/`configs/`/`langpacks/`/`testdata/` нет ни одного изменения); ноль удалённых имён тестов; оба регресса раунда 1 красные на до-фиксном коде; ни `platform/`, ни `frontend/`, ни контракт 14 не читают `projected_book_usd`/`rebill_usd`; **новое чтение замерено на синтетическом сторе 800 глав / 4800 чекпоинтов / 64 МБ — 3943 мс** против 10-секундного бюджета операции. Мой счёт тестов был на единицу меньше фактического.
**Перепроверка после ревью — командами.** `make battery` → EXIT=0, те же два SKIP. Голден → ok. `go vet -tags live ./...` → чисто. `^func Test` = 823 (было 819), удалённых имён 0. Красное до фикса показано исполнением для ОБОИХ регрессов: `projected $0.007280` против настоящих `$0.003640` (пере-купленная книга) и `wave returned *pipeline.CeilingHalt` / `*errors.errorString: context canceled` вместо паники. Замер стоимости нового чтения: на самом большом реальном проекте (coldrun-a) 149 чекпоинтов, план — два индексных поиска, 2.13 мс.
**Obstacle.** (а) Интервальная самопроверка субагентом в СЕРЕДИНЕ работы не проводилась — ревью запускалось финалом по слову владельца; оба регресса нашлись бы раньше, промежуточный прогон линз стоил бы дешевле. (в) Живой прогон стартовал ~00:58 UTC и захватил границу пикового окна 0104 UTC (правило-рекомендация «не стартовать в пик»): при трате $0.0056 против потолка $0.20 ожидание трёх часов было бы несоразмерно, но факт называю. (г) `git status` показывает чужой ИНДЕКС (полигон: `eval/`, `docs/experiments/`) — не трогал и не прибирал; своё не стейджил.
**Запись оркестратора №18, 17.08 — ПРИЁМКА ПАКА «ЧЕСТНЫЕ ЧИСЛА И СТАТУСЫ»: ПРИНЯТ С ФИКС-ЛИСТОМ, залендено.** Панель 5 линз (слепая по заказу+диффу ДО отчёта · деньги · шов · вне карты · собственные мутации в копии) + пере-раны оркестратора. **Своей рукой пере-проверено исполнением:** `make battery` EXIT=0, SKIP ровно два и оба helper-процессы · голден `-run Golden` ok · `^func Test` = **827**, удалённых имён 0 · **деньги пробы ДВУМЯ независимыми путями — сумма чекпоинтов минус вызов 02.08 и пере-счёт из сырого `usage_json` по запинённой таблице `models.yaml` — оба дали $0.005582424**, до последнего знака · вердикт гейта из сырого артефакта: `runs: 5, runs_at_threshold: 4`, `finish=stop` ×5 ⇒ PASS по ратифицированной форме и ложное падение по прежней. Регрессов против HEAD панель не нашла: три места, где движок врал, действительно перестали врать.
**Фикс-лист ФЧ-1…ФЧ-8** (ни один не блокирует лендинг; все — «заявленное не прибито» либо «текст обещает больше числа»). **ФЧ-1 (HIGH, три неприбитые гарантии, мутации ПЕРЕЖИЛИ зелень):** снятие фильтра по книге в `CheckpointUsageForBook` (`store/ledger.go:371`) не валит ни store-, ни денежные pipeline-тесты — **воспроизведено оркестратором лично**, а это единственная защита от того, чтобы деньги ЧУЖОЙ книги вошли в число согласия · ключ ячейки можно лишить `chunk_idx`, и обход начнёт съедать деньги соседнего чанка (во всех фикстурах глава = один чанк) · ветка пометки «строка не пере-оценена» не ловится на продовом пути. **ФЧ-2 (MED):** два докстринга `reprice.go` описывают НЕ то, что делает код (детекция перебора односторонняя — суффикс вытесненных вызовов с точной суммой проходит молча; «остаток несётся по счёту» — код сперва съедает вызовы прошлых поколений) — в паке про честность это тот же класс, что чинился. **ФЧ-3 (MED):** заказ 187 исполнен наполовину — снятый закон approved-only продолжает утверждаться в докстринге теста и в `backend/docs/D15.2-*.md:317`. **ФЧ-4 (MED):** раскрытие «часть суммы — старые деньги» не доезжает до `status`/`--json` (`HistoricalRows` теряется на границе `status.go`), а второй операторский текст отказа (`--accept-rebill`) цитирует пере-прайсенное число без basis. **ФЧ-5 (MED, деньги — решение владельца):** пере-прайс чинит ось ЦЕНЫ, но не ось МОДЕЛИ — при смене модели стадии (модель входит в снапшот) число считается ценой ОТСТАВЛЕННОЙ модели, замер линзы: $0.003640 против честных $0.036400 при `HistoricalRows=0`, и фраза согласия при этом заявляет «current price table» без оговорки. Не регресс, но обещание §0 промта для этого триггера остаётся живым. **ФЧ-6 (LOW):** `Status()` жёстко падает при ошибке репрайсера — в трёх строках над сознательной политикой «ошибка проекции репортится, но отчёт не валит»; это канал сеттла платформы. **ФЧ-7 (MED, шов):** паника воркера ПОВЕРХ пойманного потолка уничтожает факт потолка на обоих каналах — прогон, остановленный ceiling'ом, платформа запишет `failed`, что PD-113 прямо запрещает (не регресс; до пака терялось иначе). **ФЧ-8 (LOW):** докстринг `TranslateBook` про «каждый выход через `terminal()`» неверен для паники вне волны · комментарий `dispatchCommands` («список теперь один») — usage-строка осталась второй копией · шапка рига ссылается на заменённую ратификацию.
**Подтверждено панелью как ВЕРНОЕ (клеймы сессии, пере-проверенные независимо):** словарь кодов побайтно не двинут, добавлен ровно один `return 1`; «паника → 1» читается платформой как `failed` во всех пяти точках потребления (инвентарь: `ingest/exit.go` · `ingest/supervisor.go` · `runner/engine.go` · `runner/marker.go` · `runs/reconcile.go` · `books/parse.go`), тогда как ДО пака упавший посреди книги прогон записывался `ready` · остаточный класс подтверждён: непойманные `fatal error` (конкурентная запись в map, OOM) по-прежнему дают 2, `recover` их не ловит · wire-нейтральность: оба хеш-несущих файла изменены ТОЛЬКО комментариями · строка 164 после пака по-прежнему верна · клейм сессии про банковые роли верен И это преexisting — заведена строка **194**.
**Что НЕ проверено:** мутации ФЧ-1 (2) и (3) приняты со слов мутационной линзы — лично воспроизведён только фильтр книги · старый вызов 02.08 в леджере пробы не сошёлся ни с одной из двух ценовых таблиц (вне скоупа пробы, не разбирался) · перф нового чтения на боевой книге — замер только синтетический и со слов сессии.
*(Пинги закрытых паков — фикс-пак банка D39.118, блокеры контракта D39.122 — в [archive/PROGRESS-2026-08-04-09.md](archive/PROGRESS-2026-08-04-09.md), D39.125.)*
*(Закрытые бэкенд-записи 0102.08 — общность фаза 2 · качество банка (оба этапа) · фикс-пак банка · finding-1 · пакет-чекеров · мелкая пачка · coldrun-b · ручка эффорта — в [archive/PROGRESS-2026-08-01-02.md](archive/PROGRESS-2026-08-01-02.md).)*

View file

@ -1,4 +1,4 @@
# Реестр D-нот — карта актуальности v2 (D1D39.148; строка 167; титул — носитель головы, бампать при каждом аппенде — испр. оркестратором №17 15.08 по аудиту: отставал на четыре ноты)
# Реестр D-нот — карта актуальности v2 (D1D39.149; строка 167; титул — носитель головы, бампать при каждом аппенде — испр. оркестратором №17 15.08 по аудиту: отставал на четыре ноты)
> Одна строка на КАЖДУЮ ноту журнала решений: № · дата · суть · статус · где тело · темы. Ведёт оркестратор при лендингах: новая нота = новая строка ТЕМ ЖЕ коммитом (полноту сторожит `docs/scripts/counts.py --check`). Суть и статус здесь — НАВИГАЦИЯ, не контракт: при конфликте побеждает ТЕЛО ноты (живой [`05-decisions-log.md`](05-decisions-log.md) → слайсы `../archive/architecture/05-decisions-*.md`). Несущие эрраты к фактам старых нот живут в шапке живого D-лога — их читать ОБЯЗАТЕЛЬНО, реестр их не дублирует. Темы (для грепа оси «какой закон по X»): деньги · шов · банк · промпт · судья · гейты · контракт · платформа · фронт · полигон · ToS · общность · процесс · нарезка · голос · инфра · модели.
> Родословная: прозаическая карта D1D39.28 (ревизии D31D38.2) пересобрана в реестр докс-паком 167 (09.08.2026, №16): извлечение 6+6 агентов по worksheet `../archive/reports/DOC_AUDIT_INVENTORY_2026-08-09.md`, полнота сверена скриптом против заголовков всех шести файлов. «тело: жив» = живой D-лог; «слайс N» = `../archive/architecture/05-decisions-N.md`.
@ -201,3 +201,4 @@
| D39.146 | 16.08 | Закрытие сессии №17, передача №18: лента D39.134145 (приёмки migrate/S4/DeepSeek, контракт-ревью+батч+дофикс — канон 0.3.0 финален, модель подписи один ОК, аудит бэклога потерь 0, реструктуризация роли); очередь №18 — приёмка запущенного P7 → S5+разморозка фронта, лендинг петель фазы Д, вахта весов с порогом 116, свободные 160/181; CURRENT-STATE ужат, доки проверены двумя аудиторами готовности | ЖИВОЕ: очередь — CURRENT-STATE | жив | хендофф оркестратор |
| D39.147 | 17.08 | Слово владельца напрямую №18: фронт морозится ДАЛЬШЕ лендинга P7 (условие разморозки D39.136 п.2 амендировано — P7 зону не размораживает, S5 не выдаётся, разморозка отдельным словом), курс — движок и платформа до «работает и отдаёт результат», моки фронта при разморозке уступают дев-стенду платформы; инвентарь трёх аудитов кодом: ассемблера книги нет нигде и export всегда exit 0 (строка 49), дыры выдачи молчат в тексте (новая 193), боевой конфиг без банкового контура = 140 внутри wire-батча 182, беспагинационные чтения стора под 10s (дописка 54), платформа после P7 читаема но без экспорта и эскроу (136/137/П-18), блокирующих строк движка 18 из 103 и два тяжелейших не про код (54, 16) | ЖИВОЕ: курс и условие разморозки | жив | процесс фронт платформа владелец |
| D39.148 | 17.08 | Ревизия D39.126: `--lint` якорей вплетён в pre-commit хук доков (warn-only, `--from-index`), триггер линта — любой док или CLAUDE.md, `--check` остаётся на D-логе/PROGRESS; дизайн против ложных тревог — сканируемые доки по содержимому коммита, цели якорей из дерева, состав коммита одним вызовом; верификация реальными тест-коммитами поймала два дефекта (падение на отсутствующем файле, пустое тело предупреждения); метод-урок: хук судится тест-коммитом, а не ручным прогоном — git отдаёт хуку временный индекс через GIT_INDEX_FILE | ЖИВОЕ: действующая форма гейта доков | жив | процесс инфра доки |
| D39.149 | 17.08 | Приёмка пака «честные числа и статусы» (панель 5 линз + пере-раны оркестратора): ПРИНЯТ С ФИКС-ЛИСТОМ ФЧ-1…ФЧ-8, регрессов нет; ратифицированы паника→exit 1 (PD-212 со стороны движка закрыт, полосы не двинуты), PanicError без Unwrap, пере-прайс фактическими токенами через шов сеттла с починкой обеих половин гейта, выборочный порог в риге (4/5 PASS); деньги пробы $0.005582424 сошлись двумя независимыми путями; строки 176/181/187 закрыты, 172-г запрещено закрывать этим прогоном, новые 194 (деньги банковых ролей вне согласия) · 195 (маркер поколения — схема, на владельце) · 196 (fatal error residual); владельцу — ось МОДЕЛИ в числе согласия | ЖИВОЕ: фикс-лист ФЧ и два решения владельца | жив | деньги приёмка шов бэкенд |

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.148 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
# Журнал решений оркестратора — контракт D1D39.149 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
@ -483,3 +483,15 @@
**4. Метод-урок (записан, потому что куплен ошибкой оркестратора №18 в тот же день).** Хук нельзя судить, запуская его руками в шелле: при частичном коммите (`git commit -- <пути>`, наша каноническая форма) git строит ВРЕМЕННЫЙ индекс и отдаёт его хуку через `GIT_INDEX_FILE`, поэтому `git diff --cached` и `git show :файл` ВНУТРИ хука видят состав коммита, а ручной прогон — настоящий индекс с чужим стейджем. №18 на этом основании объявил хук мёртвым для наших коммитов; диагноз опровергнут экспериментом параллельного ревьюера и пере-воспроизведён №18. ⚠ Опровергающая улика лежала в его же наблюдении (фронтовый фрагмент того же диспетчера отрабатывал на pathspec-коммитах) и была объяснена, а не проверена — «связность вместо истинности» из дисциплины ревьюера. Проверка хука = тест-коммит, не вызов скрипта.
**5. Носители:** докстринг `docs/scripts/counts.py` (строка «--lint руками» переписана), шапка `docs/scripts/githooks/pre-commit`, `docs/README.md`. (17.08.2026, оркестратор №18) ✅
## D39.149 — ПРИЁМКА БЭКЕНД-ПАКА «ЧЕСТНЫЕ ЧИСЛА И СТАТУСЫ» ПРИНЯТА С ФИКС-ЛИСТОМ И ЗАЛЕНДЕНА: строки 176/181/187 закрыты, 172-г НЕ закрыта, новые 194196 (17.08). ✅
**1. Приёмка исполнением (панель 5 линз в изолированных копиях + пере-раны оркестратора; $0, только агент-токены).** Линзы: слепая (заказ+дифф ДО отчёта) · деньги · шов · вне карты · собственные мутации в копии дерева. Все пять — ACCEPT_WITH_FIXES, ни одного REJECT, регрессов против HEAD нет. **Пере-проверено МОЕЙ рукой:** батарея EXIT=0 с ровно двумя SKIP (оба helper-процессы) · голден бит-в-бит · `^func Test` = 827, удалённых имён 0 (числа отчёта 819/823 — промежуточные снимки, поправлено на месте) · **деньги живой пробы ДВУМЯ независимыми путями** (сумма чекпоинтов минус вызов 02.08 · пере-счёт из сырого `usage_json` по запинённой таблице `models.yaml`) — оба дали **$0.005582424**, знак в знак с заявленным · вердикт гейта из СЫРОГО артефакта: `runs: 5, runs_at_threshold: 4`, `finish=stop` ×5. Механика, фикс-лист ФЧ-1…ФЧ-8 и «что не проверено» — запись приёмки в PROGRESS «Бэкенд» (состав там).
**2. Что ратифицируется этой нотой.** (а) **Паника любой горутины движка → exit 1** принято: номера полос не двинуты (добавлен ровно один `return 1`), платформа читает 1 как `failed` во всех ПЯТИ точках потребления, тогда как ДО пака упавший посреди книги прогон записывался `ready` — это закрытие PD-212 со стороны движка. (б) **`obs.PanicError` намеренно без `Unwrap`** — ратифицировано: иначе паника значением `context.Canceled`/`*CeilingHalt` уехала бы в 5/4 при формально нетронутом словаре. (в) **Пере-прайс проекции идёт ФАКТИЧЕСКИМИ токенами чекпоинтов через шов сеттла**, а не оценкой; обе половины гейта (числитель и база 5%-порога) починены вместе — ратифицировано как единственная форма, при которой порог не сравнивает две валюты. (г) **Выборочный порог D39.136 п.6б внесён в риг**; прогон дал 4/5 — гейт ПРОЙДЕН, и это ровно тот случай, на котором прежняя форма «6/6 на каждом» упала бы ложно.
**3. Строки.** **176 ЗАКРЫТА** для паник (остаточный класс `fatal error` — новая **196**). **181 ЗАКРЫТА** (число согласия и `projected_book_usd` считаются текущей таблицей; ось МОДЕЛИ — открытый хвост ФЧ-5). **187 ЗАКРЫТА** в коде (тот же снятый закон в докстринге теста и `D15.2:317` — ФЧ-3). **172-г НЕ ЗАКРЫТА и закрывать по этому прогону запрещено** (риг меряет классификатор на flash, сменившиеся веса — у pro-редактора; дописка в теле строки). Новые: **194** деньги банковых ролей вне контура согласия (преexisting, подтверждён двумя линзами) · **195** маркер поколения на `checkpoints` — схемное решение владельца, а не третья заплата поверх ВЫВОДА членства · **196** остаточный класс `fatal error`.
**4. Владельцу (не решается этой нотой).** (а) **Ось МОДЕЛИ в числе согласия (ФЧ-5):** при смене модели стадии проекция считает ценой отставленной модели — замер линзы $0.003640 против честных $0.036400, и текст согласия при этом заявляет «current price table» без оговорки. Развилка: прайсить по модели, которую стадия резолвит СЕЙЧАС, либо оставить и назвать вслух в тексте согласия. Это деньги ⇒ слово владельца. (б) **Строка 195** — заводить ли `snapshot_id`/`run_id` на денежной таблице.
**5. Метод-заметка приёмки.** Три HIGH фикс-листа — не дефекты поведения, а НЕПРИБИТЫЕ гарантии: мутации переживали зелёную батарею. Самую тяжёлую (снятие фильтра по книге в `CheckpointUsageForBook`) оркестратор воспроизвёл лично — ни store-, ни денежные pipeline-тесты не краснеют, то есть единственная защита от денег ЧУЖОЙ книги в числе согласия держится не тестом. Сессия сама провела два раунда адверсариального ревью (38 агентов) и нашла в своей работе два настоящих регресса при зелёной самопроверке — это правильное поведение, и оно же показывает, что финальная панель приёмки не заменяется самопроверкой. (17.08.2026, оркестратор №18) ✅