Land c-lite edit-unit blast-radius fix (D39.18): a flagged draft member is dropped and the editor runs over the clean remainder instead of blanking the whole unit; align export/status/quality read-models on the dropped member; no-flag path byte-identical (golden stable)

This commit is contained in:
Claude (backend session) 2026-07-19 21:40:44 +03:00
parent 1db62d41a3
commit 488c8d4368
6 changed files with 352 additions and 28 deletions

View file

@ -2,6 +2,7 @@ package pipeline
import (
"fmt"
"strings"
"textmachine/backend/internal/store"
)
@ -125,6 +126,18 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) {
return nil, err
}
units := r.outputUnits(manifest)
// c-lite (D39.17-fix): the wave editor edits the CLEAN members of a unit even when one member draft flagged
// — the edit row is DispOK and SHIPS the edited clean remainder, but the UNIT is flagged because a member
// was dropped. Mirror status.resolveChunkState: re-derive "a member draft flagged ⇒ the unit is flagged"
// from the member draft rows, so export/status/translate agree while the edited text still ships.
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
type droppedMember struct{ reason, detail string }
memberDrop := map[chunkKey]droppedMember{} // a flagged member draft's reason+detail, keyed by its draft chunk
for _, cs := range statuses {
if draftStageNames[cs.Stage] && cs.Disposition == string(DispFlagged) {
memberDrop[chunkKey{cs.Chapter, cs.ChunkIdx}] = droppedMember{cs.FlagReason, cs.Detail}
}
}
exp := &BookExport{BookID: r.Book.BookID, Chunks: []ChunkExport{}}
inManifest := map[chunkKey]bool{}
for _, u := range units {
@ -145,8 +158,35 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) {
if err != nil {
return nil, err
}
// c-lite: an ok edit row over a unit that DROPPED a flagged member is a flagged (incomplete) unit — mark
// it so (the first dropped member's reason AND detail, so the record is self-consistent, not the ok edit's
// blank detail), keeping ce.FinalText (the edited clean remainder). Mirrors status.resolveChunkState.
droppedAny := false
if ce.Disposition == string(DispOK) {
for _, m := range u.Members {
if dm, dropped := memberDrop[chunkKey{m.Chapter, m.ChunkIdx}]; dropped {
ce.Disposition = string(DispFlagged)
ce.FlagReason = dm.reason
ce.Detail = dm.detail
droppedAny = true
break
}
}
}
if pairs {
ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs)
if droppedAny {
// The target is the edit over the CLEAN members only — align the --pairs source to match (exclude
// the dropped members, byte-for-byte as runEditUnit's cleanSources join) so the DC1/DC2 FP-measure
// sees a real src↔target pair, not the dropped member's source terms with no target counterpart.
var clean []string
for _, m := range u.Members {
if _, dropped := memberDrop[chunkKey{m.Chapter, m.ChunkIdx}]; !dropped {
clean = append(clean, m.Text)
}
}
ce.Source = strings.Join(clean, unitJoinSeparator)
}
}
exp.Chunks = append(exp.Chunks, ce)
}
@ -167,7 +207,6 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) {
// wave means `translate` would --resnapshot (a draft-config change re-pins the draft wave and cascades
// to the edit via content-hash). Only meaningful when a wave's rows carry a single snapshot (a
// mid-book multi-snapshot drift is already visible in the per-row snapshot_id).
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
editStageNames := stageNameSet(r.waveStagesIndexed(waveEdit))
draftSnaps, editSnaps := map[string]bool{}, map[string]bool{}
for _, cs := range statuses {

View file

@ -120,6 +120,27 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true
}
// c-lite (D39.17-fix): the wave editor drops a flagged member and edits the clean remainder, so the leader
// edit row is DispOK even though a member's draft echoed/stripped. Re-derive the dropped member's reason per
// unit (like export/status) so the echo/strip RATES still count it — else a unit RECOVERED by editing the
// clean remainder would vanish from EchoRate/CosmeticStripRate the owner reads for the acceptance planka.
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
memberDraftFlag := map[chunkKey]string{}
for _, cs := range statuses {
if draftStageNames[cs.Stage] && cs.Disposition == string(DispFlagged) {
memberDraftFlag[chunkKey{cs.Chapter, cs.ChunkIdx}] = cs.FlagReason
}
}
droppedReasonByLeader := map[chunkKey]string{}
for _, u := range units {
for _, m := range u.Members {
if reason, dropped := memberDraftFlag[chunkKey{m.Chapter, m.ChunkIdx}]; dropped {
droppedReasonByLeader[chunkKey{u.Chapter, u.FirstChunkIdx}] = reason
break
}
}
}
rep := &QualityReport{BookID: r.Book.BookID, TotalChunks: len(units)}
byChunk := map[chunkKey]*ChunkQuality{}
order := []chunkKey{}
@ -195,6 +216,18 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
if cs.FlagReason == string(FlagSanitizerStripped) {
rep.CosmeticStripChunks++ // a stripped chunk carried a markdown OR CJK cosmetic leak (F6)
}
// c-lite: a DispOK leader edit row whose unit dropped a member counts toward the same rates by the
// dropped member's reason — the echo/strip happened in a real (dropped) draft, recovered by editing the
// rest. Disjoint from the two checks above (they need a flagged edit row; this needs a DispOK one).
if cs.Disposition == string(DispOK) {
switch droppedReasonByLeader[k] {
case string(FlagCJKArtifact):
rep.EchoChunks++
chunkOf(k)
case string(FlagSanitizerStripped):
rep.CosmeticStripChunks++
}
}
// Structural KPI: recompute over the exported final text (ok, or the cosmetic-stripped export).
if cs.FinalHash == "" {
continue

View file

@ -165,8 +165,13 @@ func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) (st ChunkState
case string(DispOK):
ok++
case string(DispFlagged):
// The first (only) flagged stage decides the chunk; keep its reason.
st, reason = ChunkFlagged, cs.FlagReason
// The FIRST flagged stage decides the chunk; keep its reason (the rows arrive in member/stage order
// with the leader bucket first, so first-wins aligns status with translate/export, which both take
// the first flag — a member drop's reason, or the edit's own when the edit flagged). Without the
// guard the loop's last-flagged row would win, diverging from the other read-models on a multi-flag unit.
if st != ChunkFlagged {
st, reason = ChunkFlagged, cs.FlagReason
}
case string(DispSkipped):
skipped++
}

View file

@ -370,21 +370,26 @@ func (r *Runner) runDraftChunk(ctx context.Context, draftSnapshot string, ch Chu
// runEditUnit runs the editor stage(s) over one edit unit under the edit-wave snapshot (plan §1/§2). It reads
// the unit's draft as the concatenation of its member chunks' draft outputs (NEVER re-rendering the draft
// stage — the несущий invariant), does a FRESH memory.Select over the WHOLE-unit source over the ENRICHED
// bank (sticky degenerate at unit scope → nil), and runs the editor over (unit source, unit draft). A unit whose
// ANY member draft flagged is flagged (skip edit — no paid edit over a partial draft, D2 flag+skip); the
// good members' draft-wave spend stays honestly committed. Post-check + cheap gates run over the edit output at
// unit granularity, merged into the LEADER chunk's retrieval_state row.
// stage — the несущий invariant), does a FRESH memory.Select over the unit source over the ENRICHED bank
// (sticky degenerate at unit scope → nil), and runs the editor over (unit source, unit draft). Post-check +
// cheap gates run over the edit output at unit granularity, merged into the LEADER chunk's retrieval_state row.
//
// A flagged member draft is DROPPED from the edit, not blanked-whole-unit (c-lite, D39.17-fix): the editor
// still runs over the CLEAN members' source+draft, so the good (already-paid) drafts are edited and shipped —
// only when EVERY member flagged does the unit ship "". The edit row records the edit's honest outcome; the
// UNIT is flagged whenever a member dropped (or the edit itself flagged), a fact the export/status read-models
// re-derive from the member draft rows (status.resolveChunkState) so all three agree. The pre-c-lite behaviour
// (any flagged member → whole unit blank) discarded a chapter-scale unit + its paid sibling drafts over one bad
// chunk — a regression from the retired per-chunk model, closed here.
func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit editUnit, draftByKey map[chunkKey]stageSeqResult, editStages []wavedStage) (*ChunkOutcome, error) {
leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Text: unit.sourceText()}
out := &ChunkOutcome{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Disposition: DispOK}
var draftStages []StageResult
var draftCost float64
var draftParts []string
var cleanSources []string // the non-flagged members' source (the editor's {{text}}, aligned to the clean draft)
var draftParts []string // the non-flagged members' draft (the editor's input)
memberFlagged := false
var memberFlagReason FlagReason
memberRecovered := ""
for _, m := range unit.Members {
d, ok := draftByKey[chunkKey{m.Chapter, m.ChunkIdx}]
if !ok {
@ -396,17 +401,18 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
if !memberFlagged {
memberFlagged = true
memberFlagReason = d.flagReason
memberRecovered = d.recovered
}
continue
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)
}
out.CostUSD = draftCost
if memberFlagged {
// A member draft flagged → the unit's edit is skipped (no paid edit over a partial draft). Record a
// skipped edit chunk_status at the leader and export the recovered draft (or "" for a dropped flag).
// Only when EVERY member draft flagged is there nothing clean to edit → skip the edit and ship "" (the whole
// unit is unusable). Record a skipped edit chunk_status at the leader so the read-models see a flagged unit.
if len(draftParts) == 0 {
leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx}
skipped, err := r.recordSkippedStages(ctx, editStages, editSnapshot, leader, memberFlagReason)
if err != nil {
return nil, err
@ -414,10 +420,14 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
out.Stages = append(draftStages, skipped...)
out.Disposition = DispFlagged
out.FlagReason = memberFlagReason
out.FinalText = exportNormalize(memberRecovered)
out.FinalText = ""
return out, nil
}
// The edit is ADDRESSED at the manifest leader (chapter, FirstChunkIdx — where the read-models look for the
// unit's final row) but its SOURCE + DRAFT are the CLEAN members only. With NO flagged member cleanSources
// join == unit.sourceText() byte-for-byte, so the normal path (and the golden) is unchanged.
leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Text: strings.Join(cleanSources, unitJoinSeparator)}
unitDraft := strings.Join(draftParts, unitJoinSeparator)
var editSel memorySelection
injectionByRole := map[string]string{}
@ -467,11 +477,18 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
}
}
if flagged {
// Unit disposition: the EDIT itself flagged → ship its recovered/""; else a MEMBER dropped → still ship the
// edited clean remainder, flagged for review (c-lite); else clean → ship the edit output ok.
switch {
case flagged:
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = exportNormalize(recovered)
} else {
case memberFlagged:
out.Disposition = DispFlagged
out.FlagReason = memberFlagReason
out.FinalText = exportNormalize(finalText)
default:
out.FinalText = exportNormalize(finalText)
}
return out, nil

View file

@ -194,9 +194,10 @@ func TestWaveParallelWorkersMoneyConserved(t *testing.T) {
}
}
// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the whole unit is
// flagged and its edit is SKIPPED (no paid edit over a partial draft), while the good member's draft money
// stays committed. There is exactly ONE edit call fewer than a clean run.
// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the flagged member is
// DROPPED but the editor STILL runs over the CLEAN member(s) (c-lite) — the good, already-paid draft is edited
// and shipped, the UNIT is flagged for review (the dropped member's reason), the good member's draft money
// stays committed, and export agrees. Regression guard: the pre-c-lite code blanked the whole chapter-scale unit.
func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) {
rec := &reqRec{}
// The second paragraph carries a marker; the mock returns an untranslated CJK echo for it → cjk_artifact.
@ -217,7 +218,6 @@ func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) {
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
@ -226,15 +226,91 @@ func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) {
t.Fatalf("still one unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
// The unit is FLAGGED (a member dropped) with the dropped member's reason — but it SHIPS the edited clean
// remainder, not "" (c-lite: don't blank a chapter over one bad chunk).
if oc.Disposition != DispFlagged || oc.FlagReason != FlagCJKArtifact {
t.Fatalf("a flagged member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason)
t.Fatalf("a dropped member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason)
}
if oc.FinalText != "" {
t.Fatalf("a flagged unit exports nothing, got %q", oc.FinalText)
if oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("the unit must ship the edited CLEAN remainder, got %q", oc.FinalText)
}
// 2 draft calls (one ok, one echo), ZERO edit calls — the unit's edit was skipped over the partial draft.
// 2 draft calls (one ok, one echo) + 1 edit over the CLEAN member — the flagged member was dropped, not the
// whole unit skipped.
if rec.count() != 3 {
t.Fatalf("a dropped-member unit runs 2 drafts + 1 clean edit, got %d calls", rec.count())
}
// The editor read ONLY the clean member's draft — never the echoed member.
var editBody string
for _, b := range rec.all() {
if isEditBody(b) {
editBody = b
}
}
if strings.Count(editBody, "ЧЕРНОВИК ПЕРЕВОДА") != 1 || strings.Contains(editBody, "完全没有翻译") {
t.Fatalf("the edit must carry ONLY the clean member's draft (not the dropped echo)")
}
r.Close()
// Export agrees: the unit is flagged (a member dropped) yet SHIPS the edited clean remainder.
r2 := newRunner(t, bookPath)
defer r2.Close()
exp, err := r2.Export(false)
if err != nil {
t.Fatal(err)
}
if len(exp.Chunks) != 1 {
t.Fatalf("export must project one unit, got %d", len(exp.Chunks))
}
if exp.Chunks[0].Disposition != string(DispFlagged) || exp.Chunks[0].FlagReason != string(FlagCJKArtifact) {
t.Fatalf("export unit must be flagged (dropped member), got %s/%s", exp.Chunks[0].Disposition, exp.Chunks[0].FlagReason)
}
if exp.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("export must ship the edited clean remainder for a dropped-member unit, got %q", exp.Chunks[0].FinalText)
}
// The dropped member's echo must NOT vanish from the quality telemetry the owner reads for acceptance
// (c-lite makes the leader edit row DispOK; quality re-derives the drop like export/status).
q, err := r2.QualityReport()
if err != nil {
t.Fatal(err)
}
if q.EchoChunks != 1 {
t.Fatalf("the dropped member's echo must still count in QualityReport (EchoRate not silently regressed), got EchoChunks=%d", q.EchoChunks)
}
}
// TestWaveEditUnitAllMembersFlagged: when EVERY member draft of a multi-chunk unit flags there is nothing clean
// to edit — the unit ships "" and runs ZERO edit calls (the one case c-lite still blanks a unit).
func TestWaveEditUnitAllMembersFlagged(t *testing.T) {
rec := &reqRec{}
const echoMarker = "禁"
respond := func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "这是完全没有翻译的中文内容。", "stop" // every draft echoes → cjk_artifact
}
srv := newJSONProvider(rec, respond)
defer srv.Close()
src := strings.Repeat(echoMarker, 1400) + "。\n\n" + strings.Repeat(echoMarker, 1100) + "。"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("one unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.Disposition != DispFlagged || oc.FinalText != "" {
t.Fatalf("an all-flagged unit ships nothing, got %s / %q", oc.Disposition, oc.FinalText)
}
// 2 draft calls (both echo), ZERO edit calls — nothing clean to edit.
if rec.count() != 2 {
t.Fatalf("a partial-draft unit runs 2 drafts + 0 edit, got %d calls", rec.count())
t.Fatalf("an all-flagged unit runs 2 drafts + 0 edit, got %d calls", rec.count())
}
}

View file

@ -0,0 +1,154 @@
# R1 wave-driver — post-landing review (bugs + concurrency + functional)
**Session:** backend, 2026-07-19, on top of the landed R1 driver-switch (`89d5bf9`).
**Ask (owner):** a clean functional + concurrency-bug look at the wave feature — "what happens when a book is
loaded into the backend" — plus three owner findings to fold in (R1-FL-A/B/C).
**Method:** own source trace + **4 independent adversarial reviewers** (author≠reviewer) on distinct lenses
(money · store/SQLite · wave-logic · end-to-end functional), then a **verification workflow** (3 lenses +
synthesis) over the fix I wrote. Every reviewer finding was re-verified against source before acting.
**Status:** two LIVE fixes applied to the working tree (UNCOMMITTED — orchestrator lands); R1-FL-A/B/C deferred
to the pere-run mining batch per owner. `go build/vet`, full `go test -race ./...`, golden byte-stable.
---
## 1. Concurrency verdict — SOUND (3 reviewers + trace agree)
No money-correctness, budget, resume, or data-race defect on the concurrency axis. Confirmed at `file:line`:
- **Money atomic under the single writer.** `Reserve`/`SettleWithCheckpoint` are single `BEGIN IMMEDIATE`
transactions on the 1-connection write pool (`store.go:71`, `ledger.go:50`) → `committed == Σ settled`; two
parallel reserves fully serialize (no ceiling skip). Settle uses a fresh `opContext` (not the wave ctx), so a
cancelling wave still persists a paid 2xx; a `kill-9` loses ≤ N in-flight, each re-paid once (`recoverReservations`).
- **Escalation soft-cap exact.** `escMu` is held across the fresh hop's settle (`escalation.go:119-128`); the next
worker's read-pool `EscalationSpentUSD` sees the committed hop (WAL read-after-commit across the mutex) →
overshoot ≤ 1 hop.
- **No reservation leak**: every post-`Reserve` exit settles or releases (`stagerun.go:377/382/393/422/443/483`).
- **Shared chapter×stage job row is advisory-only** (`EnsureJob` keyed `(book,chapter,stage)`): `chunk_status`/
checkpoints are the resume authority; `jobs.status` thrash is telemetry, nothing gates on it.
- **`retrieval_state` RMW race-free**: each unit owns a distinct leader `(chapter,FirstChunkIdx)`; waves are
sequential (barrier at `wg.Wait()`).
- **No shared mutable Runner field written during a wave**; `MemoryBank.Select/postcheck` are pure reads over
immutable state; `roleInjectionRenderers` read-only; `ReqInfo` value-typed (no ctx aliasing).
- **«переоплата ОДНА» airtight at BOTH levels**: the draft injection selects over `r.baseMemory` (Source≠mined)
and the draft snapshot folds `r.memory.BaseVersion()` (same mined-exclusion) — a mining sign moves only the
edit snapshot.
---
## 2. Bug FIXED — `runWave` swallowed parent cancellation (the one real defect, live)
The single concurrency bug all reviewers-but-one missed on the first pass — the wave-logic lens caught it.
`runWave` (`waverun.go`) set `firstErr` **only** from a worker error and returned it bare after `wg.Wait()`
no `parent.Err()` check. The feeder drops items on `ctx.Done()`, and a worker need not error to finish (a **$0
resume** worker resolves entirely through `opContext()` store reads, never touching the cancellable ctx). So a
**Ctrl-C / SIGTERM** (`main.go:55` `signal.NotifyContext`) whose in-flight workers don't error → `runWave`
returns `nil` with items undone →
- **edit pipeline (prod C1 default):** `unitOutcomes[i]==nil``*oc` **nil-deref panic**;
- **draft-only:** zero-value → fake `DispOK ""`**exit 0 on an incomplete book**.
Reliably hits on a Ctrl-C'd resume. The durable store was always correct (undone items never checkpointed → a
re-run resumes), so no data/money loss — the damage is a crash / a false success signal.
**Fix:** `return parent.Err()` after `wg.Wait()` when `firstErr==nil`. **Proven by execution:**
`TestRunWaveSurfacesParentCancellation` fails red on the old code, green on the fix.
---
## 3. Owner-escalated DECISION → implemented — edit-unit flag blast radius (c-lite)
**The live behavioural consequence of the chunk→unit shift.** Pre-fix, in `runEditUnit` one flagged draft member
skipped the whole unit's edit and shipped `exportNormalize(memberRecovered)` — always `""` for a draft stage
(the sanitizer only fills `recovered` on `isFinal`). So a single bad draft chunk **blanked a whole chapter-scale
unit**, discarding the good, already-paid sibling drafts — a regression from the retired per-chunk model.
**Chosen (owner deferred the call; owner leaned toward option c):** **edit-clean-remainder** ("c-lite"). A flagged
member is DROPPED; the editor runs over the CLEAN members' source+draft, addressed at the manifest leader; the
unit SHIPS the edited clean remainder but is FLAGGED (a member dropped). Only an all-members-flagged unit ships "".
**Why c-lite over a full positional split:** any "ship edited content" option forces export to become member-aware
(today's contract is "flagged unit → edit row non-OK → ships nothing"). A **full split** (N sub-edits with in-place
gaps) additionally forces every read-model to reconstruct the sub-run partition from stored draft dispositions and
makes `status`'s `expected`-stage-count **flag-dependent** — the fragile surface where read-model bugs breed.
c-lite keeps ONE edit row per unit (edit row stays DispOK → resume unchanged), so `status` and the golden are
untouched. **Byte-identical to a full split whenever the flagged member is at a unit edge** (the common case);
differs only for a mid-unit flag (flanking clean pieces edited as one joined block, not positioned sub-edits) — a
negligible artifact on a rare, human-reviewed flagged unit. **One cost:** redriving a dropped member later
re-edits over the now-complete unit (one rare wasted editor call).
**Files:** `runEditUnit` (drop flagged members, edit clean remainder, ship flagged); `export.go` re-derives
"dropped member ⇒ flagged unit" from member draft rows so export/status/translate agree while the text ships;
`status`/golden untouched (the no-flag path is byte-identical → cleanSources join == `unit.sourceText()`).
**Tests:** dropped-member → edits the clean remainder + ships it flagged (export agrees); all-members-flagged →
ships "".
### 3.1 Verification-workflow findings closed (all MINOR/NIT telemetry — no bytes/money/resume impact)
The 3-lens verification of c-lite found the core sound (no CRITICAL/MAJOR) and four consistency items, all fixed:
1. **quality.go echo/KPI (MINOR — the one un-aligned read-model, an acceptance metric):** a recovered unit's edit
row is now DispOK, so `EchoRate`/`CosmeticStripRate` stopped counting its (real, dropped) draft echo. **Fixed:**
quality re-derives the drop like export/status and counts the dropped member's reason toward the rates. The
structural KPI intentionally measures the shipped remainder (accurate). *Owner nuance:* this preserves the
pre-c-lite semantics ("echo anywhere, incl. dropped members"); if you'd rather `EchoRate` mean "echo in shipped
text only", say so and it's a one-line revert of this preservation.
2. **status.go flag_reason first-vs-last (NIT):** `resolveChunkState` overwrote `reason` on every flagged row
(last-wins), contradicting its own "first (only)" comment and diverging from translate/export on a multi-flag
unit. **Fixed:** first-wins guard (aligns all read-models; also closes the pre-existing plain-multi-member case).
3. **export --pairs source (NIT):** `--pairs` source was the full unit source while the target is the clean-members
edit, mis-pairing the DC1/DC2 FP-measure. **Fixed:** for a dropped-member unit the `--pairs` source is
reconstructed from the clean members (byte-for-byte as `runEditUnit`'s `cleanSources`).
4. **export Detail (NIT):** the member-drop override flipped `FlagReason` but kept the OK edit's blank `Detail`.
**Fixed:** propagate the dropped member's `Detail` alongside its reason.
---
## 4. Owner findings R1-FL-A/B/C — confirmed, DEFERRED to the pere-run mining batch
All three reproduce exactly and are gated behind LIVE mining (a langpack + a contrast artifact — stand-only). Per
owner: apply in the pere-run-prep batch together, NOT as a micro-commit.
- **R1-FL-A** (`main.go:34-44`): `exitCode` maps only `*CompletedWithFlags`→2; a `*WaveSignatureStop`→**1** (== a
crash). The `translate()` path returns it raw with no `errors.As` arm; the `waverun.go:72-77` comment over-claims
CLI wiring. Human sees the stderr text; automation can't distinguish a sign-boundary stop from an infra crash.
*Fix shape:* an `errors.As(&sigStop)` arm → a distinct exit code.
- **R1-FL-B** (`mining.go:56`, `miner_emit.go`): the mining STOP clears only on an EMPTY delta and there is no
reject/ignore list — a term the owner declines to sign is re-mined → STOP forever (livelock). The only mechanical
escape (undocumented, semantically wrong) is a `status:auto` no-dst seed row. Ties to the deferred **W1.5-UX**
design (a reject/suppression set).
- **R1-FL-C**: `x/text/norm` carries its own Unicode revision, not folded into Ш-2 (only `unicode.Version` is) — a
real tripwire before an `x/text` bump.
---
## 5. Reviewed — not bugs (NITs, for awareness)
- `jobs.status` thrash under the shared chapter×stage job row — advisory, nothing gates on it.
- Read-model fields say `chunk`/`total_chunks` but now carry UNIT counts — naming/contract debt for JSON
consumers (counts are internally correct).
- `escMu` held across the escalation LLM call — throughput smell (rare, opt-in escalations), no correctness impact.
- `minedToCandidates` is dead in the live path (superseded by file-based `loadMinedDelta`) — cleanliness.
- The real HTTP adapter's concurrency is unexercised by the fake-client tests — worth one live parallel smoke
(standard `http.Client` is safe by design).
---
## 6. Change set (working tree, UNCOMMITTED — orchestrator lands)
LIVE wave-driver correctness (NOT mining-gated) — land these:
- `waverun.go`: `runWave` parent-cancellation → error; `runEditUnit` c-lite (edit the clean remainder).
- `export.go`: member-drop re-derivation + Detail + `--pairs` clean-source alignment.
- `status.go`: `resolveChunkState` first-wins guard.
- `quality.go`: echo/strip preservation for a recovered unit.
- `waverun_test.go`: `TestRunWaveSurfacesParentCancellation`, updated `TestWaveEditUnitFlaggedMemberDraft`
(c-lite + export + quality assertions), `TestWaveEditUnitAllMembersFlagged`.
Evidence: `go build/vet` clean; full `go test -race ./...` clean (~31s); golden byte-stable (no-flag path
identical); gofmt clean. Nothing committed (orchestrator lands per the standing workflow).
## 7. Open for the owner
- **Echo-metric semantics** (finding 3.1-1): kept as "echo anywhere incl. dropped members" (non-regression). Switch
to "shipped-text only" if preferred.
- **R1-FL-A/B/C** land in the pere-run mining batch; R1-FL-B needs the W1.5-UX reject-set design.
- Landing order: the two LIVE fixes (§6) can land before the mining batch.