textmachine/backend/internal/pipeline/driftbasis.go

93 lines
5 KiB
Go

package pipeline
import "textmachine/backend/internal/store"
// driftbasis.go: the config-drift verdict, and the BASIS that says what the verdict is a verdict OF.
//
// THE DEFECT THIS CLOSES. `config_drift` is a BOOLEAN over a THREE-valued fact. `false` means both «the
// stored rows match the current config» and «the check could not run», and the two are opposite
// instructions to whoever reads them. Both surfaces already knew it and neither could say it:
// export.go logged «drift state unknown (reported as none)» — the one place in the engine where that
// sentence appears next to a field that then reports none, while four sibling surfaces say «unknown, not
// zero» and mean it (quality.go, status.go's unsigned-term count, bookbuild.go's stale check).
//
// It is not a silent field either: `build` GATES ON IT. staleUnits opens with `if exp.ConfigDrift {
// return nil, true }`, so a failed drift check that reports `false` makes build compute staleness as
// though the config were clean and publish `stale: 0` over an unknown state — a field WITHOUT a basis
// corrupting the field next to it that HAS one. That propagation is why this is not cosmetic.
//
// The form is the zone's own, not a new invention: RebillBasis (status.go) already publishes «what are
// these figures a projection of», down to a `failed` value whose doc says «the figures are zero because
// they are UNKNOWN». This is that discipline applied to the second figure that needed it.
// Ratified with the disclosure law, D39.181 п.3.
// The values of the config-drift basis. A reader that understands only the boolean is unaffected; a reader
// that wants to know whether `false` is an answer looks here.
const (
// DriftBasisNone — the check RAN and the stored rows are resolved under the snapshot the current
// config renders. `config_drift:false` is an ANSWER.
DriftBasisNone = "none"
// DriftBasisDrift — the check ran and found a difference. `config_drift:true`.
DriftBasisDrift = "drift"
// DriftBasisUnknown — the check could NOT run (the bank could not be materialized, a wave snapshot
// could not be rendered, a re-pricing the check depends on failed). `config_drift:false` is then NOT
// an answer, and a consumer must treat the book as un-judged rather than as clean. The reason is on
// the WARN log beside it.
DriftBasisUnknown = "unknown"
)
// driftCheckable is the ONE precondition both surfaces ask before they judge drift at all: are there
// stored rows carrying a snapshot to compare against.
//
// ⛔ IT EXISTS BECAUSE THE CURE REPRODUCED THE DISEASE. The first version let each surface decide for
// itself when the check was possible — `status` required stored rows, `export` assumed it could always
// run — and on ONE book with ONE config they answered `unknown` and `none`. That is backlog row 239
// exactly: two $0 read-models disagreeing about the same fact, this time inside the field built to stop
// it. Found by acceptance. A shared predicate is the only form that cannot drift.
func driftCheckable(statuses []store.ChunkStatus) bool {
for _, cs := range statuses {
if cs.SnapshotID != "" {
return true
}
}
return false
}
// driftBasisFor collapses the two facts a caller holds — did the check run, did it find drift — into the
// published word. One definition, because status and export must never disagree about what `false` means.
func driftBasisFor(ran, drifted bool) string {
switch {
case !ran:
return DriftBasisUnknown
case drifted:
return DriftBasisDrift
default:
return DriftBasisNone
}
}
// orphanStageRows reports whether the stored rows carry a stage the CURRENT pipeline does not run — a
// stage renamed or removed since the run — and names the first such stage.
//
// THIS IS THE RULE `export` HAD AND `status` DID NOT, and the divergence was reproduced word for word on
// the cold run: one config, one database, `status --json` answering config_drift=false with
// percent_done=85 while `export --json` on the SAME config answered true. The per-wave comparison both
// surfaces already did only compares the snapshots of stages that STILL EXIST, so dropping the editor
// stage makes the draft rows the shipping rows and nothing fires: an unedited book reads as complete.
//
// ⛔ WHAT IS DELIBERATELY *NOT* SHARED. The two surfaces fold the BANK differently — status folds the
// decision files because it prices the next run, export reads the stored glossary because it judges the
// document that exists — and export.go says at length that the divergence is deliberate and asks the next
// reader not to "fix" it. Only the orphan rule is lifted here, because only the orphan rule is the same
// question on both surfaces. Sorted lookup: the reported stage must not depend on map iteration order.
func orphanStageRows(statuses []store.ChunkStatus, draftNames, editNames map[string]bool) (stage string, found bool) {
for _, cs := range statuses {
if cs.SnapshotID == "" || draftNames[cs.Stage] || editNames[cs.Stage] {
continue
}
if !found || cs.Stage < stage {
stage, found = cs.Stage, true
}
}
return stage, found
}