Land the backend pack so the memory bank reaches every provider, a hole in the shipped text is visible to its reader, and a repeated decision document converges

This commit is contained in:
heaven 2026-08-28 11:41:43 +03:00
parent 58bae309c0
commit 7d0c6f2f54
29 changed files with 2049 additions and 58 deletions

View file

@ -33,9 +33,24 @@ import (
var (
tmctlOnce sync.Once
tmctlPath string
tmctlDir string
tmctlErr error
)
// TestMain removes the directory buildTmctl compiles into. Without it every `go test ./cmd/tmctl/`
// left a 20 MB binary in the system temp dir forever: t.TempDir cannot serve here (the binary is
// shared by the whole package, outliving any one test), and the sync.Once has no teardown of its own.
// Measured, not theorised — 249 abandoned `tmctl-bin*` directories, ~5 GB, filled the machine's /tmp
// during this pack and turned the -race battery into "no space left on device", which reads exactly
// like a broken build.
func TestMain(m *testing.M) {
code := m.Run()
if tmctlDir != "" {
_ = os.RemoveAll(tmctlDir)
}
os.Exit(code)
}
// buildTmctl compiles the CLI once for the whole package.
func buildTmctl(t *testing.T) string {
t.Helper()
@ -45,6 +60,7 @@ func buildTmctl(t *testing.T) string {
tmctlErr = err
return
}
tmctlDir = dir // TestMain removes it; see the note there
tmctlPath = filepath.Join(dir, "tmctl")
out, err := exec.Command("go", "build", "-o", tmctlPath, ".").CombinedOutput()
if err != nil {

View file

@ -0,0 +1,382 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/backend/internal/pipeline"
)
// exportgap_cli_test.go is the §3.2 proof, and it is deliberately end-to-end through the REAL
// binary: the claim under test is about the bytes a human reads out of `tmctl export`, and only
// the binary produces those bytes. A unit-level assertion on a struct would prove the projection
// and leave the shipped text unexamined — which is exactly where the defect lived.
//
// THE DEFECT: a c-lite member drop ships the editor's text over the unit's CLEAN members and
// leaves the flagged member's text out entirely. The reader got a seamless concatenation with a
// chunk-sized hole in it, under a banner that said «leak cleaned, verify» — the wording for a
// COSMETIC sanitizer strip, which had not happened. So the marker was not missing; it was wrong,
// and a wrong marker is worse than none, because it answers the reader's question falsely.
// gapProvider drives one unit into the c-lite state: the member paragraph carrying echoMarker
// comes back as untranslated CJK (classify → cjk_artifact → the member's draft flags and is
// dropped from the edit), the other translates, and the editor returns its own text.
func gapProvider(t *testing.T, echoMarker string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
text := "ЧЕРНОВИК ПЕРЕВОДА"
switch {
case strings.Contains(string(body), "Черновик перевода для редактуры"):
text = "Отредактированный текст уцелевшей части."
case strings.Contains(string(body), echoMarker):
text = "这是完全没有翻译的中文内容。"
}
tb, _ := json.Marshal(text)
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
"usage":{"prompt_tokens":100,"completion_tokens":50}}`, tb)
}))
t.Cleanup(srv.Close)
return srv
}
// setupGapProject is setupCLIProject plus an EDIT stage (units need members to lose one) and a
// two-paragraph source, the second of which the provider echoes.
func setupGapProject(t *testing.T, providerURL, echoMarker string) string {
t.Helper()
dir := t.TempDir()
writeCLIFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeCLIFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
writeCLIFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
context: { glossary_injection: selective, glossary_token_budget: 800 }
stages:
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt_override: prompts/editor.md, prompt_version: v-cli, temperature: 0.4, reasoning: "off" }
`)
writeCLIFile(t, filepath.Join(dir, "source.txt"),
strings.Repeat("文", 1400)+"。\n\n"+strings.Repeat(echoMarker, 1100)+"。")
writeCLIFile(t, filepath.Join(dir, "book.yaml"), `
book_id: gap-book
title: Тест
source_lang: zh
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: palladius
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
return filepath.Join(dir, "book.yaml")
}
// setupStrippedDraftOnlyProject builds a DRAFT-ONLY book with the output sanitizer on, whose single
// chunk comes back with a leading markdown header. The strip removes the «### » artifact and ships the
// whole prose: the chunk is flagged sanitizer_stripped and loses NO reader-visible text.
func setupStrippedDraftOnlyProject(t *testing.T, providerURL string) string {
t.Helper()
cfg := setupCLIProject(t, providerURL)
writeCLIFile(t, filepath.Join(filepath.Dir(cfg), "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
context: { glossary_injection: selective, glossary_token_budget: 800 }
stages:
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" }
gates:
sanitizer:
enabled: true
`)
return cfg
}
// TestADraftOnlyStrippedChunkIsNotCalledIncomplete is the ANTI-SCOPE guard, and it is here because an
// adversarial pass found the change failing it: a draft-only pipeline makes every chunk a SINGLETON
// unit whose own draft row is its final row, so the member-drop rule counted the chunk's own flag as a
// lost member. The reader was then told a fragment was missing from a chunk whose text is entirely
// present — a marker that misinforms, which the order forbids as explicitly as the silence it replaces.
func TestADraftOnlyStrippedChunkIsNotCalledIncomplete(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
tb, _ := json.Marshal("### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.")
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
"usage":{"prompt_tokens":100,"completion_tokens":50}}`, tb)
}))
t.Cleanup(srv.Close)
bookPath := setupStrippedDraftOnlyProject(t, srv.URL)
report := runTmctl(t, bookPath, "translate")
shipped := runTmctl(t, bookPath, "export", "--plaintext")
t.Logf("TRANSLATE:\n%s\nEXPORT:\n%s", report, shipped)
var doc pipeline.BookExport
if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "export")), &doc); err != nil {
t.Fatal(err)
}
if len(doc.Chunks) != 1 || doc.Chunks[0].FlagReason != string(pipeline.FlagSanitizerStripped) {
t.Fatalf("fixture must produce ONE cosmetically stripped chunk, got %+v", doc.Chunks)
}
if doc.Chunks[0].DroppedMembers != 0 {
t.Fatalf("a draft-only unit has no members to drop, got dropped_members=%d", doc.Chunks[0].DroppedMembers)
}
// The whole prose is there — nothing was lost, so nothing may claim it was.
if !strings.Contains(shipped, "Судзуки открыл седьмую дверь и замер на пороге.") {
t.Fatalf("the stripped chunk must still ship its prose:\n%s", shipped)
}
for _, out := range []string{shipped, report} {
if strings.Contains(out, "TEXT MISSING") || strings.Contains(out, "INCOMPLETE") {
t.Fatalf("nothing was lost; the gap marker must not fire:\n%s", out)
}
}
// And the TRUE banner is restored, not shadowed by the incomplete branch.
if !strings.Contains(shipped, "(leak cleaned, verify)") {
t.Fatalf("a real cosmetic strip must keep its own banner:\n%s", shipped)
}
if !strings.Contains(shipped, "(of them incomplete=0)") {
t.Fatalf("the summary must not count it as incomplete:\n%s", shipped)
}
}
// runTmctl runs the real binary and returns its stdout, tolerating the documented non-error exit
// codes (2 = completed with flags — which is precisely the run this test needs).
func runTmctl(t *testing.T, bookPath string, args ...string) string {
t.Helper()
cmd := exec.Command(buildTmctl(t), append(args, "--config", bookPath)...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
if code := exitCodeOf(t, err); code != 0 && code != 2 {
t.Fatalf("tmctl %v exited %d\nstdout:\n%s\nstderr:\n%s", args, code, out.String(), errb.String())
}
return out.String()
}
// TestExportPlaintextMarksTheGapInTheShippedText is the deliverable: on a real c-lite run, the
// text a human reads out of `tmctl export --plaintext` says a piece is missing, and does NOT
// claim a cosmetic clean-up that never happened.
func TestExportPlaintextMarksTheGapInTheShippedText(t *testing.T) {
const echoMarker = "禁"
srv := gapProvider(t, echoMarker)
bookPath := setupGapProject(t, srv.URL, echoMarker)
runTmctl(t, bookPath, "translate")
shipped := runTmctl(t, bookPath, "export", "--plaintext")
t.Logf("SHIPPED TEXT:\n%s", shipped)
// The state really is the one under test: an ok edit over the clean member, with a member lost.
var doc pipeline.BookExport
if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "export")), &doc); err != nil {
t.Fatalf("export --json must round-trip: %v", err)
}
if len(doc.Chunks) != 1 {
t.Fatalf("fixture must produce ONE unit, got %d — the c-lite state was not reached", len(doc.Chunks))
}
u := doc.Chunks[0]
if u.Disposition != string(pipeline.DispFlagged) || u.FinalText == "" || u.DroppedMembers != 1 {
t.Fatalf("fixture must produce a flagged unit that SHIPS text with 1 dropped member, got %+v", u)
}
// 1. The reader is told, in the text stream, that a piece is missing.
if !strings.Contains(shipped, "TEXT MISSING") {
t.Fatalf("the shipped text does not tell the reader a fragment is missing:\n%s", shipped)
}
if !strings.Contains(shipped, "1 source fragment of this unit could not be translated") {
t.Fatalf("the marker must say HOW MUCH is missing:\n%s", shipped)
}
// 2. And it is NOT told the false thing it used to be told.
if strings.Contains(shipped, "leak cleaned") {
t.Fatalf("a member drop must not be reported as a cosmetic sanitizer clean-up:\n%s", shipped)
}
// 3. The marker sits with the prose, above the text it qualifies — not only in a header far
// above, which a reader scrolling a concatenation passes once and never sees again.
iMark, iText := strings.Index(shipped, "TEXT MISSING"), strings.Index(shipped, "Отредактированный текст")
if iMark < 0 || iText < 0 || iMark > iText {
t.Fatalf("the marker must precede the incomplete text (marker@%d, text@%d):\n%s", iMark, iText, shipped)
}
// 4. The surviving text still ships in full — the marker informs, it does not withhold.
if !strings.Contains(shipped, "Отредактированный текст уцелевшей части.") {
t.Fatalf("the paid, clean remainder must still ship:\n%s", shipped)
}
// 5. The summary counts it as incomplete rather than folding it into `exported`.
if !strings.Contains(shipped, "(of them incomplete=1)") {
t.Fatalf("the export summary must count the incomplete unit:\n%s", shipped)
}
}
// TestTranslateMarksTheGapInTheShippedText: the same run's own report is the OTHER surface a human
// reads, and it carried the same false wording. One state, both renderers.
func TestTranslateMarksTheGapInTheShippedText(t *testing.T) {
const echoMarker = "禁"
srv := gapProvider(t, echoMarker)
bookPath := setupGapProject(t, srv.URL, echoMarker)
report := runTmctl(t, bookPath, "translate")
t.Logf("TRANSLATE REPORT:\n%s", report)
if !strings.Contains(report, "TEXT MISSING") {
t.Fatalf("translate must tell the reader a fragment is missing:\n%s", report)
}
if strings.Contains(report, "leak cleaned") {
t.Fatalf("translate must not report a member drop as a cosmetic clean-up:\n%s", report)
}
if !strings.Contains(report, "Отредактированный текст уцелевшей части.") {
t.Fatalf("the clean remainder must still be printed:\n%s", report)
}
}
// TestExportPlaintextStateMatrix pins all four unit states on ONE document, so the branches cannot
// drift apart: only the member-drop unit is called incomplete, only a real sanitizer strip is
// called a cleaned leak, a withheld unit is neither, and the header counts each honestly.
func TestExportPlaintextStateMatrix(t *testing.T) {
doc := &pipeline.BookExport{BookID: "b", TotalUnits: 8, PendingUnits: 1, Chunks: []pipeline.ChunkExport{
{Chapter: 1, ChunkIdx: 0, Disposition: "ok", FinalText: "Чистый текст."},
{Chapter: 2, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped", FinalText: "Очищенный текст."},
{Chapter: 3, ChunkIdx: 0, Disposition: "flagged", FlagReason: "cjk_artifact", FinalText: "Уцелевшая часть.", DroppedMembers: 2, DroppedReason: "cjk_artifact"},
{Chapter: 4, ChunkIdx: 0, Disposition: "flagged", FlagReason: "glossary_miss", FinalText: ""},
{Chapter: 5, ChunkIdx: 0, Disposition: "pending"},
// EVERY member dropped: the unit ships NOTHING. It carries a drop count all the same, and the
// marker must NOT fire — «a fragment is missing from the text below» over an empty body points
// at text that does not exist. This case is why both call sites guard on a non-empty text.
{Chapter: 6, ChunkIdx: 0, Disposition: "flagged", FlagReason: "cjk_artifact", FinalText: "", DroppedMembers: 3, DroppedReason: "cjk_artifact"},
// A flagged unit with the sanitizer's reason but NO text. The engine does not produce one today
// (classifyOutput hands back a non-empty strip or a different reason), but the branch order must
// not be the thing standing between that and a banner announcing a clean-up of nothing.
{Chapter: 7, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped", FinalText: ""},
// THE INTERSECTION, and the one an adversarial pass caught: the EDIT flagged on its own account
// (a cosmetic strip) AND a member dropped. The unit's FlagReason is the strip's; the cause of
// the HOLE is the member's. Printing the unit's reason as the cause of the loss would tell the
// reader a clean-up ate a chunk of the book — the same false claim, moved into the prose.
{Chapter: 8, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped",
FinalText: "Почищенная уцелевшая часть.", DroppedMembers: 1, DroppedReason: "untranslated_echo"},
}}
var b bytes.Buffer
if err := renderExport(&b, doc, true); err != nil {
t.Fatal(err)
}
out := b.String()
for _, want := range []string{
// exported counts only units that actually shipped text: 8 total 1 pending 3 withheld = 4,
// of which 2 are incomplete (a SUBSET of exported, not a fourth part of the total).
"# export b — total units=8, exported=4 (of them incomplete=2), withheld=3, pending=1",
"=== CHAPTER 1 CHUNK 0 ===\nЧистый текст.",
// A REAL cosmetic strip keeps the wording that is true of it, and gains no gap marker.
"=== CHAPTER 2 CHUNK 0 — flagged (sanitizer_stripped) (leak cleaned, verify) ===\nОчищенный текст.",
// The member drop is named, counted and marked in the text stream — plural form included.
"=== CHAPTER 3 CHUNK 0 — flagged (cjk_artifact) (INCOMPLETE) ===\n" +
"[⚠ TEXT MISSING — 2 source fragments of this unit could not be translated and are NOT in the text below: cjk_artifact]\n" +
"Уцелевшая часть.",
"=== CHAPTER 4 CHUNK 0 — flagged (glossary_miss) (not translated, flagged for a human) ===",
"=== CHAPTER 6 CHUNK 0 — flagged (cjk_artifact) (not translated, flagged for a human) ===",
"=== CHAPTER 7 CHUNK 0 — flagged (sanitizer_stripped) (not translated, flagged for a human) ===",
// The banner keeps the unit's verdict; the MARKER names the hole's own cause, not the strip's.
"=== CHAPTER 8 CHUNK 0 — flagged (sanitizer_stripped) (INCOMPLETE) ===\n" +
"[⚠ TEXT MISSING — 1 source fragment of this unit could not be translated and is NOT in the text below: untranslated_echo]\n" +
"Почищенная уцелевшая часть.",
"=== CHAPTER 5 CHUNK 0 — pending (not yet translated) ===",
} {
if !strings.Contains(out, want) {
t.Fatalf("plaintext export must contain\n%q\ngot:\n%s", want, out)
}
}
// ANTI-SCOPE: a unit that lost nothing must never carry the marker — a marker on complete text
// misinforms the reader exactly as the old wording did, in the other direction.
head := out[:strings.Index(out, "=== CHAPTER 3")]
if strings.Contains(head, "TEXT MISSING") {
t.Fatalf("the gap marker leaked onto a unit that lost nothing:\n%s", head)
}
if n := strings.Count(out, "TEXT MISSING"); n != 2 {
t.Fatalf("exactly two units ship INCOMPLETE text, got %d markers:\n%s", n, out)
}
// The strip's reason must never be printed as the CAUSE of a hole.
if strings.Contains(out, "could not be translated and is NOT in the text below: sanitizer_stripped") {
t.Fatalf("the marker named the unit's own flag reason as the cause of the loss:\n%s", out)
}
// The wholly-withheld unit (ch.6: every member dropped, nothing shipped) must not have been dressed
// as merely incomplete — scoped to ITS block, since ch.8 legitimately carries both.
ch6 := out[strings.Index(out, "=== CHAPTER 6"):strings.Index(out, "=== CHAPTER 7")]
if strings.Contains(ch6, "TEXT MISSING") || strings.Contains(ch6, "INCOMPLETE") {
t.Fatalf("a unit that ships NOTHING must not claim a fragment is missing below it:\n%s", ch6)
}
}
// TestTranslateStateMatrix is renderExport's matrix for the OTHER human surface. Its twin existed and
// this one did not, so an adversarial pass could revert renderTranslate's two guards — the ones the
// code itself calls LOAD-BEARING — and watch the whole cmd/tmctl suite stay green. A guard nothing
// pins is a comment.
func TestTranslateStateMatrix(t *testing.T) {
res := &pipeline.BookResult{BookID: "b", Flagged: 5, Chunks: []pipeline.ChunkOutcome{
{Chapter: 1, Disposition: pipeline.DispOK, FinalText: "Чистый текст."},
{Chapter: 2, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped, FinalText: "Очищенный текст."},
{Chapter: 3, Disposition: pipeline.DispFlagged, FlagReason: "cjk_artifact", FinalText: "Уцелевшая часть.",
DroppedMembers: 2, DroppedReason: "cjk_artifact"},
// Every member dropped: nothing ships. The marker must not point at text that is not there.
{Chapter: 4, Disposition: pipeline.DispFlagged, FlagReason: "cjk_artifact", FinalText: "",
DroppedMembers: 3, DroppedReason: "cjk_artifact"},
// The sanitizer's reason with NO text: the branch order must not print a clean-up of nothing.
{Chapter: 5, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped, FinalText: ""},
// The intersection: the EDIT flagged for its own reason AND a member dropped. The marker names
// the hole's cause, never the strip's.
{Chapter: 6, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped,
FinalText: "Почищенная уцелевшая часть.", DroppedMembers: 1, DroppedReason: "untranslated_echo"},
}}
var b bytes.Buffer
err := renderTranslate(&b, res, func() (float64, float64, error) { return 0, 0, nil })
var flagged *pipeline.CompletedWithFlags
if !errors.As(err, &flagged) {
t.Fatalf("a flagged run must return the exit-2 sentinel, got %v", err)
}
out := b.String()
for _, want := range []string{
"=== CHAPTER 1 CHUNK 0 — ok ===\nЧистый текст.",
"[FLAG sanitizer_stripped — leak cleaned, exported cleaned, verify] ↓\nОчищенный текст.",
"[FLAG cjk_artifact] ↓\n[⚠ TEXT MISSING — 2 source fragments of this unit could not be translated and are NOT in the text below: cjk_artifact]\nУцелевшая часть.",
"=== CHAPTER 4 CHUNK 0 — flagged(cjk_artifact) ===\n[FLAG cjk_artifact] chunk not translated",
"=== CHAPTER 5 CHUNK 0 — flagged(sanitizer_stripped) ===\n[FLAG sanitizer_stripped] chunk not translated",
"[⚠ TEXT MISSING — 1 source fragment of this unit could not be translated and is NOT in the text below: untranslated_echo]\nПочищенная уцелевшая часть.",
} {
if !strings.Contains(out, want) {
t.Fatalf("translate must contain\n%q\ngot:\n%s", want, out)
}
}
if n := strings.Count(out, "TEXT MISSING"); n != 2 {
t.Fatalf("exactly two units ship INCOMPLETE text, got %d markers:\n%s", n, out)
}
if strings.Contains(out, "could not be translated and is NOT in the text below: sanitizer_stripped") {
t.Fatalf("the marker named the unit's own flag reason as the cause of the loss:\n%s", out)
}
}

View file

@ -23,6 +23,49 @@ import (
// The ledger callbacks preserve the EXACT order "print → read SpentUSD → print"
// of the original code: hoisting the read above the render would change the partial output on error.
// gapMarker is the IN-TEXT marker for a unit that ships text with a piece MISSING — the c-lite
// member drop, where the editor edited the unit's clean members and left a flagged member's text
// out entirely. Without it the reader gets a seamless concatenation with a chunk-sized hole in it
// and no way to know: the hole has no seam, because the editor rewrote the remainder around it.
//
// WHY IT LIVES IN THE RENDERER and not in the export projection, where the deterministic chapter
// title is applied. The title is part of the BOOK and must reach the platform; this marker is
// metadata ABOUT the text, and the wire decides `translated` vs `withheld` by asking whether the
// unit's text is EMPTY (runevents UnitDone.Shipped ← oc.FinalText != ""). A marker written into
// that text would turn every withheld unit into a translated one and break the very distinction
// the contract promises. So it is applied one layer further out, where nothing but a human reads.
//
// WHY IT IS NOT TARGET-LANGUAGE TEXT. Every banner on this surface is English operator vocabulary
// («leak cleaned, verify», «not translated, flagged for a human»), and this surface is an AUDIT
// concatenation — it interleaves per-unit banners with prose, so it is read by the operator, not
// sold to a reader. Keeping the marker in that same vocabulary makes it pair-independent by
// construction: a target language with no langpack at all gets byte-identical output, and there is
// no per-pair string to forget. A target-language marker would belong to the reader-facing artifact
// the PLATFORM builds, and that is not this file.
//
// ⚠ THE GUARD AT BOTH CALL SITES (FinalText != "") IS LOAD-BEARING. Members are counted whenever they
// drop, INCLUDING when every member of the unit drops — and then the unit ships nothing at all. Saying
// «a fragment is missing from the text below» over an empty body would be a second false statement,
// pointing at text that does not exist; the honest banner there is the one that already existed,
// «not translated, flagged for a human». The marker is for a unit that ships SOME of its text.
// ⚠ THE REASON IS THE DROP'S OWN, never the unit's FlagReason. A unit whose edit flagged for a
// cosmetic sanitizer strip AND also lost a member carries the STRIP as its FlagReason; printing that
// as the cause of the hole tells the reader a clean-up ate a chunk of the book — the same false claim
// the banner used to make, moved into the prose. The banner still shows the unit's verdict; the marker
// shows the hole. They are different facts and are now carried by different fields.
func gapMarker(dropped int, reason string) string {
frag, verb := "fragment", "is"
if dropped != 1 {
frag, verb = "fragments", "are"
}
m := fmt.Sprintf("[⚠ TEXT MISSING — %d source %s of this unit could not be translated and %s NOT in the text below",
dropped, frag, verb)
if reason != "" {
m += ": " + reason
}
return m + "]"
}
// renderTranslate prints the per-chunk translation report and returns the
// CompletedWithFlags sentinel when chunks were flagged (exit 2).
func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
@ -31,12 +74,26 @@ func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (commi
switch {
case ch.Disposition == pipeline.DispOK:
fmt.Fprintln(w, ch.FinalText)
case ch.FinalText != "":
case ch.DroppedMembers > 0 && ch.FinalText != "":
// c-lite member drop: the editor shipped the CLEAN members and left a flagged member's
// text out. The text below is real and paid for, but it is INCOMPLETE, and saying
// «leak cleaned» here — which this branch used to do for every flagged-with-text unit,
// whatever the reason — told the reader the opposite of what happened.
fmt.Fprintf(w, "[FLAG %s] ↓\n", ch.FlagReason)
fmt.Fprintln(w, gapMarker(ch.DroppedMembers, string(ch.DroppedReason)))
fmt.Fprintln(w, ch.FinalText)
case ch.FlagReason == pipeline.FlagSanitizerStripped && ch.FinalText != "":
// Cosmetic sanitizer strip (D35.4a): the leak was removed and the remainder exported,
// but the chunk stays flagged for a human to verify the auto-clean — not lost to an
// empty placeholder (ch5/ch20 chapter openers used to drop whole for a leading «###»).
fmt.Fprintf(w, "[FLAG %s — leak cleaned, exported cleaned, verify] ↓\n", ch.FlagReason)
fmt.Fprintln(w, ch.FinalText)
case ch.FinalText != "":
// Flagged, with text, and neither of the two known causes. The engine produces no such
// unit today; printing a neutral banner keeps an unforeseen one from inheriting either
// of the specific claims above.
fmt.Fprintf(w, "[FLAG %s — verify] ↓\n", ch.FlagReason)
fmt.Fprintln(w, ch.FinalText)
default:
fmt.Fprintf(w, "[FLAG %s] chunk not translated — draft/edit unusable, flagged for a human\n", ch.FlagReason)
}
@ -479,8 +536,25 @@ func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error
if asPlaintext {
// Manifest/drift summary first (F3/F4): a partial or drifted book is EXPLICIT, not silently
// exported as complete.
fmt.Fprintf(w, "# export %s — total units=%d, exported=%d, pending=%d",
exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits, exp.PendingUnits)
// The header counts WITHHELD units separately. It used to fold them into `exported`
// (exported = total pending), so a book that shipped nothing for two units still
// announced them as exported — the same lie as the mislabelled banner below, told in
// numbers: a reader who trusts the summary never learns to look.
withheld, incomplete := 0, 0
for _, ce := range exp.Chunks {
switch {
case ce.Disposition == "pending":
case ce.FinalText == "":
withheld++
case ce.DroppedMembers > 0:
incomplete++
}
}
// `incomplete` is a SUBSET of `exported`, not a fourth part of the total: those units DID ship
// text, with a piece of it missing. Spelled that way so a reader adding the numbers up is not
// misled into thinking they partition the book.
fmt.Fprintf(w, "# export %s — total units=%d, exported=%d (of them incomplete=%d), withheld=%d, pending=%d",
exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits-withheld, incomplete, withheld, exp.PendingUnits)
if exp.GhostRows > 0 {
fmt.Fprintf(w, ", ghost-rows-dropped=%d", exp.GhostRows)
}
@ -494,10 +568,22 @@ func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — pending (not yet translated) ===\n", ce.Chapter, ce.ChunkIdx)
case ce.Disposition == string(pipeline.DispOK):
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d ===\n", ce.Chapter, ce.ChunkIdx)
case ce.FinalText != "":
case ce.DroppedMembers > 0 && ce.FinalText != "":
// c-lite member drop: real text, but a member chunk's worth of it is MISSING. The
// banner says so and the marker repeats it INSIDE the text stream, because a reader
// scrolling prose passes the banner once and the hole has no seam of its own.
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (INCOMPLETE) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
fmt.Fprintln(w, gapMarker(ce.DroppedMembers, ce.DroppedReason))
case ce.FlagReason == string(pipeline.FlagSanitizerStripped) && ce.FinalText != "":
// Cosmetic sanitizer strip (D35.4a): auto-cleaned remainder, flagged for a human.
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (leak cleaned, verify) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
case ce.FinalText != "":
// Flagged, with text, neither known cause — a neutral banner rather than an
// inherited claim (see renderTranslate for the same reasoning).
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (verify) ===\n",
ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason))
default:
// Substantive flag / upstream skip: no export text (D2 — contaminated output never ships).
fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (not translated, flagged for a human) ===\n",

View file

@ -211,13 +211,13 @@
},
{
"id": "P-alias-judged-on-the-set",
"why": "an inert decline is judged AFTER the fold, on the result of the whole call: the term that owns the alias may be approved by another decision of the same set",
"why": "an inert decline is judged AFTER the fold, on the result of the whole call: the term that owns the alias may be approved by another decision of the same set (anchor re-pointed when refuseInertDeclines gained its ApplyInput argument; the attacked property — the ORDER of the two phases — is unchanged)",
"package": "./internal/membank/",
"edits": [
{
"file": "internal/membank/decisions.go",
"find": "\tfoldAccepted(in, rs, &res)\n\trefuseInertDeclines(rs, &res)",
"replace": "\trefuseInertDeclines(rs, &res)\n\tfoldAccepted(in, rs, &res)"
"find": "\tfoldAccepted(in, rs, &res)\n\trefuseInertDeclines(in, rs, &res)",
"replace": "\trefuseInertDeclines(in, rs, &res)\n\tfoldAccepted(in, rs, &res)"
}
]
},
@ -427,13 +427,13 @@
},
{
"id": "AJ-seed-alias-decline",
"why": "a decline of a seed ALIAS is inert only while the delta has no row of its own for that surface; when it does, the decline drops it and repairs the collision the report is listing",
"why": "a decline of a seed ALIAS is inert only while the delta has no row of its own for that surface; when it does, the decline drops it and repairs the collision the report is listing (anchor re-pointed when the refusal gained its second carve-out; the attacked property is unchanged)",
"package": "./internal/membank/",
"edits": [
{
"file": "internal/membank/decisions.go",
"find": "held && !deltaHoldsSurface(in.Delta, r.key.Src) {",
"replace": "held {"
"find": "held &&\n\t\t\t!deltaHoldsSurface(in.Delta, r.key.Src) && !rejectsHoldSurface(in.Rejects, r.key.Src) {",
"replace": "held && !rejectsHoldSurface(in.Rejects, r.key.Src) {"
}
]
},
@ -724,5 +724,161 @@
"replace": "\t\t// The POST-state, re-read from disk: for an outcome that touched the world the report describes\n\t\t// the files as they now are, never the bytes the call intended.\n\t\trep.Signature = signatureState(book, st.seed.Terms, docsFromResult(st, res))"
}
]
},
{
"id": "SH1-system-join",
"why": "an endpoint that carries ONE system message must receive the memory-bank injection INSIDE that message; without the join the glossary is dropped by the provider at HTTP 200 and no gate can see it",
"package": "./internal/llm/",
"edits": [
{
"file": "internal/llm/httpllm.go",
"find": "\tif mode != SystemMessagesSingle {",
"replace": "\tif true {"
}
]
},
{
"id": "SH2-system-axis-from-yaml",
"why": "the declared quirk has to survive the trip from models.yaml to the resolved Capability; dropped there, the join never runs and the loss is silent again",
"package": "./internal/pipeline/",
"edits": [
{
"file": "internal/config/models.go",
"find": "\tdefault:\n\t\tc.SystemMessages = llm.SystemMessagesMode(cfg.SystemMessages)\n\t}",
"replace": "\tdefault:\n\t}"
}
]
},
{
"id": "SH3-gap-marker",
"why": "a unit that ships text with a member chunk MISSING must say so in the text a human reads; without the marker the reader gets a seamless concatenation with a hole and no seam",
"package": "./cmd/tmctl/",
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "\tm := fmt.Sprintf(\"[⚠ TEXT MISSING — %d source %s of this unit could not be translated and %s NOT in the text below\",",
"replace": "\tm := fmt.Sprintf(\"[%d %s %s\","
}
]
},
{
"id": "SH4-dropped-members-counted",
"why": "the incompleteness fact is carried by DroppedMembers, not inferred from a flag reason; stop counting it and both renderers go back to guessing, which is how the reader was told a member drop was a cosmetic clean-up",
"package": "./cmd/tmctl/",
"edits": [
{
"file": "internal/pipeline/export.go",
"find": "\tce.DroppedMembers = len(drops)",
"replace": "\tce.DroppedMembers = 0"
}
]
},
{
"id": "SH5-decline-converges",
"why": "a decline already on the record is a decision being RE-SENT, not one being made; without this carve-out the door refuses the identical document forever and a worker that retries splits the state",
"package": "./internal/membank/",
"edits": [
{
"file": "internal/membank/decisions.go",
"find": " && !rejectsHoldSurface(in.Rejects, r.key.Src) {",
"replace": " {"
}
]
},
{
"id": "SH6-rejects-rename-first",
"why": "the delta is the document the seed-conflict refusal READS, so it must not be the one that lands first: a decline interrupted after a delta-first rename leaves a state no predicate can tell from an inert decline, and the re-send is refused forever",
"package": "./internal/pipeline/",
"edits": [
{
"file": "internal/pipeline/bankdecisions.go",
"find": "\tif rejectsStage != nil {\n\t\tif err := rejectsStage.commit(); err != nil {\n\t\t\tif deltaStage != nil {\n\t\t\t\tdeltaStage.abort()\n\t\t\t}\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.rejects = true\n\t}\n\tif deltaStage != nil {\n\t\tif err := deltaStage.commit(); err != nil {\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.delta = true\n\t}",
"replace": "\tif deltaStage != nil {\n\t\tif err := deltaStage.commit(); err != nil {\n\t\t\tif rejectsStage != nil {\n\t\t\t\trejectsStage.abort()\n\t\t\t}\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.delta = true\n\t}\n\tif rejectsStage != nil {\n\t\tif err := rejectsStage.commit(); err != nil {\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.rejects = true\n\t}"
}
]
},
{
"id": "SH7-drop-reason-is-the-holes-own",
"why": "the marker must name why the MEMBER dropped, not the unit's flag reason: a unit whose edit flagged for a cosmetic strip AND lost a member would otherwise tell the reader a clean-up ate a chunk of the book — the same false claim the pack removes, moved into the prose",
"package": "./cmd/tmctl/",
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "gapMarker(ce.DroppedMembers, ce.DroppedReason)",
"replace": "gapMarker(ce.DroppedMembers, ce.FlagReason)"
}
]
},
{
"id": "SH8-draft-only-has-no-members",
"why": "a draft-only pipeline makes every chunk a SINGLETON unit whose own draft row is its final row, so the member-drop rule reads the unit's own flag as a lost member; without the guard a cosmetically stripped chunk that lost NOTHING is announced as incomplete — a marker that misinforms, which the order forbids as firmly as the silence it replaces",
"package": "./cmd/tmctl/",
"edits": [
{
"file": "internal/pipeline/export.go",
"find": "\tif r.finalStageWave() == waveEdit {",
"replace": "\tif true {"
}
]
},
{
"id": "SH9-shipped-gemini-declaration",
"why": "the two lines in configs/models.yaml are the ONLY thing that makes the system-message join reach the real endpoint; everything else about the fix is exercised against a synthetic fixture provider, and deleting them used to leave the whole module green",
"package": "./internal/config/",
"edits": [
{
"file": "configs/models.yaml",
"find": " capabilities:\n system_messages: single\n",
"replace": ""
}
]
},
{
"id": "SH10-translate-empty-text-guard",
"why": "renderTranslate's non-empty-text guards are what stop the gap marker pointing at text that does not exist; the code calls them load-bearing and nothing pinned them",
"package": "./cmd/tmctl/",
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "\t\tcase ch.DroppedMembers > 0 && ch.FinalText != \"\":",
"replace": "\t\tcase ch.DroppedMembers > 0:"
}
]
},
{
"id": "SH11-explicit-multi-costs-nothing",
"why": "writing the DEFAULT out loud must not put a key in the capability the snapshot carries — otherwise a line that changes no byte on the wire re-buys the book",
"package": "./internal/config/",
"edits": [
{
"file": "internal/config/models.go",
"find": "\tcase \"multi\":\n\t\tc.SystemMessages = llm.SystemMessagesMulti",
"replace": "\tcase \"multi\":\n\t\tc.SystemMessages = llm.SystemMessagesMode(\"multi\")"
}
]
},
{
"id": "SH12-declined-never-enters-the-bank",
"why": "the rejects-first rename order leaves the delta row on disk so an interrupted decline can converge; without this filter that window is one in which a PAID run injects a term the owner explicitly declined",
"package": "./internal/pipeline/",
"edits": [
{
"file": "internal/pipeline/mining.go",
"find": "\t\t\tif rejects[text.NormalizeSourceKey(e.Src)] {",
"replace": "\t\t\tif false {"
}
]
},
{
"id": "SH13-inert-decline-second-door",
"why": "the ALIAS door refuses an inert decline for its own good reason, but a decline ALREADY on the record is a decision being re-sent; unnarrowed it refused the standing ledger forever and, the layer being all-or-nothing, discarded the lawful decisions sent beside it",
"package": "./internal/membank/",
"edits": [
{
"file": "internal/membank/decisions.go",
"find": "\t\tif rejectsHoldSurface(in.Rejects, r.key.Src) {\n\t\t\tcontinue\n\t\t}\n",
"replace": ""
}
]
}
]
]

View file

@ -120,6 +120,21 @@ providers:
# total_tokenspromptcompletion (live-проба 2026-07-10: completion=2, total=847 → 823 thinking).
# Адаптер деривит их из total и биллит по output — иначе mandatory-thinking апекс слепил бы потолок.
reasoning: additive_total
# ⚠️ ОДНО системное сообщение на запрос. Конвейер строит ДВА (базовый промпт + инъекция банка
# памяти, render.go MessagesWithInjection), и этот слой второе не проносит: инъекция —
# глоссарий — пропадала БЕЗ ошибки, ответ приходил 200 и выглядел правильным переводом.
# ВЕНДОР-ОСНОВАНИЕ (ai.google.dev/api/generate-content, страница помечена 2026-08-17, снято
# 2026-08-28): в нативном GenerateContentRequest поле `systemInstruction` — ОДИН
# `object (Content)` (в той же таблице `contents[]` и `tools[]` несут суффикс повторяемого
# поля `[]`), а `Content.role` документирован как «Must be either 'user' or 'model'» ⇒ второму
# системному ходу в нативном запросе места нет вовсе. Что делает с ним OpenAI-совместимый
# шим, вендор НЕ документирует нигде (раздел «Current limitations» молчит и в живой странице,
# и в снимке 2026-08-21). Наша проба ($0, полигон 22.08) устанавливает, что они НЕ
# склеиваются и что инструкция ПЕРВОГО не исполняется; выживает ли второй — открыто и для
# решения безразлично: при любом чтении, кроме «склеиваются», два системных теряют текст.
# Поэтому склеиваем сами — это единственная форма, которая потерять не может.
capabilities:
system_messages: single
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60 } # апекс думает дольше
openai: # OpenAI прямой ключ (D3: Anthropic убран, OpenAI ОСТАЁТСЯ). gpt-5-nano альт-черновик,

View file

@ -98,6 +98,11 @@ type CapabilitiesConfig struct {
// below this minimum. 0 (unset) = inherit / no floor. Schema, not a comment —
// the Kimi≥16k / Gemini≥8k / DeepSeek≥8k min-budgets that were prose notes.
MinMaxTokens int `yaml:"min_max_tokens"`
// SystemMessages is the endpoint's system-message cardinality: "" (inherit / multi) or
// "single" for an endpoint that carries exactly ONE system message. Declared on the PROVIDER
// as a rule, because it is a property of the endpoint's request translation and not of a
// model's talent — every model behind the same base_url shares it.
SystemMessages string `yaml:"system_messages"`
}
// TemperatureCap declares how temperature reaches the wire.
@ -236,6 +241,15 @@ func LoadModels(path string) (*Models, error) {
if p.Kind == "local" && p.Model == "" {
bad("provider %s: local kind requires model (its own tag)", name)
}
// The Anthropic adapter takes NO Capability at all (clients.go builds it without one), so a
// wire-shape declaration there is inert — and worse than inert: it still resolves, still
// marshals into the capability the job snapshot carries, and therefore still re-buys the book
// for a line that changes no byte on the wire. Refused by KIND, the way cache_ttl is refused
// on the kinds that cannot use it. Named for system_messages because that is the axis whose
// silent no-op would restore the exact defect it exists to close.
if p.Kind == "anthropic" && p.Capabilities != nil && p.Capabilities.SystemMessages != "" {
bad("provider %s: capabilities.system_messages is an OpenAI-compat wire shape and the anthropic adapter takes no capability — it would change nothing on the wire and still move the snapshot. Drop it (the Messages API carries system as its own blocks)", name)
}
if p.LegacyPermissive != nil {
bad("provider %s: `permissive:` is retired — declare WHICH content labels this endpoint may receive: `accepts_labels: [<label>]` (a book's content_labels must be a subset of it, D39.25/D39.26). Drop the flag", name)
}
@ -558,6 +572,18 @@ func applyCapConfig(c *llm.Capability, cfg *CapabilitiesConfig) {
if cfg.MinMaxTokens > 0 { // 0 = unset → inherit the provider/kind floor (model wins when it declares one)
c.MinMaxTokens = cfg.MinMaxTokens
}
// "" = unset → inherit (the multi baseline, or the provider's declaration). An explicit
// "multi" NORMALISES to the zero value: it is how a model says "not this provider's single-
// slot rule" and how an author records a verified endpoint, and it must cost nothing —
// resolving it to a distinct string would put a key in the snapshot and re-buy the book for
// a line that changed no byte on the wire.
switch cfg.SystemMessages {
case "":
case "multi":
c.SystemMessages = llm.SystemMessagesMulti
default:
c.SystemMessages = llm.SystemMessagesMode(cfg.SystemMessages)
}
}
// canonicalLabel reports whether a label is written the way LoadBook normalises a book's labels
@ -626,6 +652,11 @@ func validateCapabilities(bad func(string, ...any), where string, c *Capabilitie
default:
bad("%s: capabilities.budget_field must be max_tokens|max_completion_tokens, got %q", where, c.BudgetField)
}
switch c.SystemMessages {
case "", "multi", "single":
default:
bad("%s: capabilities.system_messages must be multi|single, got %q", where, c.SystemMessages)
}
if c.Temperature != nil {
switch c.Temperature.Mode {
case "send", "omit", "force":

View file

@ -1,7 +1,14 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/backend/internal/llm"
)
// models_catalog_test.go — the pack-12 point-1 catalog validation test (research/21
@ -71,3 +78,110 @@ func TestShippedModelsCatalogValid(t *testing.T) {
}
}
}
// TestShippedGeminiDeclaresOneSystemMessage is the gate the pack's own fix was missing: the two lines
// in configs/models.yaml are the ONLY thing that makes the system-message join reach the real endpoint,
// and an adversarial pass proved that deleting them left the entire module's tests green. Everything
// else about the fix is exercised against a synthetic fixture provider; this is what ties it to
// production. The failure it guards is a silent HTTP 200 with the glossary dropped — nothing 4xx, no
// gate, a plausible translation that has quietly stopped obeying the memory bank.
func TestShippedGeminiDeclaresOneSystemMessage(t *testing.T) {
m, err := LoadModels(shippedModelsYAML)
if err != nil {
t.Fatalf("shipped configs/models.yaml failed validation:\n%v", err)
}
seen := 0
for name, mod := range m.Models {
if mod.Provider != "gemini" {
continue
}
seen++
if got := m.ResolveCapability(name).SystemMessages; got != llm.SystemMessagesSingle {
t.Errorf("model %s rides the Gemini OpenAI-compat layer, which carries ONE system message; "+
"resolved SystemMessages = %q, want %q — the memory-bank injection is the second system "+
"message and is dropped by the endpoint without an error",
name, got, llm.SystemMessagesSingle)
}
}
if seen == 0 {
t.Fatal("no model resolves to the gemini provider — the declaration this test guards has nothing to guard; " +
"if the provider was removed on purpose, remove this test with it")
}
}
// TestSystemMessagesIsRefusedOnTheAnthropicKind: the axis is an OpenAI-compat wire shape, and the
// Anthropic adapter is built with no Capability at all — so a declaration there reaches nothing, while
// still resolving into the capability the job snapshot carries. Accepted silently it would re-buy a
// book for a line that changed no byte on the wire; refused by kind, the mistake is a load error.
func TestSystemMessagesIsRefusedOnTheAnthropicKind(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "models.yaml")
if err := os.WriteFile(path, []byte(`
prices_checked: "`+time.Now().UTC().Format("2006-01-02")+`"
default_model: m
providers:
a:
kind: anthropic
api_key_env: X
capabilities: { system_messages: single }
models:
m:
provider: a
price: { input_per_m: 1, cached_per_m: 1, cache_write_per_m: 0, output_per_m: 1 }
`), 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadModels(path)
if err == nil {
t.Fatal("a wire-shape axis the anthropic adapter cannot read must be refused, not silently resolved")
}
if !strings.Contains(err.Error(), "capabilities.system_messages") {
t.Fatalf("the refusal must name the key, got: %v", err)
}
}
// TestExplicitMultiCostsNothing pins the normalisation the code argues for: writing the DEFAULT out
// loud is how an author records a verified endpoint or overrides a provider's single-slot rule, and it
// must not put a key in the capability the snapshot carries — that would re-buy the book for a line
// that changed no byte on the wire.
func TestExplicitMultiCostsNothing(t *testing.T) {
dir := t.TempDir()
write := func(caps string) *Models {
t.Helper()
path := filepath.Join(dir, strings.ReplaceAll(caps, " ", "")+"models.yaml")
if err := os.WriteFile(path, []byte(`
prices_checked: "`+time.Now().UTC().Format("2006-01-02")+`"
default_model: m
providers:
p:
kind: openai
base_url: http://x
api_key_env: X
`+caps+`
models:
m:
provider: p
price: { input_per_m: 1, cached_per_m: 1, cache_write_per_m: 0, output_per_m: 1 }
`), 0o644); err != nil {
t.Fatal(err)
}
m, err := LoadModels(path)
if err != nil {
t.Fatalf("load: %v", err)
}
return m
}
bare := write("")
multi := write(" capabilities: { system_messages: multi }\n")
single := write(" capabilities: { system_messages: single }\n")
bareJSON, _ := json.Marshal(bare.ResolveCapability("m"))
multiJSON, _ := json.Marshal(multi.ResolveCapability("m"))
singleJSON, _ := json.Marshal(single.ResolveCapability("m"))
if string(bareJSON) != string(multiJSON) {
t.Fatalf("an explicit `multi` must resolve byte-identically to saying nothing:\n bare %s\n multi %s", bareJSON, multiJSON)
}
if string(singleJSON) == string(bareJSON) {
t.Fatalf("a declared `single` MUST move the snapshot bytes — that is what makes the flip a loud --resnapshot: %s", singleJSON)
}
}

View file

@ -84,6 +84,46 @@ const (
ReasoningMandatory ReasoningControl = "mandatory"
)
// SystemMessagesMode declares how many SYSTEM messages the endpoint carries.
//
// It is a wire-shape fact about the ENDPOINT, not about the model's talent, which is why it
// belongs here and not in the assembler: the pipeline builds a stable system prefix plus its own
// memory-bank injection message (render.go MessagesWithInjection), and an endpoint that carries
// only one of them drops the injection — the glossary — while answering 200 with a plausible
// translation. That is the silent class this axis exists to close: nothing fails, the book just
// stops being consistent, and no downstream gate can tell the difference.
type SystemMessagesMode string
const (
// SystemMessagesMulti is the OpenAI-compat baseline: system messages go on the wire
// one-for-one, in the order the assembler produced them. Zero value, so a provider that
// declares nothing keeps exactly the wire it has today AND marshals byte-identically —
// declaring this axis on ONE provider does not move anybody else's snapshot.
SystemMessagesMulti SystemMessagesMode = ""
// SystemMessagesSingle is for an endpoint that accepts exactly ONE system message and does
// not document what it does with the rest. The leading system run is JOINED into one
// message, in order, before it reaches the wire.
//
// The vendor fact this is declared from (Gemini, generativelanguage OpenAI-compat layer;
// ai.google.dev/api/generate-content, page stamped 2026-08-17, read 2026-08-28): the native
// GenerateContentRequest carries `systemInstruction` as a SINGLE `object (Content)` — the
// same field table writes `contents[]` and `tools[]` with the repeated-field `[]` suffix —
// and `Content.role` is documented "Must be either 'user' or 'model'". So the native request
// has exactly one system slot and a second system TURN is not representable at all. What the
// compat shim does with a second one the vendor does not say anywhere (the "Current
// limitations" section is silent, in the live page and in the 2026-08-21 snapshot alike);
// our own $0 probe establishes only that they are NOT concatenated and that the FIRST one's
// instruction is not executed. Whether the second survives stays open — and does not matter
// here: under every reading except "concatenated", sending two loses content, and joining
// them ourselves is the one form that cannot.
SystemMessagesSingle SystemMessagesMode = "single"
)
// systemJoinSeparator is what SystemMessagesSingle joins the system run with: a blank line — the
// same seam ApplyHeading uses, and the one the vendor's own note describes for a multi-part
// system Content ("content in each part will be in a separate paragraph").
const systemJoinSeparator = "\n\n"
// ReasoningCap is the resolved reasoning wire-form for one model.
type ReasoningCap struct {
Control ReasoningControl
@ -115,6 +155,25 @@ type Capability struct {
// floor-less model's snapshot byte-identical to before this field existed. 0 =
// no floor (reasoning-off models: GLM, grok).
MinMaxTokens int `json:",omitempty"`
// SystemMessages is the endpoint's system-message cardinality (default: multi). It is folded
// into the job snapshot with everything else here, and for this axis that fold is not a
// formality: the join happens BELOW RequestHash (the hash is taken over the neutral message
// list in render.go; the join in toOpenAIMessages), so where the snapshot does not reach,
// nothing at all notices that a stored checkpoint now stands for different bytes. omitempty
// keeps a provider that does not declare it byte-identical to before this field existed.
//
// ⚠ WHERE THE FOLD DOES NOT REACH, said plainly rather than assumed away (adversarial review).
// The snapshot carries the wire of the STAGE models and the single escalate_to hop; the
// TERMINOLOGY GATE's model is deliberately not snapshot-folded (config/pipeline.go, the gate's
// own note), and that role DOES send two system messages. So flipping this axis on a provider
// that only the terminology gate uses changes that call's bytes under an unchanged
// RequestHash, and its stored checkpoints keep replaying. The hole is not this axis's — every
// capability axis (budget field, temperature mode, reasoning control) has ridden it since D3.1
// — but this axis is the first whose whole purpose is to change those bytes, so it is named
// here. Closing it means folding the gate's model wire the way repairSnapshot folds the repair
// model's; that is a snapshot-contract change and belongs to whoever owns the gate's
// not-folded decision, not to a provider quirk.
SystemMessages SystemMessagesMode `json:",omitempty"`
}
// withDefaults fills the OpenAI-compat baseline for zero fields: max_tokens +

View file

@ -513,13 +513,50 @@ func retryAfterOf(err error) time.Duration {
// toOpenAIMessages maps neutral messages onto the wire. CacheBoundary is
// meaningless here: OpenAI-compatible caches (DeepSeek: prefix-match from token
// 0 in blocks of 64 tokens) are automatic; the stable-prefix ORDER the
// assembler produced is all that matters.
func toOpenAIMessages(msgs []Message) []openAIMessage {
out := make([]openAIMessage, len(msgs))
for i, m := range msgs {
out[i] = openAIMessage{Role: m.Role, Content: m.Content}
// assembler produced is all that matters — which is also why JOINING the system
// run under SystemMessagesSingle costs no cache: the stable prefix keeps its
// bytes and its position, it merely stops being its own message.
//
// mode is the endpoint's system-message cardinality (Capability.SystemMessages).
// Under SystemMessagesSingle the LEADING system run is joined into one message so
// an endpoint with one system slot receives the memory-bank injection instead of
// silently dropping it; a system message AFTER a non-system turn is refused loud,
// exactly as the Anthropic adapter refuses it — our assembler never produces one,
// and a join that quietly re-ordered a conversation would be a worse lie than the
// one this function exists to stop. Under SystemMessagesMulti (the default) the
// mapping is one-for-one, byte-identical to the wire before this axis existed.
func toOpenAIMessages(msgs []Message, mode SystemMessagesMode) ([]openAIMessage, error) {
if mode != SystemMessagesSingle {
out := make([]openAIMessage, len(msgs))
for i, m := range msgs {
out[i] = openAIMessage{Role: m.Role, Content: m.Content}
}
return out, nil
}
return out
var systemRun []string
out := make([]openAIMessage, 0, len(msgs))
inSystemPrefix := true
for _, m := range msgs {
if m.Role == "system" {
if !inSystemPrefix {
return nil, fmt.Errorf("llm: this endpoint carries a single system message and got one after a non-system turn; joining it would re-order the conversation")
}
systemRun = append(systemRun, m.Content)
continue
}
if inSystemPrefix {
inSystemPrefix = false
if len(systemRun) > 0 {
out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)})
}
}
out = append(out, openAIMessage{Role: m.Role, Content: m.Content})
}
// A message list that is system-only (no user turn) still has to ship its join.
if inSystemPrefix && len(systemRun) > 0 {
out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)})
}
return out, nil
}
// jsonResponseFormat returns the response_format value for JSONOnly requests

View file

@ -85,9 +85,15 @@ func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMRespons
if c.temp > 0 {
temp = c.temp
}
// Same resolution as the cloud OpenAI-compat path: the local stand rides the identical wire
// shape, so it reads the identical axis rather than assuming the default.
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
if err != nil {
return nil, err
}
resp, err := c.http.complete(ctx, openAIRequest{
model: c.model,
messages: toOpenAIMessages(req.Messages),
messages: msgs,
maxTokens: maxTok,
temperature: temp,
stream: false,

View file

@ -106,9 +106,16 @@ func additiveReasoning(ctx context.Context, log *slog.Logger, provider string, u
}
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
// The system-run join is resolved BEFORE the request is built, and its refusal is returned
// unretried: an un-joinable message list is a request-shape error, and retrying it would only
// buy the same refusal three times.
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
if err != nil {
return nil, err
}
resp, err := c.http.complete(ctx, openAIRequest{
model: req.Model,
messages: toOpenAIMessages(req.Messages),
messages: msgs,
maxTokens: req.MaxTokens,
temperature: req.Temperature,
stream: false,

View file

@ -0,0 +1,233 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// systemmessages_test.go pins the SystemMessages capability axis — the fix for the silent loss of
// the memory-bank injection on an endpoint that carries one system message.
//
// ⚠ WHAT THESE TESTS MUST ASSERT, and why the obvious test is worthless: the pipeline has ALWAYS
// put both system messages in the request, and it does so on unfixed code too — the loss happens
// at the PROVIDER, not in our assembler. A test that checks "the injection is in the request" is
// therefore green before the fix and proves nothing. What separates fixed from unfixed is the
// POST-FIX invariant asserted below: on an endpoint declared single there is EXACTLY ONE system
// message on the wire and the injection is INSIDE it.
// systemRoles returns the roles of a decoded wire body's messages, in order.
func systemRoles(t *testing.T, body map[string]any) []string {
t.Helper()
raw, ok := body["messages"].([]any)
if !ok {
t.Fatalf("messages absent or not a list: %v", body["messages"])
}
roles := make([]string, 0, len(raw))
for _, m := range raw {
roles = append(roles, m.(map[string]any)["role"].(string))
}
return roles
}
func messageContent(t *testing.T, body map[string]any, i int) string {
t.Helper()
raw := body["messages"].([]any)
return raw[i].(map[string]any)["content"].(string)
}
// captureWire runs one Complete against a stub endpoint and returns the decoded request body.
func captureWire(t *testing.T, cap Capability, msgs []Message) map[string]any {
t.Helper()
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode request: %v", err)
}
openAIOK(t, w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{
Name: "stub", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), Cap: cap,
}, nil)
if _, err := c.Complete(context.Background(), LLMRequest{
Model: "m", Messages: msgs, MaxTokens: 4096, Temperature: 0.4,
}); err != nil {
t.Fatalf("Complete: %v", err)
}
return got
}
// bankRun is the message list the pipeline assembler actually produces for a chunk whose memory
// bank is non-empty (render.go MessagesWithInjection): stable system prefix, injection, user.
func bankRun() []Message {
return []Message{
{Role: "system", Content: "Переводи художественный текст с zh на ru.", CacheBoundary: true},
{Role: "system", Content: "ГЛОССАРИЙ: 方源 → Фан Юань"},
{Role: "user", Content: "исходный чанк"},
}
}
// TestSystemMessagesSingleCarriesTheInjection is THE post-fix invariant: one system message, and
// the glossary inside it. On unfixed code the wire carries two system messages and this fails.
func TestSystemMessagesSingleCarriesTheInjection(t *testing.T) {
body := captureWire(t, Capability{SystemMessages: SystemMessagesSingle}, bankRun())
roles := systemRoles(t, body)
nSystem := 0
for _, r := range roles {
if r == "system" {
nSystem++
}
}
if nSystem != 1 {
t.Fatalf("a single-system endpoint must receive EXACTLY ONE system message, got %d (roles %v)", nSystem, roles)
}
if want := []string{"system", "user"}; len(roles) != 2 || roles[0] != want[0] || roles[1] != want[1] {
t.Fatalf("roles = %v, want %v (the join must not move the user turn)", roles, want)
}
sys := messageContent(t, body, 0)
if !strings.Contains(sys, "ГЛОССАРИЙ: 方源 → Фан Юань") {
t.Fatalf("the memory-bank injection is NOT inside the single system message: %q", sys)
}
if !strings.Contains(sys, "Переводи художественный текст") {
t.Fatalf("the base prompt was lost by the join: %q", sys)
}
// Order is load-bearing: the stable prefix must stay first, or the prefix cache stops hitting.
if strings.Index(sys, "Переводи") > strings.Index(sys, "ГЛОССАРИЙ") {
t.Fatalf("the join reversed the stable prefix and the injection: %q", sys)
}
if want := "Переводи художественный текст с zh на ru.\n\nГЛОССАРИЙ: 方源 → Фан Юань"; sys != want {
t.Fatalf("joined system =\n%q\nwant\n%q", sys, want)
}
if got := messageContent(t, body, 1); got != "исходный чанк" {
t.Fatalf("user turn = %q", got)
}
}
// TestSystemMessagesMultiIsTheWireWeAlreadyShip is the control: an endpoint that declares nothing
// keeps the two-message wire byte-for-byte, so declaring the quirk on ONE provider cannot change
// what every other provider receives.
func TestSystemMessagesMultiIsTheWireWeAlreadyShip(t *testing.T) {
for _, cap := range []Capability{{}, {SystemMessages: SystemMessagesMulti}} {
body := captureWire(t, cap, bankRun())
roles := systemRoles(t, body)
if want := []string{"system", "system", "user"}; len(roles) != 3 ||
roles[0] != want[0] || roles[1] != want[1] || roles[2] != want[2] {
t.Fatalf("roles = %v, want %v", roles, want)
}
if got := messageContent(t, body, 0); got != "Переводи художественный текст с zh на ru." {
t.Fatalf("system[0] = %q", got)
}
if got := messageContent(t, body, 1); got != "ГЛОССАРИЙ: 方源 → Фан Юань" {
t.Fatalf("system[1] = %q", got)
}
}
}
// TestSystemMessagesSingleWithNoInjectionIsIdentical pins that the join is invisible when there is
// nothing to join — a bank-less chunk on a single-system endpoint ships exactly what it shipped.
func TestSystemMessagesSingleWithNoInjectionIsIdentical(t *testing.T) {
msgs := []Message{
{Role: "system", Content: "роль", CacheBoundary: true},
{Role: "user", Content: "чанк"},
}
body := captureWire(t, Capability{SystemMessages: SystemMessagesSingle}, msgs)
if roles := systemRoles(t, body); len(roles) != 2 || roles[0] != "system" || roles[1] != "user" {
t.Fatalf("roles = %v", roles)
}
if got := messageContent(t, body, 0); got != "роль" {
t.Fatalf("a lone system message must be untouched by the join, got %q", got)
}
}
// TestSystemMessagesSingleRefusesMidDialogueSystem: joining a system turn that sits AFTER a user
// turn would re-order the conversation, so it fails loud — the same refusal the Anthropic adapter
// makes for the same reason. Our assembler never produces one; this keeps that true by force.
func TestSystemMessagesSingleRefusesMidDialogueSystem(t *testing.T) {
_, err := toOpenAIMessages([]Message{
{Role: "system", Content: "роль"},
{Role: "user", Content: "чанк"},
{Role: "system", Content: "поздний системный"},
}, SystemMessagesSingle)
if err == nil {
t.Fatal("a system message after a non-system turn must be refused, not silently joined")
}
if !strings.Contains(err.Error(), "single system message") {
t.Fatalf("refusal must name the reason, got %v", err)
}
// The SAME list is legal on a multi endpoint: the refusal is the quirk's, not a new rule.
if _, err := toOpenAIMessages([]Message{
{Role: "system", Content: "роль"},
{Role: "user", Content: "чанк"},
{Role: "system", Content: "поздний системный"},
}, SystemMessagesMulti); err != nil {
t.Fatalf("multi endpoints keep their existing tolerance, got %v", err)
}
}
// TestSystemMessagesAxisCannotReachTheAnthropicPath is the ⛔ of the order: Anthropic turns the
// system prefix into separate blocks and CacheBoundary into a real cache_control, so a join there
// would destroy the cache contract. The axis must not be able to touch it — and structurally it
// cannot, because the Anthropic client is built without a Capability at all.
func TestSystemMessagesAxisCannotReachTheAnthropicPath(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.Write([]byte(`{"id":"m","model":"c","content":[{"type":"text","text":"ок"}],"stop_reason":"end_turn",
"usage":{"input_tokens":1,"output_tokens":1}}`))
}))
defer srv.Close()
c := NewAnthropicClient(AnthropicConfig{BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), CacheTTL: "5m"}, nil)
if _, err := c.Complete(context.Background(), LLMRequest{Model: "c", Messages: bankRun(), MaxTokens: 512}); err != nil {
t.Fatal(err)
}
sys, ok := got["system"].([]any)
if !ok || len(sys) != 2 {
t.Fatalf("Anthropic must still receive TWO system blocks, got %v", got["system"])
}
first := sys[0].(map[string]any)
cc, ok := first["cache_control"].(map[string]any)
if !ok || cc["type"] != "ephemeral" || cc["ttl"] != "5m" {
t.Fatalf("the CacheBoundary block lost its live cache_control: %v", first)
}
if second := sys[1].(map[string]any); second["cache_control"] != nil {
t.Fatalf("the injection block must carry no cache_control: %v", second)
}
if msgs, ok := got["messages"].([]any); !ok || len(msgs) != 1 {
t.Fatalf("messages = %v", got["messages"])
}
}
// TestCapabilityWithoutTheAxisMarshalsUnchanged is the DETERMINISM guard. The resolved Capability
// is folded into the job snapshot, so a new field that marshalled for every model would move every
// snapshot id and re-buy every book. omitempty is what keeps that from happening; this pins it.
func TestCapabilityWithoutTheAxisMarshalsUnchanged(t *testing.T) {
data, err := json.Marshal(Capability{Budget: BudgetMaxTokens, Temp: TempSend, MinMaxTokens: 4000}.withDefaults())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "SystemMessages") {
t.Fatalf("a capability that does not declare the axis must not carry it into the snapshot: %s", data)
}
want := `{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}`
if string(data) != want {
t.Fatalf("snapshot bytes drifted:\n got %s\nwant %s", data, want)
}
// Declaring it DOES move the bytes — that is the point: the flip is a loud --resnapshot.
declared, err := json.Marshal(Capability{SystemMessages: SystemMessagesSingle}.withDefaults())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(declared), `"SystemMessages":"single"`) {
t.Fatalf("a declared axis must reach the snapshot: %s", declared)
}
}

View file

@ -296,7 +296,7 @@ func ApplyDecisions(in ApplyInput) ApplyResult {
rs := resolveAll(in, &res)
refuseSeedConflicts(in, rs, &res)
foldAccepted(in, rs, &res)
refuseInertDeclines(rs, &res)
refuseInertDeclines(in, rs, &res)
refuseNewFaults(in, &res)
if len(res.Rejected) > 0 {
@ -371,7 +371,30 @@ func refuseSeedConflicts(in ApplyInput, rs []resolvedDecision, res *ApplyResult)
// exactly that livelock). Refusing there told the owner to «remove it from glossary_seed
// instead», which would not have removed the delta row either: the report's own instruction did
// not fix what the report complained about.
if prior, held := seedSurfaces[text.NormalizeSourceKey(r.key.Src)]; held && !deltaHoldsSurface(in.Delta, r.key.Src) {
// ⚠ …AND the refusal's sentence is still true a SECOND time. «Declining it would change
// nothing while reading as a decision» is false the moment the reject list ALREADY holds
// the surface: the decision is not being read, it is on the record, and what the caller is
// doing is re-sending it. Without this clause the refusal fired on the state its own
// acceptance produced — the first call was lawful BECAUSE the delta held a row, applying it
// DROPPED that row (applyOne → dropTerms), and the identical document was then refused
// forever. That broke three promises at once: this file's own idempotency contract
// (already_applied, see AcceptedDecision.State) and the caller-facing "re-send the SAME
// document, the retry converges". The state needs no crash to arise: a COMPLETED decline
// produces exactly it, and the identical document was then bounced forever.
//
// ⚠ The cost of the narrowing, named rather than discovered later: the clause is keyed on the
// SURFACE being on the record, so a decline that is genuinely inert against the signed seed —
// and that also happens to be recorded — now answers already_applied instead of repeating the
// instruction "Remove it from glossary_seed instead". That instruction is the only one that
// works, and it is lost in that corner. Convergence was judged the heavier duty (a caller
// following the published retry has no other move), but the trade is real and a report field
// carrying the standing fact would close it.
//
// The refusal it DOES keep is the load-bearing one, and the direction guard below pins it:
// a decline of a seed surface with no delta row AND no reject on the record still refuses,
// with this same sentence, because there it is true.
if prior, held := seedSurfaces[text.NormalizeSourceKey(r.key.Src)]; held &&
!deltaHoldsSurface(in.Delta, r.key.Src) && !rejectsHoldSurface(in.Rejects, r.key.Src) {
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
Reason: fmt.Sprintf("%q is a surface of the SIGNED seed term %q→%q and this book's mined-delta has no row of its own for it: a reject only filters PROPOSALS, so declining it would change nothing while reading as a decision. Remove it from glossary_seed instead", r.key.Src, prior.Src, prior.Dst)})
rs[i].ok = false
@ -410,11 +433,21 @@ func foldAccepted(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
// where it did harm: once dropTerms has removed the row nothing owns the alias anyway — unless ANOTHER
// term also carries the surface as an alias, which is exactly what the escape let through (delta holding
// «Nick→Nik» and «Hero→Nik2 (alias Nick)», decline Nick → accepted, and Hero went on firing Nick).
func refuseInertDeclines(rs []resolvedDecision, res *ApplyResult) {
// ⚠ AND IT CONVERGES, for the same reason its sibling does: a decline ALREADY on the record is a
// decision being re-sent, not one being made. The state is reachable by accepted calls only — decline a
// surface that IS a delta term of its own (lawful), then approve a term that carries it as an alias
// (lawful) — and after those two the standing ledger holding both decisions was refused forever, taking
// the lawful approve down with it, because the layer is all-or-nothing. The check reads in.Rejects and
// NOT res.Rejects: the fold has already run by now and its own addReject would otherwise make every
// first-time decline look like a repeat, disabling the refusal outright.
func refuseInertDeclines(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
for i, r := range rs {
if !r.ok || r.d.Action != ActionDecline {
continue
}
if rejectsHoldSurface(in.Rejects, r.key.Src) {
continue
}
if owner, held := aliasOwner(res.Delta, r.key.Src); held {
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
Reason: fmt.Sprintf("%q is an ALIAS of the approved term %q→%q, not a term of its own: it is already excluded from proposals, and this door cannot remove an alias — decline %q itself to withdraw the whole term", r.key.Src, owner.Src, owner.Dst, owner.Src)})
@ -679,6 +712,20 @@ func deltaHoldsSurface(f seed.File, src string) bool {
return false
}
// rejectsHoldSurface reports whether this surface is ALREADY on the reject list — i.e. whether a
// decline of it is a decision being re-sent rather than a decision being made. It is the mirror of
// deltaHoldsSurface and is normalised the same way addReject normalises, so "already recorded" means
// here exactly what it means where the record is written.
func rejectsHoldSurface(f seed.RejectFile, src string) bool {
nk := text.NormalizeSourceKey(src)
for _, r := range f.Rejects {
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) == nk {
return true
}
}
return false
}
func findTerm(terms []seed.Term, key termKey) (seed.Term, int) {
for i, t := range terms {
if strings.TrimSpace(t.Src) == key.Src && strings.TrimSpace(t.Sense) == key.Sense &&

View file

@ -0,0 +1,218 @@
package membank
import (
"strings"
"testing"
"textmachine/backend/internal/seed"
"textmachine/backend/internal/store"
)
// decisions_converge_test.go: the door's own idempotency contract, on the ONE trajectory that broke it.
//
// The contract, in this package's words (AcceptedDecision.State): «a retry of a decision that is
// already in the files changes nothing, writes nothing and still exits 0, because a worker that resumes
// and retries must not be able to split the state.» The published form of the same promise is
// «re-send the SAME document — the retry converges».
//
// It was false for exactly one document: a DECLINE of a surface that belongs to a signed seed term and
// that the mined-delta holds a row for — the lawful livelock repair. The first call is accepted BECAUSE
// the delta holds the row; applying it DROPS the row; and refuseSeedConflicts, which fires when the
// delta has no row, then refuses the identical document forever. The refusal was reading the state its
// own acceptance had produced.
// seedSurfaceState builds the exact state that trajectory needs: a SIGNED seed term, and a mined-delta
// holding a row of its own for the same surface.
func seedSurfaceState() ([]store.GlossaryEntry, seed.File) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан-Юань", Status: "auto"}}}
return seedRows, delta
}
func declineOf(src string) Decision { return Decision{Action: ActionDecline, Src: src} }
// TestTheSameDeclineDocumentConverges is the ordered invariant: send it, apply it, send it again — and
// the second answer is «already applied», not a refusal.
func TestTheSameDeclineDocumentConverges(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源")}
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(first.Rejected) > 0 {
t.Fatalf("the FIRST send is lawful (the delta holds a row of its own): %+v", first.Rejected)
}
if len(first.Accepted) != 1 || first.Accepted[0].State != StateApplied {
t.Fatalf("first send = %+v", first.Accepted)
}
if !first.DeltaTouched || !first.RejectsTouched {
t.Fatalf("a decline over a held surface must touch BOTH documents: %+v", first)
}
// The re-send sees the state the first one produced — which is the whole point.
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
if len(second.Rejected) > 0 {
t.Fatalf("the SAME document was refused on re-send — the retry does not converge: %+v", second.Rejected)
}
if len(second.Accepted) != 1 || second.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("the re-send must answer already_applied, got %+v", second.Accepted)
}
if second.DeltaTouched || second.RejectsTouched {
t.Fatalf("a converged re-send must change nothing: delta=%v rejects=%v", second.DeltaTouched, second.RejectsTouched)
}
// And a THIRD send is the same answer: convergence is a fixed point, not a one-off tolerance.
third := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: second.Delta, Rejects: second.Rejects, Decisions: doc})
if len(third.Rejected) > 0 || third.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("third send = %+v / %+v", third.Accepted, third.Rejected)
}
}
// TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument: the layer is all-or-nothing, so the refusal
// did not merely strand the decline — it discarded every lawful decision sent beside it.
func TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源"), approve("李青", "Ли Цин")}
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(first.Rejected) > 0 {
t.Fatalf("first send: %+v", first.Rejected)
}
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
if len(second.Rejected) > 0 {
t.Fatalf("the re-send refused, taking the lawful approve down with it: %+v", second.Rejected)
}
if len(second.Accepted) != 2 {
t.Fatalf("both decisions must be answered, got %+v", second.Accepted)
}
for _, a := range second.Accepted {
if a.State != StateAlreadyApplied {
t.Fatalf("every decision of a fully-landed document is already_applied, got %+v", a)
}
}
}
// TestTheHalfStateOfAnInterruptedDeclineConverges is the case where a refusal was not an inconvenience
// but a locked door: the process died between the two renames. Under the rejects-first order the
// surviving half is the REJECTS and the delta still holds the row, so the identical document finishes
// the job. (The engine's own writeDecisionFiles pins that order; this pins what the order buys.)
func TestTheHalfStateOfAnInterruptedDeclineConverges(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源")}
full := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(full.Rejected) > 0 {
t.Fatalf("setup: %+v", full.Rejected)
}
// Half-state: the REJECTS landed, the delta did not (it still holds the row).
half := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Rejects: full.Rejects, Decisions: doc})
if len(half.Rejected) > 0 {
t.Fatalf("an interrupted decline must be finishable by re-sending the same document: %+v", half.Rejected)
}
if !half.DeltaTouched {
t.Fatal("the re-send must finish the unwritten half (drop the delta row)")
}
if deltaHoldsSurface(half.Delta, "方源") {
t.Fatal("after the converged re-send the delta must no longer hold the declined surface")
}
// The finished state is then the fixed point.
again := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: half.Delta, Rejects: half.Rejects, Decisions: doc})
if len(again.Rejected) > 0 || again.DeltaTouched || again.RejectsTouched {
t.Fatalf("after convergence the document must be inert: %+v / %+v", again.Rejected, again.Accepted)
}
}
// TestAGenuinelyInertDeclineIsStillRefused is the DIRECTION GUARD, and it is half the order: the
// refusal that was narrowed is load-bearing and was bought with a livelock. A decline of a seed surface
// with NO delta row of its own AND no reject on the record still refuses — with the same sentence.
func TestAGenuinelyInertDeclineIsStillRefused(t *testing.T) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{declineOf("方源")}})
if len(res.Rejected) != 1 {
t.Fatalf("an inert decline of a signed seed surface must still be refused, got %+v", res.Rejected)
}
if r := res.Rejected[0].Reason; !strings.Contains(r, "filters PROPOSALS") ||
!strings.Contains(r, "Remove it from glossary_seed instead") {
t.Fatalf("the refusal must keep its own words, got %q", r)
}
// The narrowing is EXACTLY «already on the record», nothing wider: an unrelated reject does not
// license it.
other := seed.RejectFile{Rejects: []seed.Reject{{Src: "李青"}}}
res2 := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: other, Decisions: []Decision{declineOf("方源")}})
if len(res2.Rejected) != 1 {
t.Fatalf("a reject for a DIFFERENT surface must not lift the refusal, got %+v", res2.Rejected)
}
}
// TestARepeatedInertDeclineConverges closes the pair: once the refusal has been lifted for a surface
// that IS on the record, the record is what decides — including for a surface the owner declined when
// it was still a live delta row and which the seed later grew to cover.
func TestARepeatedInertDeclineConverges(t *testing.T) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
onRecord := seed.RejectFile{Rejects: []seed.Reject{{Src: "方源", Note: "решено владельцем"}}}
res := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: onRecord, Decisions: []Decision{declineOf("方源")}})
if len(res.Rejected) > 0 {
t.Fatalf("a decline already on the record must converge, not refuse: %+v", res.Rejected)
}
if len(res.Accepted) != 1 || res.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("got %+v", res.Accepted)
}
if res.RejectsTouched {
t.Fatal("nothing to write: the reject is already there")
}
// The owner's own words survive the repeat (the note rule of addReject).
if res.Rejects.Rejects[0].Note != "решено владельцем" {
t.Fatalf("the repeat erased the owner's note: %+v", res.Rejects.Rejects[0])
}
}
// TestTheStandingLedgerConvergesThroughBothDoors is the trajectory an adversarial pass walked, and every
// step of it is an ACCEPTED call — no crash, no hand edit. A caller who keeps one document as the
// standing ledger of their decisions (which is the workflow the published retry describes) re-sends it
// and, before the fix, was refused by the OTHER inert-decline door — losing the lawful approve beside it,
// because the layer is all-or-nothing.
func TestTheStandingLedgerConvergesThroughBothDoors(t *testing.T) {
// 方小子 starts as a delta term of its OWN, so declining it is lawful and changes something.
delta := seed.File{Terms: []seed.Term{{Src: "方小子", Dst: "Малыш Фан", Status: "approved"}}}
// The bank offers 方源 with 方小子 among its aliases, so approving 方源 later makes 方小子 an alias.
bank := []store.GlossaryEntry{{Src: "方源", Status: "auto", Source: "mined",
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}}}
step1 := ApplyDecisions(ApplyInput{Bank: bank, Delta: delta, Decisions: []Decision{declineOf("方小子")}})
if len(step1.Rejected) > 0 {
t.Fatalf("step 1 (decline a term of its own) is lawful: %+v", step1.Rejected)
}
step2 := ApplyDecisions(ApplyInput{Bank: bank, Delta: step1.Delta, Rejects: step1.Rejects,
Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}})
if len(step2.Rejected) > 0 {
t.Fatalf("step 2 (approve the owning term) is lawful: %+v", step2.Rejected)
}
// The standing ledger: BOTH decisions, re-sent over the state they themselves produced.
ledger := []Decision{declineOf("方小子"), {Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}
res := ApplyDecisions(ApplyInput{Bank: bank, Delta: step2.Delta, Rejects: step2.Rejects, Decisions: ledger})
if len(res.Rejected) > 0 {
t.Fatalf("the standing ledger was refused on re-send — and an all-or-nothing door discards the lawful approve with it: %+v", res.Rejected)
}
for _, a := range res.Accepted {
if a.State != StateAlreadyApplied {
t.Fatalf("every decision of a fully-landed ledger is already_applied, got %+v", a)
}
}
if res.DeltaTouched || res.RejectsTouched {
t.Fatal("a converged re-send must change nothing")
}
}
// TestDecliningALiveAliasIsStillRefused is the direction guard for the SECOND door: the alias refusal is
// load-bearing (declining an alias does not withdraw the term, and the owner has to be told to decline
// the term itself), and the narrowing must not have opened it for a surface nobody has decided about.
func TestDecliningALiveAliasIsStillRefused(t *testing.T) {
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved",
Aliases: []seed.Alias{{Alias: "方小子"}}}}}
res := ApplyDecisions(ApplyInput{Delta: delta, Decisions: []Decision{declineOf("方小子")}})
if len(res.Rejected) != 1 {
t.Fatalf("declining a live alias must still be refused, got %+v", res.Rejected)
}
if r := res.Rejected[0].Reason; !strings.Contains(r, "is an ALIAS of the approved term") {
t.Fatalf("the refusal must keep its own words, got %q", r)
}
}

View file

@ -185,6 +185,12 @@ func FuzzDecisionDocument(f *testing.F) {
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"赵大","dst":"Другое"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"x","sense":"s","since_chapter":-1,"dst":"y"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[]}`)
// The lawful decline of a SEED SURFACE the delta holds a row for — the livelock repair, and the one
// trajectory whose re-send the door used to refuse forever. Oracle 4 below is exactly the property
// it breaks; the corpus simply could not reach the state, because the delta fixture held no row for
// a seed surface. One row (老赵, an alias of the signed 赵大) and this seed put it in reach.
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"},{"action":"approve","src":"李青","dst":"Ли Цин"}]}`)
f.Add(`not json at all`)
bank := []store.GlossaryEntry{
@ -212,6 +218,11 @@ func FuzzDecisionDocument(f *testing.F) {
Voices: []store.VoiceProfile{{Src: "赵大", SinceCh: 1}},
Delta: seed.File{Terms: []seed.Term{
{Src: "花月", Dst: "Хуа Юэ", Status: "approved", Aliases: []seed.Alias{{Alias: "月妹", Type: "mined"}}},
// A row whose OWN src is a surface of the SIGNED seed term above (赵大's alias 老赵). It is
// the collision the seed-conflict carve-out exists to let the owner repair, and without it
// in the fixture the corpus cannot reach the state whose re-send used to be refused —
// oracle 4 owned the property and never got to exercise it.
{Src: "老赵", Dst: "Старина Чжао", Status: "approved"},
}},
Rejects: seed.RejectFile{Rejects: []seed.Reject{{Src: "水", Note: "n"}}},
Decisions: doc.Decisions,

View file

@ -432,10 +432,19 @@ func changedFiles(st bookState, res membank.ApplyResult) (delta, rejects bool) {
// What this is NOT: atomicity of the PAIR against a dying process. The window between the two renames
// remains, and closing it needs a journal across two files — a mechanism this door has not earned. The
// residue is covered by convergence: the call is all-or-nothing and recomputed from scratch, so a retry
// of the same document converges from either half-state, every half-state leaves the term UNDECIDED
// rather than wrongly decided, and a killed process prints no report. The RENAME ORDER is arbitrary and
// says so — delta-first is safe for an approve and not for a decline, the mirror order swaps which verb
// loses, and convergence is what actually carries both.
// of the same document converges from either half-state, and a killed process prints no report.
// ⚠ What a half-state leaves is stated precisely, because the older wording ("every half-state leaves
// the term UNDECIDED rather than wrongly decided") stopped being true when the order changed: under
// rejects-first an interrupted DECLINE leaves the decision RECORDED while the delta row is still on
// disk. That is not "wrongly decided" — it is decided correctly and half-written — and it is only safe
// because the bank loader drops a delta row whose surface is on the reject list (mining.go
// loadMinedDelta). Without that filter this order would have bought recoverability with a window in
// which a paid run injects a term the owner declined. The RENAME ORDER is what makes
// that true, and it is REJECTS FIRST. It used to be delta-first with a comment calling the order
// arbitrary — "the mirror order swaps which verb loses, and convergence is what actually carries both".
// The second half of that sentence was the load-bearing one and it was false for a decline: dropping the
// delta row is exactly what makes refuseSeedConflicts fire on the re-send, so delta-first did not swap
// which verb loses, it chose the verb that CANNOT recover. The order below is argued in place.
//
// The per-file return is the report's: which file this call replaced on disk, however far it got.
func writeDecisionFiles(book *config.Book, res membank.ApplyResult, deltaChanged, rejectsChanged bool) (writtenFiles, error) {
@ -455,20 +464,35 @@ func writeDecisionFiles(book *config.Book, res membank.ApplyResult, deltaChanged
return wrote, err
}
}
if deltaStage != nil {
if err := deltaStage.commit(); err != nil {
if rejectsStage != nil {
rejectsStage.abort()
// REJECTS FIRST, and the order is NOT arbitrary — see the header. Whichever document lands
// alone, the re-send has to converge, and only this order lets it for BOTH verbs:
//
// rejects-first, decline killed between the renames → the reject is recorded and the delta
// STILL HOLDS the row, so refuseSeedConflicts stays silent (its own precondition holds) and
// the re-send drops the row and no-ops the reject: converged.
// rejects-first, approve killed between → the reject is withdrawn and the delta lacks the row,
// so the re-send merges the row and no-ops the withdrawal: converged.
// delta-first, decline killed between → the row is GONE and no reject was recorded, and the two
// files are then byte-for-byte the state in which declining that surface is genuinely inert.
// No predicate over them can tell the half-state from the inert one, so the re-send is refused
// and the term is stranded neither approved nor declined, with no channel forward.
//
// That last line is why this is a swap and not a preference: the delta is the document the
// seed-conflict refusal READS, so the document that decides must not be the one that lands first.
if rejectsStage != nil {
if err := rejectsStage.commit(); err != nil {
if deltaStage != nil {
deltaStage.abort()
}
return wrote, err
}
wrote.delta = true
wrote.rejects = true
}
if rejectsStage != nil {
if err := rejectsStage.commit(); err != nil {
if deltaStage != nil {
if err := deltaStage.commit(); err != nil {
return wrote, err
}
wrote.rejects = true
wrote.delta = true
}
// The decision files are the USER's words, so their durability is not left to the kernel's leisure:
// the renames' directory entries are flushed here (both files live in the book's directory — the

View file

@ -0,0 +1,185 @@
package pipeline
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
)
// bankdecisions_converge_test.go: the same convergence contract as membank/decisions_converge_test.go,
// but through the DOOR — real files, the real lock, the real refusal classes. The semantics are pinned
// one layer down; what is pinned here is that a caller who follows the published instruction («re-send
// the SAME document») is not answered with a refusal class and left with nowhere to go.
// declineProject writes a book that carries a SIGNED seed term plus a mined-delta holding a row of its
// own for the same surface — the lawful livelock repair, and the one state the door could not converge on.
func declineProject(t *testing.T) string {
t.Helper()
cfg := decideProject(t)
dir := filepath.Dir(cfg)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
terms:
- src: 方源
dst: Фан Юань
type: name
status: approved
`)
// The book must DECLARE the seed; the delta/rejects paths stay conventional.
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
writeFile(t, filepath.Join(dir, "decide-book"+config.MinedDeltaSuffix), `
terms:
- src: 方源
dst: Фан-Юань
status: auto
`)
return cfg
}
func declineDoc(t *testing.T, dir string) string {
t.Helper()
return decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源"})
}
// TestReSendingAnAppliedDeclineIsNotARefusal is the door-level form of the ordered invariant.
func TestReSendingAnAppliedDeclineIsNotARefusal(t *testing.T) {
cfg := declineProject(t)
dir := filepath.Dir(cfg)
doc := declineDoc(t, dir)
first, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the first send is lawful: %v", err)
}
if !first.Changed || len(first.Accepted) != 1 || first.Accepted[0].State != membank.StateApplied {
t.Fatalf("first send = %+v", first)
}
second, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("re-sending the SAME document must converge, got %v (class %v)", err, refusalClassOf(err))
}
if second.Changed {
t.Fatalf("a converged re-send changes nothing: %+v", second)
}
if len(second.Accepted) != 1 || second.Accepted[0].State != membank.StateAlreadyApplied {
t.Fatalf("re-send = %+v", second.Accepted)
}
// The published instruction includes a SAFE PREVIEW; it must answer the same way, not a refusal.
dry, err := ApplyBankDecisions(t.Context(), cfg, doc, true)
if err != nil {
t.Fatalf("--dry-run of an applied document must not refuse: %v", err)
}
if dry.Changed {
t.Fatalf("dry-run of an applied document projects no change: %+v", dry)
}
}
// TestAnInterruptedDeclineIsFinishedByTheSameDocument is the case that locked a user out: the process
// died between the two renames. The rejects-first order leaves the DELTA row intact, so the identical
// document finishes the job instead of being bounced.
func TestAnInterruptedDeclineIsFinishedByTheSameDocument(t *testing.T) {
cfg := declineProject(t)
dir := filepath.Dir(cfg)
doc := declineDoc(t, dir)
// Reproduce the half-state exactly as the interrupted write leaves it: the reject list landed, the
// delta did not. (writeDecisionFiles commits rejects first — see TestTheRenameOrderIsWhatMakesADeclineRecover.)
writeFile(t, filepath.Join(dir, "decide-book"+configMinedRejectsSuffix), "rejects:\n - src: 方源\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the half-state must be finishable by the same document, got %v (class %v)", err, refusalClassOf(err))
}
if !rep.Changed || !rep.WrittenDelta {
t.Fatalf("the re-send must write the half that did not land: %+v", rep)
}
raw := readAll(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix))
if strings.Contains(raw, "方源") {
t.Fatalf("the declined surface must be gone from the delta after the converged re-send:\n%s", raw)
}
// And now it is inert.
again, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil || again.Changed {
t.Fatalf("after convergence the document is a no-op: %+v / %v", again, err)
}
}
// TestAnInertDeclineIsStillRefusedAtTheDoor is the direction guard at the door: the narrowing must not
// have opened the refusal the livelock bought.
func TestAnInertDeclineIsStillRefusedAtTheDoor(t *testing.T) {
cfg := decideProject(t) // no mined-delta at all
dir := filepath.Dir(cfg)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
terms:
- src: 方源
dst: Фан Юань
type: name
status: approved
`)
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, declineDoc(t, dir), false)
if refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("a genuinely inert decline must still be refused, got %v", err)
}
if len(rep.Rejected) != 1 || !strings.Contains(rep.Rejected[0].Reason, "filters PROPOSALS") {
t.Fatalf("the refusal must keep its own words: %+v", rep.Rejected)
}
// Nothing was written by the refusal.
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedRejectsSuffix)); !errors.Is(serr, os.ErrNotExist) {
t.Fatalf("a refused call must write nothing: %v", serr)
}
}
// TestADeclinedSurfaceNeverEntersTheBankFromTheDelta is the other half of the rejects-first order, and
// it exists because an adversarial pass showed the order alone was not safe. Rejects-first is what lets
// an interrupted decline converge — and it does so by leaving the DELTA ROW on disk. seedGlossary loads
// the mined-delta into the live bank and never consulted the reject list, so between the crash and the
// operator's re-send a PAID run would have injected into the editor's glossary a term the owner had
// explicitly declined. Convergence must not be bought with a second silent harm.
func TestADeclinedSurfaceNeverEntersTheBankFromTheDelta(t *testing.T) {
bookPath := setupProjectOpts(t, "http://127.0.0.1:1", projectOpts{})
r := newRunner(t, bookPath)
defer r.Close()
// The exact half-state the rejects-first order produces: the reject landed, the delta row did not go.
writeFile(t, r.Book.MinedDelta, "terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n")
writeFile(t, r.Book.MinedRejects, "rejects:\n - src: 方源\n")
got, err := r.loadMinedDelta()
if err != nil {
t.Fatalf("load: %v", err)
}
for _, e := range got {
if e.Src == "方源" {
t.Fatalf("a DECLINED surface reached the bank through the mined-delta: %+v", e)
}
}
if len(got) != 0 {
t.Fatalf("the delta held only the declined term; nothing may survive it: %+v", got)
}
// The filter is scoped to what the owner actually declined — an undeclined row still loads.
writeFile(t, r.Book.MinedDelta,
"terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n - src: 李青\n dst: Ли Цин\n status: approved\n")
got2, err := r.loadMinedDelta()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got2) != 1 || got2[0].Src != "李青" {
t.Fatalf("only the declined surface may be dropped, got %+v", got2)
}
}

View file

@ -140,8 +140,17 @@ func TestNothingLandedIsTheWriteIncompleteClass(t *testing.T) {
}
// TestHalfLandedIsNamedPerFile drives the write phase directly into its between-the-renames failure —
// the delta commits, the rejects rename hits an existing directory — and pins that the per-file truth
// and the single class survive it. One class for both halves; the report carries the difference.
// the FIRST document commits, the SECOND rename hits an existing directory — and pins that the per-file
// truth and the single class survive it. One class for both halves; the report carries the difference.
//
// ⚠ THE FIXTURE'S TARGET MOVED, THE PROPERTY DID NOT. This test used to occupy the REJECTS path,
// because the commit order was delta-first. That order is now rejects-first and the reason is argued in
// writeDecisionFiles: delta-first stranded a decline forever (dropping the delta row is exactly what
// makes refuseSeedConflicts fire on the re-send, and no predicate over the two files can tell that
// half-state from a genuinely inert decline). So the surviving half is now the rejects and the failing
// rename is the delta — the same between-the-renames failure, seen from the other side. What is asserted
// is unchanged and unweakened: exactly one half landed, it is the PROVEN bytes, and the report names
// which. The order itself is pinned, with its reason, by TestTheRenameOrderIsWhatMakesADeclineRecover.
func TestHalfLandedIsNamedPerFile(t *testing.T) {
dir := t.TempDir()
book := &config.Book{BookID: "half-book",
@ -149,27 +158,52 @@ func TestHalfLandedIsNamedPerFile(t *testing.T) {
MinedRejects: filepath.Join(dir, "half-book"+config.MinedRejectsSuffix),
ProjectDB: filepath.Join(dir, "half-book.db"),
}
// The rejects target IS an existing non-empty directory: staging beside it succeeds, the rename fails.
if err := os.MkdirAll(filepath.Join(book.MinedRejects, "occupied"), 0o755); err != nil {
// The DELTA target IS an existing non-empty directory: staging beside it succeeds, the rename fails.
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
t.Fatal(err)
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
wrote, err := writeDecisionFiles(book, res, true, true)
if err == nil {
t.Fatal("the rejects rename cannot succeed onto a directory")
t.Fatal("the delta rename cannot succeed onto a directory")
}
if !wrote.delta || wrote.rejects {
t.Fatalf("per-file truth: delta landed, rejects did not — got %+v", wrote)
if wrote.delta || !wrote.rejects {
t.Fatalf("per-file truth: rejects landed, delta did not — got %+v", wrote)
}
if raw, rerr := os.ReadFile(book.MinedDelta); rerr != nil || string(raw) != "terms: []\n" {
if raw, rerr := os.ReadFile(book.MinedRejects); rerr != nil || string(raw) != "rejects: []\n" {
t.Fatalf("the landed half must be the proven bytes: %q / %v", raw, rerr)
}
rep := finishReport(decisionVerdict(book, membank.ApplyResult{}), outcomeWriteFailed, book, bookState{}, membank.ApplyResult{}, wrote)
if rep.Mode != "write_incomplete" || !rep.WrittenDelta || rep.WrittenRejects || !rep.Changed || len(rep.Accepted) != 0 {
if rep.Mode != "write_incomplete" || rep.WrittenDelta || !rep.WrittenRejects || !rep.Changed || len(rep.Accepted) != 0 {
t.Fatalf("half landed and the report must say which half: %+v", rep)
}
}
// TestTheRenameOrderIsWhatMakesADeclineRecover pins the ORDER itself, and its REASON, so it cannot be
// flipped back as a tidy-up. The comment it replaces called the order arbitrary; it is not.
func TestTheRenameOrderIsWhatMakesADeclineRecover(t *testing.T) {
dir := t.TempDir()
book := &config.Book{BookID: "order-book",
MinedDelta: filepath.Join(dir, "order-book"+config.MinedDeltaSuffix),
MinedRejects: filepath.Join(dir, "order-book"+config.MinedRejectsSuffix),
}
// Make the SECOND rename fail, whichever it is, by occupying the delta path.
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
t.Fatal(err)
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
wrote, _ := writeDecisionFiles(book, res, true, true)
// The DECIDING document — the one refuseSeedConflicts reads — must be the one still unwritten, so
// the interrupted decline's precondition survives and the re-send converges. If the delta had landed
// first, the row would be gone, the reject absent, and the identical document refused forever.
if wrote.delta {
t.Fatal("the delta must NOT be the first document to land: a decline killed after it is unrecoverable")
}
if !wrote.rejects {
t.Fatal("the rejects must land first, so an interrupted decline leaves the delta row intact for the re-send")
}
}
// TestBothDocumentsAreStagedBeforeEitherRename pins the §4.8 order itself: a failure to STAGE the
// second document must leave the FIRST unmoved — under the old interleaved write the delta was already
// committed by then, which is how «half landed» happened for a failure that staging would have caught

View file

@ -67,6 +67,19 @@ type ChunkOutcome struct {
Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state)
FlagReason FlagReason // the flagging stage's reason ("" when ok)
CostUSD float64 // THIS run's spend on this chunk
// DroppedMembers counts the unit's member chunks whose draft flagged and which the c-lite editor
// therefore left OUT of the edit (waverun.go runEditUnit). It is what makes "the shipped text is
// INCOMPLETE" a fact the renderers can state instead of infer: FlagReason cannot carry it, because
// a unit can be flagged for the EDIT's own reason (a cosmetic sanitizer strip) while a member was
// dropped as well, and then the reason names the strip and says nothing about the missing member.
// 0 for every unit that lost nothing.
DroppedMembers int
// DroppedReason is why the FIRST dropped member's draft flagged — the cause of the HOLE, which is
// not the same fact as FlagReason. When the edit ALSO flags on its own account (a cosmetic
// sanitizer strip), FlagReason is the strip's and says nothing about the missing text; printing it
// as the cause of the loss tells the reader a clean-up ate a chunk of the book, which is the very
// false claim this pack exists to remove. "" when nothing dropped.
DroppedReason FlagReason
}
// BookResult aggregates a whole run over every chapter×chunk.

View file

@ -42,6 +42,16 @@ type ChunkExport struct {
// (千万) source-vs-target checkers' false-positive rate. Omitted by default (the export contract is
// target-only); the source is the $0 manifest re-chunk, never a stored/billed artifact.
Source string `json:"source,omitempty"`
// DroppedMembers counts the member chunks the c-lite editor left OUT of this unit's edit, so a
// consumer can say "the text below is INCOMPLETE" without inferring it from FlagReason — which
// cannot carry it: an edit flagged for its OWN reason (a cosmetic sanitizer strip) keeps that
// reason even when a member also dropped, and the reason then describes the strip and is silent
// about the missing text. Additive and omitempty: a unit that lost nothing serializes exactly as
// before this field existed.
DroppedMembers int `json:"dropped_members,omitempty"`
// DroppedReason is why the FIRST dropped member flagged — the cause of the HOLE, which FlagReason
// is not when the edit flagged on its own account as well. "" when nothing dropped.
DroppedReason string `json:"dropped_reason,omitempty"`
}
// exportVersion versions the SHAPE of the export document.
@ -222,6 +232,25 @@ func (r *Runner) exportUnit(u editUnit, cs store.ChunkStatus, byChunk map[chunkK
// the FIRST dropped member's reason AND detail, so it is self-consistent rather than the ok edit's blank
// detail, and ce.FinalText (the edited clean remainder) still ships.
drops := memberDrops(u, byChunk, draftStageNames)
// ⚠ A DRAFT-ONLY pipeline has no members to drop: the unit IS one draft chunk and that chunk's own
// draft row is its FINAL row, so memberDrops — which looks for a flagged DRAFT row among the unit's
// members — reports the unit's own flag as a lost member. Reading that as a hole would announce
// «a fragment is missing» over a chunk whose text is entirely present (a cosmetically stripped one
// ships all of its prose), which is the anti-scope this pack is explicitly bound by: a marker on
// complete text misinforms. Before the DroppedMembers field the mistake was impossible by accident
// — droppedAny also demanded an ok edit — so the guard has to be stated now that the count is kept
// on its own.
if r.finalStageWave() == waveEdit {
// Recorded for EVERY drop, not only for the one that overrides an ok edit: the unit whose edit
// flagged on its own account lost the member's text just the same, and that is the case the
// reason-only reading was blind to.
ce.DroppedMembers = len(drops)
if len(drops) > 0 {
ce.DroppedReason = drops[0].Reason
}
} else {
drops = nil
}
droppedAny := len(drops) > 0 && ce.Disposition == string(DispOK)
if droppedAny {
ce.Disposition = string(DispFlagged)

View file

@ -850,6 +850,40 @@ func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) {
if err != nil {
return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err)
}
// A DECLINED surface never enters the bank, even from the delta. The two decision documents are ONE
// state, and a surface on the reject list is decided-against; the delta is the only door through
// which a decided-against surface could still reach a paid run, so it is closed here.
//
// In ordinary operation the two documents cannot both hold a surface — applying a decline drops the
// delta row and records the reject in ONE act. The state exists anyway, by two routes, and both end
// in the editor's glossary if this filter is absent: a hand-edited delta, and the half-state a
// process killed between the two renames leaves behind. The second is why this sits beside the
// rejects-first rename order (writeDecisionFiles): that order is what lets an interrupted decline's
// re-send converge, and it does so by leaving the delta row on disk — so without this filter the
// convergence would have been bought with a window in which a paid run injects a term the owner
// explicitly declined. Loud, because a delta row and a reject for one surface means the two
// documents disagree and somebody should look.
rejects, rerr := r.loadMinedRejects()
if rerr != nil {
return nil, rerr
}
if len(rejects) > 0 {
kept := entries[:0:0]
var declined []string
for _, e := range entries {
if rejects[text.NormalizeSourceKey(e.Src)] {
declined = append(declined, e.Src)
continue
}
kept = append(kept, e)
}
if len(declined) > 0 {
r.Log.Warn("mined-delta holds term(s) the owner has DECLINED; they are NOT entering the bank (the two decision documents disagree — a hand edit, or a write interrupted between the two files)",
"book", r.Book.BookID, "declined", strings.Join(declined, ", "),
"mined_delta", r.Book.MinedDelta, "mined_rejects", r.Book.MinedRejects)
}
entries = kept
}
for i := range entries {
entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded)
}

View file

@ -123,6 +123,10 @@ type projectOpts struct {
// draftOnly drops the editor stage, so the DRAFT is the shipping output (the pipeline shape the wave
// executor calls draft-only). false keeps the historical two-stage fixture byte-identical.
draftOnly bool
// systemMessagesSingle declares the fake PROVIDER as an endpoint that carries exactly ONE system
// message (the Gemini OpenAI-compat quirk). false omits the capabilities block entirely, so every
// pre-existing fixture's models.yaml stays byte-identical.
systemMessagesSingle bool
}
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
@ -146,6 +150,10 @@ func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
providerCaps := ""
if o.systemMessagesSingle {
providerCaps = " capabilities: { system_messages: single }\n"
}
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
@ -153,12 +161,12 @@ providers:
fake:
kind: openai
base_url: %q
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
%s timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
`, time.Now().UTC().Format("2006-01-02"), providerURL, providerCaps))
gatesBlock := o.gatesYAML
if o.postcheckGate {

View file

@ -0,0 +1,138 @@
package pipeline
import (
"context"
"strings"
"testing"
)
// systemmessages_wave_test.go proves the SystemMessages fix on the two paths that actually carry
// the memory bank to a model — the DRAFT wave (the translator's src→dst glossary) and the EDIT
// wave (the editor's CONFIRMED-dst constraint block). The edit wave matters on its own account:
// it is the wave that delivers the OWNER'S SIGNED decisions, so a hop that ate its system message
// would silently eat the owner's edits, not merely a machine's proposals.
//
// ⚠ The assertion is the POST-FIX invariant, deliberately: "the injection is in the request" is
// true of unfixed code too (the assembler always built it; the endpoint dropped it), so it would
// be green on a broken engine. What separates fixed from unfixed is ONE system message with the
// injection INSIDE it.
// systemMessagesOf returns the wire body's system messages, in order.
func systemMessagesOf(t *testing.T, body string) []wireMsg {
t.Helper()
var out []wireMsg
for _, m := range bodyMessages(t, body) {
if m.Role == "system" {
out = append(out, m)
}
}
return out
}
// TestSingleSystemEndpointCarriesTheBankOnBothWaves runs a real two-wave book against a provider
// declared single-system and asserts, for the draft AND the edit request, that the endpoint got
// exactly one system message and that the bank block is inside it.
func TestSingleSystemEndpointCarriesTheBankOnBothWaves(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
}
return "Судзуки пошёл в тихую библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, systemMessagesSingle: true,
})
r := newRunner(t, bookPath)
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Flagged != 0 {
t.Fatalf("the join must not disturb the verdict: flagged=%d", res.Flagged)
}
cases := []struct {
wave string
isEdit bool
wantBlock string // the bank text this wave's role receives
}{
// waverun.go runDraftChunk: the translator's src→dst glossary.
{wave: "draft", isEdit: false, wantBlock: "ГЛОССАРИЙ"},
// waverun.go runEditUnit: the editor's CONFIRMED-dst constraint block — the carrier of the
// owner's signed decisions.
{wave: "edit", isEdit: true, wantBlock: "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ"},
}
for _, c := range cases {
t.Run(c.wave, func(t *testing.T) {
seen := 0
for _, body := range rec.all() {
if isEditBody(body) != c.isEdit {
continue
}
seen++
sys := systemMessagesOf(t, body)
if len(sys) != 1 {
t.Fatalf("%s wave: a single-system endpoint received %d system messages — the bank is being dropped by the provider",
c.wave, len(sys))
}
if !strings.Contains(sys[0].Content, c.wantBlock) {
t.Fatalf("%s wave: the bank block %q is not inside the single system message: %q",
c.wave, c.wantBlock, sys[0].Content)
}
if !strings.Contains(sys[0].Content, "Судзуки") {
t.Fatalf("%s wave: the approved rendering did not reach the model: %q", c.wave, sys[0].Content)
}
// The stable prompt prefix must stay FIRST inside the join, or the provider's prefix
// cache stops hitting on every call.
iPrefix := strings.Index(sys[0].Content, "Редактируй перевод.")
if !c.isEdit {
iPrefix = strings.Index(sys[0].Content, "Переводи с")
}
if iPrefix != 0 {
t.Fatalf("%s wave: the stable prefix is not first in the join (index %d): %q", c.wave, iPrefix, sys[0].Content)
}
// The user turn survives the join untouched, and the bank never leaks into it.
msgs := bodyMessages(t, body)
if len(msgs) != 2 || msgs[1].Role != "user" {
t.Fatalf("%s wave: wire shape = %+v, want [system user]", c.wave, msgs)
}
if strings.Contains(msgs[1].Content, c.wantBlock) {
t.Fatalf("%s wave: the bank leaked into the user message: %q", c.wave, msgs[1].Content)
}
}
if seen == 0 {
t.Fatalf("%s wave recorded no request", c.wave)
}
})
}
}
// TestMultiSystemEndpointIsUnchangedOnBothWaves is the control on the SAME book: a provider that
// declares nothing still receives the two-message wire, so the fix cannot have changed what every
// other provider in the stack sees.
func TestMultiSystemEndpointIsUnchangedOnBothWaves(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
}
return "Судзуки пошёл в тихую библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
for _, body := range rec.all() {
if sys := systemMessagesOf(t, body); len(sys) != 2 {
t.Fatalf("an undeclared endpoint must keep the two-message wire, got %d: %+v", len(sys), sys)
}
}
}

View file

@ -485,7 +485,9 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
memberFlagged = true
memberFlagReason = d.flagReason
}
continue // the flagged member is DROPPED from the edit — not skipped-whole-unit (c-lite)
out.DroppedMembers++ // counted for EVERY drop, including when the edit itself flags too
out.DroppedReason = memberFlagReason // the HOLE's cause, which out.FlagReason may not be
continue // the flagged member is DROPPED from the edit — not skipped-whole-unit (c-lite)
}
cleanSources = append(cleanSources, m.Text)
draftParts = append(draftParts, d.finalText)

File diff suppressed because one or more lines are too long

View file

@ -22,7 +22,7 @@
| Роль | Активный промт | Статус |
|---|---|---|
| Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE |
| Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193) · движковая идемпотентность повтора документа (§3.3). Состав — §3 промта. ⚠ **Второй рубеж ПРОТУХ:** опровергатель отработал (12 находок, включая вырожденный тест самопроверки) и лёг коммитом `3a65989`, но ПОСЛЕ него в промт добавлены три вещи, которых он не видел — §3.3 целиком (`9fd1d3e`), четвёртая дыра со своей границей и предупреждение о столкновении с `201` (`2c1fa9a`). Исполнителю это сказано прямо; его право отказа по §9 несёт этот рубеж. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
| Бэкенд | активного НЕТ | пак «тихая порча» ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.164**): инъекция банка больше не теряется на провайдере с одним системным слотом, дыра выдачи видна читателю В ТЕКСТЕ, повтор принятого документа сходится. Промт — в `archive/prompts/`. ⚠ Заказ §3.2 был ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя (маркер существовал и врал), а подсказка промта про место правки ОТКЛОНЕНА им с грунтом — оба решения ратифицированы. Свободные строки зоны: **228** (алиас возвращает отклонённую поверхность), **230** (размен сходимости), **141**-остаток, **131**. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
| Платформа | активного НЕТ | пак **P9** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.162**): дверь правок банка смонтирована, ключи доехали, полоса стала сквозной вместе с каноном **0.6.0**. Промт — в `archive/prompts/`. ⚠ Открытым остался архитектурный стоп `PD-410` (платформа продаёт главы, движку идёт только `--ceiling-usd`) и продуктовый вопрос владельцу по `Н2`/`Н3`. Следующая работа зоны — по строкам **215**/**216** и регистру, запуск по слову владельца |
| Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) |
| Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале |

View file

@ -1,4 +1,4 @@
# Реестр D-нот — карта актуальности v2 (D1D39.163;
# Реестр D-нот — карта актуальности v2 (D1D39.164;
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
@ -224,4 +224,5 @@
| D39.161 | 27.08 | Контрактный минор **0.5.0** принят и заленджен: отменённая пер-термная модель снесена из канона и компаньона, дверь `POST …/bank/corrections` выведена из словаря `bank-apply` поле в поле, признак «не построено» машиночитаем, счётчики упразднённой модели сняты. Константа платформы поднята тем же коммитом — батарея зоны 18/EXIT=0. Ошибка промта про «поля навсегда нули» найдена исполнителем и вынесена `PD-399`. | ✅ |
| D39.162 | 28.08 | **Платформенный пак P9 принят и заленджен + контрактный минор 0.6.0**: дверь правок банка смонтирована синхронно, ключи доехали `--keys-file` (строка 211 закрыта), дубль конвенции пути снят (213 сужена), полоса стала СКВОЗНОЙ вместе с каноном, `PD-399` снят. Батарея пере-прогнана приёмкой с живым Postgres (793/18/0/0). Две мои диспозиции сессия опровергла исполнением, я принял; одну её рекомендацию (закрыть 186) отклонил — жив остаток. Р3 — архитектурный стоп `PD-410`, канон НЕ смягчён. | ✅ |
| D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ |
| D39.164 | 28.08 | **Бэкенд-пак «тихая порча» принят и заленджен**: ось `SystemMessages` склеивает системный ряд у провайдера с одним слотом (инъекция банка перестала теряться), дыра выдачи видна в потоке текста, порядок записи решений сменён на rejects-first и повтор документа сходится. Приёмка — четырьмя посадками оркестратора, все пойманы топично. Заказ §3.2 пере-формулирован находкой исполнителя (маркер существовал и ВРАЛ), подсказка промта про место правки отклонена с грунтом и это ратифицировано. Строки 208 и 193 закрыты, 141 наполовину; заведены 228/229/230. | ✅ |

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.163 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
# Журнал решений оркестратора — контракт D1D39.164 (живой файл: карта · эрраты · живые тела · голова 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+).
@ -906,3 +906,86 @@
**Что этой нотой НЕ разрешено:** третьего исключения нет, и «раз уж есть два» аргументом не будет.
Каждое следующее — отдельная нота с собственным ограничением, иначе граница перестанет быть границей.
## D39.164 — БЭКЕНД-ПАК «ТИХАЯ ПОРЧА» ПРИНЯТ И ЗАЛЕНДЖЕН: инъекция банка больше не теряется, дыра выдачи видна читателю, повтор документа сходится (28.08, оркестратор №19). ✅
**Три пункта, все закрыты.** §3.1 — новая ось `llm.Capability.SystemMessages` (`multi`|`single`);
под `single` ведущий системный ряд СКЛЕИВАЕТСЯ в одно сообщение, системное после не-системного хода
отказывается ГРОМКО, а под `multi` (дефолт) отображение остаётся байт-равным прежнему проводу. §3.2 —
дыра выдачи видна В ПОТОКЕ ТЕКСТА обоих рендеров. §3.3 — порядок записи документов решений сменён на
REJECTS-FIRST, и повтор принятого документа сходится вместо отказа.
**Приёмка — четырьмя МОИМИ посадками мутаций, не чтением отчёта.** Каждая на резервной копии файла,
каждая восстановлена побайтно (git не трогался: дерево грязное, рядом живёт чужая незакоммиченная
работа).
1. «ось игнорируется, склейки нет» → падают `internal/llm` (с текстом «a system message after a
non-system turn must be refused») и `internal/pipeline`обе волны;
2. «снят гард непустого текста у маркера» → падают `TestExportPlaintextStateMatrix` и
`TestTranslateStateMatrix`;
3. «порядок возвращён к delta-first» → падают `TestHalfLandedIsNamedPerFile` и
`TestTheRenameOrderIsWhatMakesADeclineRecover`;
4. «боевое объявление квирка снято из `models.yaml`» → падает
`TestShippedGeminiDeclaresOneSystemMessage` — гейт, которого до пака НЕ БЫЛО ВОВСЕ (удаление двух
строк боевого конфига оставляло модуль зелёным; самонаходка сессии).
**Жёсткие ограничения промта проверены отдельно и держатся:** `gapMarker` живёт ТОЛЬКО в
`cmd/tmctl/render.go`, то есть маркер — проекция выгрузки, а не мутация финального текста;
присвоение `FinalText` в `export.go` не тронуто; Anthropic-путь не тронут ни одной строкой;
`exportVersion` не двигался; `dropped_members` аддитивно и `omitempty` — ось детерминизма чиста.
**Заказ §3.2 ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя, и это удешевило пак.** Маркер существовал и
ВРАЛ: ветка «флагнут и с текстом» печатала «(leak cleaned, verify)» ЛЮБОМУ такому юниту, а c-lite
member-drop попадал ровно в неё — читателю сообщали про косметическую чистку, которой не было.
Предмет стал «перестать врать и покрыть непокрытое» вместо «завести маркер».
**Подсказка МОЕГО промта про место правки была ОПАСНА, и исполнитель её отклонил с грунтом.** Я
указывал `export.go` рядом с `ApplyHeading`; туда нельзя — провод судит `translated`/`withheld`
предикатом «текст непуст», и маркер в пустом юните перевернул бы `withheld``translated`, то есть
ровно ⛔-запрет того же промта. `ApplyHeading` этого не делает лишь потому, что на пустом тексте он
no-op. Различение, которое исполнитель положил в основу и которое я ратифицирую: **титул — часть
КНИГИ и обязан ехать на провод; маркер дыры — метаданные О тексте, и в поле, по которому провод
судит, ему нельзя.**
**Изменённый тест — НЕ подгонка под зелень, проверено диффом и посадкой.**
`TestHalfLandedIsNamedPerFile` кодировал в фикстуре порядок переименований, который заказ §3.3
изменил. Фикстура пере-нацелена с пути rejects на путь delta; утверждаемое — «легла РОВНО одна
половина, это ПРОВЕРЕННЫЕ байты, отчёт называет какая» — сохранено дословно и по числу условий, а сам
порядок вынесен в новый `TestTheRenameOrderIsWhatMakesADeclineRecover` с причиной. Моя посадка №3
роняет ОБА теста. Исполнитель объявил правку САМ и первой строкой — это довод в его пользу, а не
подозрение.
**Опровергатель окупился, и это главное свойство пака.** Шесть линз, тридцать находок, двадцать
четыре доказаны исполнением; шесть настоящих дефектов — и ЧЕТЫРЕ из них внёс сам пак. Один
показателен: первая редакция ветки маркера срабатывала без проверки непустого текста, то есть юнит с
выпавшими ВСЕМИ членами получил бы баннер «фрагмент отсутствует НИЖЕ», указывающий на несуществующий
текст — одна ложь читателю едва не заменилась другой.
**Право §9 применено трижды и один раз ОШИБОЧНО — исполнителем же и названо.** Отклонены: Д3 (символ
живёт в `platform/`, движковая половина здорова — порядок «карта → память» исполнен), Д1 как отдельный
предмет, подсказка про место маркера. Ошибочно: «вторую дверь не трогаю, это устаревший документ, а не
ретрай» — опровергатель показал траекторию из одних принятых вызовов, рассуждение было неверным, дверь
починена.
**Хард-блокер, найденный и снятый, — он достался бы следующей сессии как «сломанная сборка».**
`go test ./cmd/tmctl/` строил бинарь во временном каталоге и НИКОГДА его не убирал: накопилось 249
брошенных каталогов на ~5 ГБ, `/tmp` заполнился, батарея под `-race` начала падать с «no space left on
device». Починено `TestMain`, проверено двумя прогонами подряд с нулевым приростом.
**Строки бэклога.** ЗАКРЫТЫ **208** (эскалационный хоп терял инъекцию) и **193** (дыры выдачи молчат
в тексте). **141** — половина закрыта: сноска С ханьцзы теперь видна читателю тем же маркером
бесплатно; остаток — правка самого детектора, и он НЕ про потерю текста. Якорь **131** исправлен
приёмкой (`memory.go:512-515``:518-521`). Заведены **228** (алиас возвращает отклонённую
поверхность — узкая форма Д1), **229** (снапшот не фолдит модель внутренних гейтов) и **230**
(размен «сходимость против поучения»). Бэклог 175 → 176.
**Дыра снапшота (229) ЛАТЕНТНА, и это установлено замером, а не рассуждением:** гейта
`terminology` нет ни в одном конфиге репозитория, поэтому сегодня она не стоит денег. Лечить её этим
паком было бы неверно вдвойне — фолд гейт-моделей сдвигает хеши и обесценивает чекпойнты, то есть
починка латентного дефекта стоила бы пере-покупки.
**ОШИБКА ОРКЕСТРАТОРА при лендинге P9, названная здесь, чтобы не потерялась.** Коммит `58bae30`
унёс ~103 строки журнала бэкенд-сессии под МОИМ сообщением: мы писали в `docs/PROGRESS.md` в общем
окне read-modify-write, её предупреждение пришло, когда `git add` уже отработал. Содержимое ЦЕЛО (обе
стороны проверили независимо, код не затронут), потеряна только атрибуция. Историю не переписывал —
канон запрещает это, пока в дереве живут чужие незакоммиченные правки. **Механизм плохой, а не
человек:** зонный журнал — единственный файл, который две сессии правят одновременно, и защиты у него
нет никакой.