textmachine/backend/internal/membank/decisions.go

1039 lines
52 KiB
Go

package membank
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/seed"
"textmachine/backend/internal/store"
"textmachine/backend/internal/text"
)
// decisions.go: the OWNER'S DECISIONS about the memory bank, as data the engine applies rather than as
// two files somebody edits in a text editor (D39.156, 17-seam-inbound-law п.1).
//
// The bank is the product's central value — one glossary of names and terms held consistent across a
// whole book — and until this door existed there was no way for anyone outside the engine to correct a
// term: the two files that carry a decision (mined_delta, mined_rejects) had no writer on ANY side, so
// every external participant had to impersonate a human operator with an editor. This file is the pure
// half of that door: current bank + current files + decisions → the files as they should be, or a list
// of refusals naming each one. No I/O, no clock, no randomness — the wiring (pipeline/bankdecisions.go)
// owns the lock, the store and the writes.
//
// TWO VERBS AND NO THIRD. `approve` promotes a term into the mined delta with an explicit
// `status: approved`; `decline` puts its surface on the reject list so the bank-mining stop stops
// re-proposing it. There is deliberately no "un-decide": a decision is REPLACED by the opposite one, and
// returning a term to the undecided state is not something the signing model has a use for.
// DecisionsVersion / DecisionsReportVersion version the SHAPE of the two documents this door speaks, so
// a caller that must tolerate the field set changing has something to branch on (17-seam-inbound-law
// п.3). Two constants and not one: the request and the report are different documents and are free to
// move at different times.
//
// report-v2: the `mode` vocabulary grew (`refused`, `write_incomplete` beside `apply`/`projection`/
// `stopped`) and the report carries per-file write truth (`written_delta`/`written_rejects`). The
// envelope exists to move when the shape does — leaving it at v1 would make it decoration.
const (
DecisionsVersion = "tm-bank-decisions-v1"
DecisionsReportVersion = "tm-bank-decisions-report-v2"
)
// DecisionDepth names how far into the pipeline an accepted decision reaches, and it is a FIELD rather
// than a comment because the answer is not the one a user assumes. A decision travels through the mined
// delta, which is Source:"mined" and therefore excluded from the BASE bank the draft wave selects over
// (pipeline/seeding.go) — so it reaches the EDITOR and does not re-form the draft. Making the draft
// itself carry a corrected term means moving glossary_seed, which shifts the base snapshot and re-pays
// the whole draft wave; that is a different door and it is not designed yet (backlog row 192).
const DecisionDepth = "edit_wave"
// The two actions.
const (
ActionApprove = "approve"
ActionDecline = "decline"
)
// Decision is one owner decision about one term.
//
// Its vocabulary is the PUBLISHED IDENTITY AND RENDERING of a bank row, minus the axes that have no
// producer, plus the one self-documentation field. Spelled out, because the shorter slogan "the input
// vocabulary is the output vocabulary" is not true of it and a later session correcting the code to
// match that slogan would break the door in both directions:
//
// - a term that EXISTS is named by the read-out's stable `id`; a term that does not is named by the
// full tuple that id is derived from. Those are the published identity;
// - `dst` and `kind` are the published rendering and classification — the two things a decision is
// actually about;
// - `note` is accepted and NOT published (bankexport.go carries no note field). It is the durable
// channel for the owner's reasoning, and it is what survives the file being re-rendered;
// - `aliases` are published and NOT accepted, deliberately: the alias set is the miner's own cluster
// and a decision has no opinion about it, so a promotion CARRIES IT FORWARD unchanged rather than
// inviting a caller to re-state it (see termFromBank);
// - `gender`, `speech` and `decl` are neither. They are seed-only with no automatic producer at all
// (backlog row 210), and their production fork is reserved — a door for them would be a door onto
// an axis that does not work, and would pre-empt a decision that has not been taken.
//
// ⚠ ONE NARROWNESS OF v1, named rather than hidden: a term's chapter WINDOW cannot be corrected in one
// decision. Approving the new tuple adds a second row and leaves the old one standing, and declining
// removes every window of the surface. Widening or moving a window is a two-call operation for now.
type Decision struct {
Action string `json:"action"`
// ID is the bank read-out's stable id of an EXISTING row. Mutually exclusive with the tuple below:
// accepting both would let a caller name two different terms in one decision and never learn which
// one the engine picked.
ID string `json:"id,omitempty"`
// Src / Sense / SinceChapter / UntilChapter are the term's uniqueness key — the tuple the id is
// derived from, and the form in which a term the bank does not have yet is added.
Src string `json:"src,omitempty"`
Sense string `json:"sense,omitempty"`
SinceChapter int `json:"since_chapter,omitempty"`
UntilChapter int `json:"until_chapter,omitempty"`
// Dst is the rendering. Required by `approve` and forbidden on `decline`: an approved term with no
// dst is not a signature, it is a load failure of the next run (memseed.go fails loud on it), and a
// WHICH-only surface can therefore only ever be declined.
Dst string `json:"dst,omitempty"`
// Kind is the engine's own `type` column (name|place|…), as the read-out publishes it. Absent means
// "not decided" and keeps whatever the term already carries.
Kind string `json:"kind,omitempty"`
// Note is the owner's reason, carried into whichever file the decision lands in so a list stays
// self-documenting.
//
// ABSENT means "not decided", like `kind`: a repeat of a decision from a screen that does not carry
// notes keeps the note the owner wrote the first time, in BOTH files. The cost is named rather than
// hidden — a note cannot be EMPTIED through this door, only replaced with other words, which is the
// same narrowness an alias has (see termFromBank). Erasing provenance by omission is the worse of
// the two, and it is the one a caller does by accident.
Note string `json:"note,omitempty"`
}
// DecisionsDoc is the request document.
type DecisionsDoc struct {
Version string `json:"decisions_version"`
// BookID is REQUIRED and checked against the book being opened. Decisions are the one input that
// carries a user's own words into a book's canon, and applying a set computed for another book is
// not an error anything downstream could notice.
BookID string `json:"book_id"`
Decisions []Decision `json:"decisions"`
}
// DecodeDecisions parses a request document. Strict about unknown fields for the same reason the CLI is
// strict about unknown flags (17-seam-inbound-law п.4): a field the engine does not know is a caller
// believing it set something, and the quiet version of that is a decision half-applied.
func DecodeDecisions(raw []byte) (DecisionsDoc, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var doc DecisionsDoc
if err := dec.Decode(&doc); err != nil {
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: %w", err)
}
if doc.Version != DecisionsVersion {
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: decisions_version is %q, this engine speaks %q", doc.Version, DecisionsVersion)
}
if doc.BookID == "" {
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: the document names no book_id")
}
if len(doc.Decisions) == 0 {
return DecisionsDoc{}, fmt.Errorf("membank: decode decisions: the document carries no decisions")
}
return doc, nil
}
// TermID is the stable id of a bank row, derived from its uniqueness key (src, sense, since_ch,
// until_ch) — NOT from glossary.id, which is a fresh autoincrement on every bank replace and would
// re-point under a consumer between two reads of the same unchanged term.
//
// The four parts are LENGTH-PREFIXED rather than separator-joined. The first version used U+001F and
// asserted in a comment that it "cannot occur in any of them" — an assumption about data the engine never
// validates: `src` and `sense` are free text copied from a seed YAML, and nothing on the load path
// rejects a control character. Length-prefixing makes the encoding injective by construction, so the
// claim does not have to be true (and does not have to be re-checked when a new seed source appears).
// 16 hex chars of SHA-256 is 64 bits, which for a bank of thousands of rows makes a collision an
// irrelevance rather than a risk taken on purpose.
func TermID(src, sense string, since, until int) string {
var b strings.Builder
for _, part := range []string{src, sense, strconv.Itoa(since), strconv.Itoa(until)} {
fmt.Fprintf(&b, "%d:%s", len(part), part)
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])[:16]
}
// termKey is a term's uniqueness key — the store's UNIQUE(book_id, src, sense, since_ch, until_ch).
type termKey struct {
Src, Sense string
Since, Until int
}
func keyOf(e store.GlossaryEntry) termKey {
return termKey{e.Src, e.Sense, e.SinceCh, e.UntilCh}
}
func (k termKey) id() string { return TermID(k.Src, k.Sense, k.Since, k.Until) }
// ApplyInput is everything the decision pass reads.
type ApplyInput struct {
// Bank is the book's bank as it stands — every row, every status. It is what the caller's ids were
// derived from, and it is read from the store rather than from the published read-out on purpose:
// that artifact is a projection and never a source (bankexport.go).
Bank []store.GlossaryEntry
// Seed is the entries of the book's glossary_seed, the owner's SIGNED base. Nil when the book has
// none. A term of the seed cannot be decided here — see the conflict rule in applyOne.
Seed []store.GlossaryEntry
// Voices / Pairs are the seed's D21 records. They are here for ONE check and it is not cosmetic: a
// voice or address row naming a character the bank does not have STOPS the run (seeding.go —
// "a profile for a term that does not exist can never fire"), and a seed may legitimately name a
// character whose TERM lives in the delta. Declining that term is then an accepted decision that
// kills the next run — reproduced before this field existed.
Voices []store.VoiceProfile
Pairs []store.AddressPair
// Ruby are the book's captured ruby readings, for the same reason and the same failure. The run
// GRAFTS every name-shaped reading onto its seed term as a firing alias (AttachRubyAliasesToManual)
// BEFORE it runs the collision check, so a seed term is reachable through surfaces the seed file
// never spells. A door that judged the seed file alone would accept a mined term whose src IS one of
// those readings and hand the next run a livelock — reproduced end to end before this field existed.
//
// These three fields are one rule, not three: the entry set this door judges must be the entry set
// seedGlossary builds, or "it would load" here means something narrower than it means there.
Ruby []store.RubyReading
// Delta / Rejects are the two decision files as they stand (zero values when they do not exist yet).
Delta seed.File
Rejects seed.RejectFile
Decisions []Decision
}
// AcceptedDecision is one decision the engine took, and what it displaced doing so.
type AcceptedDecision struct {
Index int `json:"index"`
Action string `json:"action"`
ID string `json:"id"`
Src string `json:"src"`
Dst string `json:"dst,omitempty"`
// State is `applied` or `already_applied`. The second is the whole of the idempotency contract as a
// caller sees it: 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.
State string `json:"state"`
// Replaced names what this decision displaced — a previous rendering, a previous decline. A
// decision is a REPLACEMENT, never a return to the undecided state, so what it overwrote has to be
// visible or the owner cannot see that they overwrote anything.
Replaced []string `json:"replaced,omitempty"`
}
// RejectedDecision is one refusal, by name.
type RejectedDecision struct {
// Index is the decision's position in the request, or -1 for a refusal about the RESULT as a whole
// (a document that would not load, a collision the set introduces).
Index int `json:"index"`
Action string `json:"action,omitempty"`
Src string `json:"src,omitempty"`
Reason string `json:"reason"`
}
// ApplyResult is the outcome of the pass.
type ApplyResult struct {
Delta seed.File
Rejects seed.RejectFile
// DeltaTouched / RejectsTouched say which DOCUMENT a decision actually changed. The caller writes
// only what was touched: a decline changes the reject list, and re-rendering the delta beside it
// would destroy an operator's formatting of a file this call had no opinion about.
DeltaTouched bool
RejectsTouched bool
// DeltaBytes / RejectBytes are the CANONICAL BYTES of the two resulting documents — the exact bytes
// the caller writes. Produced here rather than by the caller because they are also the bytes that
// were PROVEN readable (renderProved): a caller that rendered its own would be writing bytes nothing
// checked. Nil when the set was refused — there is then nothing to write — or when the document
// could not be rendered at all, which is itself one of the refusals.
DeltaBytes []byte
RejectBytes []byte
Accepted []AcceptedDecision
Rejected []RejectedDecision
// Preexisting are faults the CURRENT files already have and that this call did not cause. They never
// refuse anything — a book whose files already contradict each other has to stay repairable through
// this door, and charging a decision for a fault it did not cause would make the door useless exactly
// when it is needed. They are REPORTED because the alternative is a verb that succeeds cheerfully on
// a book whose next run is already going to die.
Preexisting []string
}
// The two values of AcceptedDecision.State. Exported because they are a published vocabulary: the
// wiring gates the write on them, and a consumer reads them off the report.
const (
StateApplied = "applied"
StateAlreadyApplied = "already_applied"
)
// resolvedDecision is one request entry joined to the term key it names. `ok` goes false the moment the
// entry is refused, so a later stage never folds a decision the earlier one turned down.
type resolvedDecision struct {
d Decision
key termKey
ok bool
}
// ApplyDecisions folds the decisions onto the two files and returns the result they should have.
//
// ALL OR NOTHING. Any refusal — an ill-formed decision, a conflict with the signed seed, a collision the
// SET would introduce — leaves Rejected non-empty, and the caller then writes nothing at all. Nine
// accepted decisions out of ten are not nine decisions to apply: the tenth is the one the owner has to
// see, and a partial write hides it behind work that appears to have succeeded.
//
// Pure: no clock, no randomness, no I/O. Deterministic in the decisions' own order.
func ApplyDecisions(in ApplyInput) ApplyResult {
in.Decisions = normalizedDecisions(in.Decisions)
res := ApplyResult{Delta: cloneFile(in.Delta), Rejects: cloneRejects(in.Rejects)}
// The door as a sequence of named judgements. Only ONE of them changes anything; the rest decide
// whether it may, and each has to run before the fold or after it for a reason its own comment gives.
rs := resolveAll(in, &res)
refuseSeedConflicts(in, rs, &res)
foldAccepted(in, rs, &res)
refuseInertDeclines(in, rs, &res)
refuseNewFaults(in, &res)
if len(res.Rejected) > 0 {
// All-or-nothing: the caller must not be handed a half-built document it might decide to write,
// and must not be handed BYTES of one either.
res.Delta, res.Rejects = cloneFile(in.Delta), cloneRejects(in.Rejects)
res.Accepted, res.DeltaBytes, res.RejectBytes = nil, nil, nil
}
sort.SliceStable(res.Rejected, func(i, j int) bool { return res.Rejected[i].Index < res.Rejected[j].Index })
return res
}
// resolveAll joins every decision to the term it names and refuses the ill-formed ones.
//
// BEFORE anything is touched, so a set that names one term twice is refused as ill-formed rather than
// applied in request order with the last one silently winning.
func resolveAll(in ApplyInput, res *ApplyResult) []resolvedDecision {
byID := make(map[string]termKey, len(in.Bank))
for _, e := range in.Bank {
byID[TermID(e.Src, e.Sense, e.SinceCh, e.UntilCh)] = keyOf(e)
}
rs := make([]resolvedDecision, len(in.Decisions))
keys := make([]termKey, len(in.Decisions))
for i, d := range in.Decisions {
key, err := resolveDecision(d, byID)
if err != nil {
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: d.Action, Src: d.Src, Reason: err.Error()})
continue
}
rs[i], keys[i] = resolvedDecision{d: d, key: key, ok: true}, key
}
res.Rejected = append(res.Rejected, duplicateDecisions(in.Decisions, keys)...)
return rs
}
// refuseSeedConflicts keeps the SIGNED seed off-limits from this door, in both directions.
func refuseSeedConflicts(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
seedByKey := make(map[termKey]store.GlossaryEntry, len(in.Seed))
seedSurfaces := make(map[string]store.GlossaryEntry, len(in.Seed))
for _, e := range in.Seed {
seedByKey[keyOf(e)] = e
if nk := text.NormalizeSourceKey(e.Src); nk != "" {
seedSurfaces[nk] = e
}
for _, a := range e.Aliases {
if nk := text.NormalizeSourceKey(a.Alias); nk != "" {
seedSurfaces[nk] = e
}
}
}
for i, r := range rs {
if !r.ok {
continue
}
// Scoped to APPROVE: it is the action that would write a delta row, and the reason below is about
// that row's key. A decline of the same term is refused just below, for its own (different) reason
// — a reject writes no row at all, so quoting the UNIQUE constraint at it would be false.
if prior, clash := seedByKey[r.key]; clash && r.d.Action == ActionApprove {
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
Reason: fmt.Sprintf("%q (sense %q, window [%d,%d]) is a SIGNED seed term (%q→%q): a delta row with that key would crash the bank replace on the glossary UNIQUE constraint. Editing a seed term means editing glossary_seed, which moves the BASE snapshot and re-pays the draft wave — a different decision, and not this one",
r.key.Src, r.key.Sense, r.key.Since, r.key.Until, prior.Src, prior.Dst)})
rs[i].ok = false
continue
}
if r.d.Action != ActionDecline {
continue
}
// ⚠ Scoped to the case where the refusal's own sentence is TRUE. «Declining it would change
// nothing» holds only while the DELTA has no row with that surface. When it does, the decline
// drops that row — a real effect, and usually the repair for the collision the same report is
// listing under preexisting_problems (a seed alias and a delta term sharing a firing key is
// 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.
// ⚠ …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
}
}
}
// foldAccepted is the ONE phase that changes a document. Everything before it decides whether it may run
// on a given decision, and everything after it judges what it produced.
func foldAccepted(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
bankByKey := make(map[termKey]store.GlossaryEntry, len(in.Bank))
for _, e := range in.Bank {
bankByKey[keyOf(e)] = e
}
for i, r := range rs {
if !r.ok {
continue
}
res.Accepted = append(res.Accepted, applyOne(res, r.d, r.key, i, bankByKey))
}
}
// refuseInertDeclines refuses a decline that would change nothing while reading as a decision — judged
// on the SET, and therefore only after the fold.
//
// A surface that is an ALIAS of an approved delta term is already excluded from proposals, and the term
// keeps rendering it, because removing an alias has no door in v1. Judged AFTER the fold because the
// term that owns the alias may be approved by ANOTHER decision of the same call: `approve 方源` +
// `decline 方小子` was accepted as two applied decisions while the nickname went on firing. It is the
// RESULT that is judged and not the input, which is also what keeps the mirror case legitimate: two
// declines, of a term and of its own alias, both stand — the first removes the row, so by the end
// nothing owns the alias.
//
// ⚠ NO ESCAPE FOR «it was a term of its own». An earlier version skipped this check when the surface had
// been a delta term before the call. Measured, that escape was a no-op in every input EXCEPT the one
// 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).
// ⚠ 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)})
}
}
}
// refuseNewFaults is the verdict on the RESULT: both documents have to be writable, the delta has to
// load, and it has to load without a collision the next run would die on.
//
// Only NEW faults refuse; the ones the files already had are reported instead, and they are told apart
// by SUBJECT (problems.go). A verdict that named every broken term in one string used to change entirely
// when one of them was fixed, so repairing a book one term at a time was refused with the text of a term
// the decision never touched.
func refuseNewFaults(in ApplyInput, res *ApplyResult) {
before := inspectDocuments(in, in.Delta, in.Rejects)
res.Preexisting = problemTexts(before.problems)
known := make(map[string]bool, len(before.problems))
for _, p := range before.problems {
known[p.Subject] = true
}
after := inspectDocuments(in, res.Delta, res.Rejects)
for _, p := range after.problems {
if !known[p.Subject] {
res.Rejected = append(res.Rejected, RejectedDecision{Index: -1, Reason: p.Text})
}
}
res.DeltaBytes, res.RejectBytes = after.delta, after.rejects
}
// resolveDecision turns one decision into the term key it names, refusing every shape that could name
// two things at once or nothing at all.
func resolveDecision(d Decision, byID map[string]termKey) (termKey, error) {
switch d.Action {
case ActionApprove, ActionDecline:
default:
return termKey{}, fmt.Errorf("action must be %s or %s, got %q", ActionApprove, ActionDecline, d.Action)
}
// The shape rules of each verb, checked HERE so the refusal carries the decision's own index. The
// resulting document is validated too (inspectDocuments), but that verdict is about the file as a
// whole and cannot tell a caller which of its ten decisions to fix.
switch d.Action {
case ActionApprove:
if strings.TrimSpace(d.Dst) == "" {
return termKey{}, fmt.Errorf("an approved term needs a non-empty `dst`: an approved row with no rendering is not a weak signature, it fails the load of the next run — a WHICH-only surface can only be declined")
}
case ActionDecline:
if d.Dst != "" || d.Kind != "" {
return termKey{}, fmt.Errorf("a decline carries only the surface and an optional note; `dst`/`kind` on it says the caller meant to edit the term, and half of that is not something to guess at")
}
}
tuple := d.Src != "" || d.Sense != "" || d.SinceChapter != 0 || d.UntilChapter != 0
switch {
case d.ID != "" && tuple:
return termKey{}, fmt.Errorf("a decision names a term by `id` OR by the (src, sense, window) tuple, never by both")
case d.ID != "":
key, ok := byID[d.ID]
if !ok {
return termKey{}, fmt.Errorf("no bank term has id %q — the read-out it came from is for another book or predates a re-cut of this one (the id carries the chapter window)", d.ID)
}
return key, nil
case strings.TrimSpace(d.Src) == "":
return termKey{}, fmt.Errorf("a decision needs either an `id` or a `src`")
}
key := termKey{strings.TrimSpace(d.Src), strings.TrimSpace(d.Sense), d.SinceChapter, d.UntilChapter}
if key.Since < 0 || key.Until < 0 {
return termKey{}, fmt.Errorf("chapter bounds are 1-based, 0 meaning «no bound»; got [%d,%d]", key.Since, key.Until)
}
// A window that ENDS before it begins is not a narrow window, it is an empty one: the row is written,
// loads, and matches in no chapter at all (windowsOverlap and the matcher both read it as a range).
// Nothing downstream would ever say so — the term simply never fires. Until = 0 is "no upper bound"
// and is not a reversal.
if key.Until != 0 && key.Since > key.Until {
return termKey{}, fmt.Errorf("the chapter window [%d,%d] ends before it begins: such a term is written and loaded but fires in no chapter, so it can only look like a decision that was taken", key.Since, key.Until)
}
return key, nil
}
// normalizedDecisions trims the free text of every decision, which is the SAME rule the bank loader and
// this door already apply to the three fields they key on (src, sense, dst) — stated over the request
// instead of at three call sites, so `note` stops being the exception that escaped it.
//
// ⚠ WHAT IT BUYS IS THE TRUTHFULNESS OF THE REPORT, not writability. An earlier version of this comment
// said it was what keeps a lawful note writable; that is checkably false — the document is normalized
// again on the way out (RenderSeedFile calls seed.File.Normalize), so an untrimmed note is written
// correctly either way. What breaks without this is the door's own IDEMPOTENCY comparison: a caller
// re-sending a decision whose note carries stray whitespace compares it against the trimmed value on
// disk, finds them different, and the call answers `state: applied` while the byte gate one layer up
// correctly answers `changed: false` — a report that contradicts itself about work that did not happen.
//
// `action` and `id` are deliberately NOT trimmed: they are MATCHED, never written, and trimming them
// would silently widen what the door accepts.
func normalizedDecisions(in []Decision) []Decision {
out := make([]Decision, len(in))
for i, d := range in {
d.Src, d.Sense, d.Dst = strings.TrimSpace(d.Src), strings.TrimSpace(d.Sense), strings.TrimSpace(d.Dst)
d.Kind, d.Note = strings.TrimSpace(d.Kind), strings.TrimSpace(d.Note)
out[i] = d
}
return out
}
// duplicateDecisions refuses a request that decides one term twice — and a request that approves and
// declines the same SURFACE, which is the same contradiction one level up: the reject list keys on the
// normalized src, so it would suppress proposals for a term the same call just approved.
func duplicateDecisions(ds []Decision, keys []termKey) []RejectedDecision {
var out []RejectedDecision
seenKey := map[termKey]int{}
seenSurface := map[string]int{}
for i, k := range keys {
if k == (termKey{}) {
continue // unresolved: already refused with its own reason
}
if first, dup := seenKey[k]; dup {
out = append(out, RejectedDecision{Index: i, Action: ds[i].Action, Src: k.Src,
Reason: fmt.Sprintf("decision %d already decides this term (sense %q, window [%d,%d]) — one call, one decision per term", first, k.Sense, k.Since, k.Until)})
continue
}
seenKey[k] = i
nk := text.NormalizeSourceKey(k.Src)
first, seen := seenSurface[nk]
if !seen {
seenSurface[nk] = i
continue
}
// A DECLINE is surface-scoped — the reject list holds a src and nothing else — so any second
// decision about the same surface collides with it, whichever way round. Two declines of one
// surface with different senses are not two terms: the second silently overwrote the first's
// note while BOTH reported applied. Two APPROVEs of one surface in different senses or windows
// stay legitimate: an approve is key-scoped and those are genuinely two terms.
if ds[first].Action == ActionDecline || ds[i].Action == ActionDecline {
out = append(out, RejectedDecision{Index: i, Action: ds[i].Action, Src: k.Src,
Reason: fmt.Sprintf("decision %d already %ss the surface %q, and a decline names a SURFACE rather than one sense or window — one call decides a surface once", first, ds[first].Action, k.Src)})
}
}
return out
}
// applyOne folds one resolved decision into the working documents and reports what it did.
func applyOne(res *ApplyResult, d Decision, key termKey, index int, bank map[termKey]store.GlossaryEntry) AcceptedDecision {
acc := AcceptedDecision{Index: index, Action: d.Action, ID: key.id(), Src: key.Src, State: StateApplied}
changed := false
switch d.Action {
case ActionApprove:
acc.Dst = strings.TrimSpace(d.Dst)
prior, at := findTerm(res.Delta.Terms, key)
if at < 0 {
// Promoting a row the bank ALREADY HAS: start from what the engine itself proposed about the
// term's identity, not from a blank. Its ALIASES are the miner's cluster — the other surfaces
// that fire for this entity — and once a signed delta row holds the key, the auto-bank row
// carrying them is dropped as a key clash (loadAutoBank). Promotion would therefore SILENTLY
// narrow the term to its own src: exactly the "approved but inert on half its surfaces" hole
// this bank exists to close.
prior = termFromBank(bank[key])
}
next := mergeTerm(prior, d, key)
switch {
case at < 0:
res.Delta.Terms = append(res.Delta.Terms, next)
changed, res.DeltaTouched = true, true
case !sameTerm(prior, next):
// Replaced IN PLACE: the file's order is the owner's reading order and a decision is not a
// reason to reshuffle it.
res.Delta.Terms[at] = next
changed, res.DeltaTouched = true, true
if prior.Dst != next.Dst {
acc.Replaced = append(acc.Replaced, fmt.Sprintf("previous rendering %q", prior.Dst))
}
}
if dropped := dropReject(&res.Rejects, key.Src); dropped {
changed, res.RejectsTouched = true, true
acc.Replaced = append(acc.Replaced, "a previous decline of this surface")
}
case ActionDecline:
if gone := dropTerms(&res.Delta, key.Src); len(gone) > 0 {
changed, res.DeltaTouched = true, true
for _, g := range gone {
acc.Replaced = append(acc.Replaced, fmt.Sprintf("a previous approval %q→%q", g.Src, g.Dst))
}
}
if addReject(&res.Rejects, key.Src, d.Note) {
changed, res.RejectsTouched = true, true
}
}
if !changed {
acc.State = StateAlreadyApplied
}
return acc
}
// termFromBank carries a bank row's IDENTITY into the delta row that promotes it: the alias surfaces
// and the classified kind. Not its dst (the decision brings that), not its status (the promotion sets
// it), and not the seed-only metadata — a decidable row is never a seed row, so it has none.
func termFromBank(e store.GlossaryEntry) seed.Term {
t := seed.Term{Type: e.Type}
for _, a := range e.Aliases {
t.Aliases = append(t.Aliases, seed.Alias{Alias: a.Alias, Type: a.AliasType})
}
return t
}
// mergeTerm builds the delta row a decision produces, MERGING onto whatever the file already holds for
// that term rather than replacing it wholesale: a hand-authored `gender` or `decl` on the row is not
// something a decision about the rendering was asked to discard.
//
// `status: approved` is written EXPLICITLY and always. The seed loader defaults an ABSENT status to
// approved, so a file without the field would work — but a mechanically copied signature-map row carries
// `status: auto`, which the mining stop filters out as unsigned, and the stop then never clears. Writing
// the word is what makes the promotion survive being read back.
func mergeTerm(prior seed.Term, d Decision, key termKey) seed.Term {
t := prior
t.Src, t.Sense, t.SinceCh, t.UntilCh = key.Src, key.Sense, key.Since, key.Until
t.Dst = strings.TrimSpace(d.Dst)
t.Status = "approved"
if d.Kind != "" {
t.Type = d.Kind
}
if d.Note != "" {
t.Note = d.Note
}
return t
}
// aliasOwner finds the SIGNED delta term that owns `src` as an ALIAS rather than as its own surface.
// Keyed on the normalized form, like everything else that matches a surface rather than a row.
//
// ⚠ SIGNED, and the status is load-bearing rather than decorative. The rule this serves is "declining
// this surface would be inert, because it is already excluded from proposals" — and the exclusion is
// `unsignedEngineSurfaces` (pipeline/mining.go), which drops mined rows whose status is not `approved`.
// A delta row carrying `status: auto` — the state row 199's first format mine is entirely about — does
// NOT exclude its surfaces, so declining its alias is precisely what the signing screen asks for.
// Ignoring the status refused that decline, and told the owner it was declining an alias of "the
// approved term", which the row was not. An ABSENT status is approved, as the loader defaults it.
func aliasOwner(f seed.File, src string) (seed.Term, bool) {
nk := text.NormalizeSourceKey(src)
for _, t := range f.Terms {
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
return seed.Term{}, false // its own surface: an ordinary decline of the term
}
if st := strings.TrimSpace(t.Status); st != "" && st != "approved" {
continue // unsigned: its surfaces are not excluded from proposals, so a decline is not inert
}
for _, a := range t.Aliases {
if text.NormalizeSourceKey(strings.TrimSpace(a.Alias)) == nk {
return t, true
}
}
}
return seed.Term{}, false
}
// deltaHoldsSurface reports whether the document has a row whose OWN src is this surface — i.e. whether
// a decline of it would drop something. Not to be confused with aliasOwner, which asks the opposite
// question (does somebody ELSE keep firing this surface).
func deltaHoldsSurface(f seed.File, src string) bool {
nk := text.NormalizeSourceKey(src)
for _, t := range f.Terms {
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
return true
}
}
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 &&
t.SinceCh == key.Since && t.UntilCh == key.Until {
return t, i
}
}
return seed.Term{}, -1
}
// sameTerm compares two rows through the BYTES they would be written as.
//
// Deliberately not reflect.DeepEqual, which is the ordinary Go answer for a struct holding slices and a
// pointer. Two reasons, in order of weight: the question this function is asked is "will the file
// change?", and the file is YAML, so comparing the rendering answers it directly and stays consistent
// with the byte-level write gate one layer up; and DeepEqual distinguishes a nil slice from an empty one
// (`aliases:` absent vs `aliases: []`), which is a difference the format does not have and which would
// report a spurious change. It is also total in the same way DeepEqual is — it cannot go stale the day
// the schema grows a field, which is exactly how an idempotency check silently starts reporting
// "already applied" for a decision it did not apply.
func sameTerm(a, b seed.Term) bool {
ab, aerr := yaml.Marshal(a)
bb, berr := yaml.Marshal(b)
return aerr == nil && berr == nil && bytes.Equal(ab, bb)
}
// dropTerms removes every delta row whose surface normalizes to src's, and returns them.
//
// Surface-scoped rather than key-scoped because the reject list itself is: it holds a src and nothing
// else, so "I decline this term" cannot mean one spoiler window of it. Removing the approvals it
// contradicts is the REPLACEMENT half of the decline; leaving them would inject a rendering the owner
// has just declined.
func dropTerms(f *seed.File, src string) []seed.Term {
nk := text.NormalizeSourceKey(src)
kept := f.Terms[:0:0]
var gone []seed.Term
for _, t := range f.Terms {
if text.NormalizeSourceKey(strings.TrimSpace(t.Src)) == nk {
gone = append(gone, t)
continue
}
kept = append(kept, t)
}
f.Terms = kept
return gone
}
// addReject records a decline, replacing an existing one for the same surface.
//
// An ABSENT note keeps whatever note the entry already carries — the same rule `kind` follows on the
// approve side ("absent means not decided"), and the same rule mergeTerm follows for the delta. The two
// halves of the door disagreed about this: a repeat approve kept the note and a repeat decline ERASED
// it, so a caller re-sending the same decision from a screen that does not carry notes silently threw
// away the owner's own words. One rule, stated once here and in Decision.Note.
func addReject(f *seed.RejectFile, src, note string) bool {
nk := text.NormalizeSourceKey(src)
for i, r := range f.Rejects {
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) != nk {
continue
}
next := seed.Reject{Src: src, Note: note}
if note == "" {
next.Note = r.Note
}
if r == next {
return false
}
f.Rejects[i] = next
return true
}
f.Rejects = append(f.Rejects, seed.Reject{Src: src, Note: note})
return true
}
// dropReject withdraws a decline of this surface, which is what an approval of it means.
func dropReject(f *seed.RejectFile, src string) bool {
nk := text.NormalizeSourceKey(src)
kept := f.Rejects[:0:0]
for _, r := range f.Rejects {
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) == nk {
continue
}
kept = append(kept, r)
}
dropped := len(kept) != len(f.Rejects)
f.Rejects = kept
return dropped
}
// docVerdict is one state of the two documents as this door judges it: every fault, keyed by subject,
// and the canonical bytes each document would be written as (nil when it could not be rendered at all).
type docVerdict struct {
problems []Problem
delta []byte
rejects []byte
}
// inspectDocuments is the WRITABILITY + loadability + collision + coverage verdict on one
// (seed, delta, rejects) state. The load checks are the ones seedGlossary runs on the way into the bank,
// in the same order, over the same joined entry set — so "this document would load" here means the same
// thing it means there.
//
// Both documents are judged, not just the delta. The reject list carries no bank content, so it has no
// collisions to have — but the same call writes it, and a reject list the next run cannot parse refuses
// that run's CONFIG, which is how one lawful decline used to make a book unopenable through this door.
func inspectDocuments(in ApplyInput, delta seed.File, rejects seed.RejectFile) docVerdict {
var problems problemList
v := docVerdict{}
// Keyed by SUBJECT — the document — because the TEXT of these two carries a parser's line number,
// which moves when an unrelated part of the file is edited. That is the same instability problems.go
// exists to keep out of the before/after comparison.
rejectRaw, rerr := RenderRejectFile(rejects)
if rerr != nil {
problems.addKeyed("the mined-rejects document cannot be written",
fmt.Sprintf("the mined-rejects document cannot be written: %v", rerr))
}
v.rejects = rejectRaw
raw, err := RenderSeedFile(delta)
if err != nil {
problems.addKeyed("the mined-delta document cannot be written",
fmt.Sprintf("the mined-delta document cannot be written: %v", err))
v.problems = sortedProblems(problems)
return v
}
v.delta = raw
bs, err := ParseBankSeed("mined-delta", raw)
if err != nil {
// One entry PER SUBJECT, not one per verdict. The loader accumulates its faults and joins them
// for a human; joined, they are a single string that changes whenever any one of them is fixed.
var sp SeedProblems
if errors.As(err, &sp) {
problems = append(problems, sp.Problems...)
} else {
problems.addKeyed("the resulting mined-delta would not load",
fmt.Sprintf("the resulting mined-delta would not load: %v", err))
bs.Terms = nil // a document that did not even PARSE has no entries to judge
}
}
// ⚠ THE CHECKS BELOW RUN EVEN WHEN THE LOAD FAILED, over the entries the loader did build.
//
// They used to be skipped, and that is a blindness with teeth: a delta that does not load hides every
// collision, gender and voice fault in it, so the moment a decision REPAIRED loadability all of them
// appeared at once and read as introduced by that decision. The first fix for that excused them
// wholesale, which was worse — a livelock the call really did introduce was then filed as
// pre-existing and WRITTEN (reproduced: a set that repairs one row and adds a term colliding with an
// untouched one). Making the before-verdict COMPLETE instead is what lets the ordinary
// compare-by-subject rule answer both cases correctly, with no special case at all.
mined := bs.Terms
for i := range mined {
mined[i].Source = "mined" // as loadMinedDelta stamps them: base-excluded, edit-wave only
}
// The SEED as the run sees it — with the ruby readings grafted on as firing aliases — and only then
// joined to the delta. Same order as seedGlossary: attach, then join, then check.
joined := append(withRubyAliases(in.Seed, in.Ruby), mined...)
// The subject-carrying variants, NOT the exported []string ones: three of these messages name a
// term's RENDERING, which is precisely what a decision changes (problems.go, subjectOf).
problems = append(problems, minedDeltaSeedCollisions(in.Seed, mined)...)
problems = append(problems, approvedSharedKeyCollisions(joined)...)
problems = append(problems, genderVocabViolations(mined)...)
// The seed's voice/address rows are judged against the JOINED set for the same reason seedGlossary
// judges them there: a character may legitimately be signed in the delta rather than in the base
// seed, and removing that term leaves a profile that can never fire — which stops the run.
// This one's message is already identity-only — it names a character and a sense and nothing a
// decision can rewrite — so Subject == Text is a correct key for it.
for _, msg := range UnknownVoiceCharacters(joined, in.Voices, in.Pairs) {
problems.add(msg)
}
v.problems = sortedProblems(problems)
return v
}
// sortedProblems orders a verdict deterministically. Every one of these checks is order-deterministic on
// its own; the sort is what makes the JOIN of four of them one stable list.
func sortedProblems(in problemList) []Problem {
out := []Problem(in)
sort.SliceStable(out, func(i, j int) bool { return out[i].Text < out[j].Text })
return out
}
// withRubyAliases returns a COPY of the seed entries with the book's name-shaped ruby readings attached
// as aliases, exactly as the run does before it judges collisions.
//
// A copy, and a deep one on the alias slices: AttachRubyAliasesToManual appends in place, and an append
// that happens to fit a shared backing array would reach back into the caller's rows — which here are
// the loaded seed, read again by the before/after comparison. The readings it REFUSES to graft (a
// homophone contested between two seeded terms) are the run's own diagnostic, not a fault of this
// document, so they are deliberately dropped here.
func withRubyAliases(seedEntries []store.GlossaryEntry, ruby []store.RubyReading) []store.GlossaryEntry {
out := make([]store.GlossaryEntry, len(seedEntries))
for i, e := range seedEntries {
e.Aliases = append([]store.GlossaryAlias(nil), e.Aliases...)
out[i] = e
}
if len(ruby) > 0 {
AttachRubyAliasesToManual(out, ruby)
}
return out
}
// RenderSeedFile is the canonical bytes of a seed document — the form the engine writes, and the form
// two identical decision sets have to produce for a retry to be a byte-level no-op.
//
// It PROVES its own output. See renderProved: the engine does not get to write a document it cannot
// read back, and "cannot read back" includes reading back a DIFFERENT one.
func RenderSeedFile(f seed.File) ([]byte, error) {
return renderProved(f.Normalize(), func(b []byte) (seed.File, error) {
back, err := seed.DecodeFile(b)
return back.Normalize(), err
}, "mined-delta document")
}
// RenderRejectFile is RenderSeedFile for the reject list — same gate, other schema.
func RenderRejectFile(f seed.RejectFile) ([]byte, error) {
return renderProved(f.Normalize(), func(b []byte) (seed.RejectFile, error) {
back, err := seed.DecodeRejects(b)
return back.Normalize(), err
}, "mined-rejects document")
}
// RenderSignatureMap renders the owner signature map under the seam envelope (seed.SignatureMap): the
// same proving gate as the two decision documents, because the map is read by the other side of the
// seam and a map the engine cannot read back is a signing screen that silently shows nothing.
func RenderSignatureMap(f seed.File) ([]byte, error) {
content := f.Normalize()
id, err := seed.SignatureMapID(content)
if err != nil {
return nil, fmt.Errorf("membank: %w", err)
}
m := seed.SignatureMap{Version: seed.SignatureMapVersion, ID: id, File: content}
return renderProved(m, func(b []byte) (seed.SignatureMap, error) {
back, err := seed.DecodeSignatureMap(b)
back.File = back.Normalize()
return back, err
}, "signature map")
}
// renderProved marshals a NORMALIZED document and proves the bytes read back as that same document,
// through the real strict decoder the next run will use.
//
// The gate exists because the YAML library the engine writes with can emit a document it cannot itself
// parse, and can emit one that parses to a DIFFERENT value. Measured on gopkg.in/yaml.v3 v3.0.1: a
// string whose first byte is a newline becomes a block scalar whose indentation indicator disagrees
// with the indentation written inside a sequence item — both documents of this schema are sequences of
// mappings, so it reaches every text field of both. `seed.File.Normalize` removes the input that
// triggers it; this proves the result instead of assuming the trigger set is fully known. It is the
// difference between a door that refuses a lawful decline and a door that BRICKS the book: before this
// gate the bytes were written, and every later call on that book — including the projection the law
// calls a safe preview — died reading them.
//
// A document that fails the gate is an ERROR, never bytes: all-or-nothing means the caller writes
// nothing, and the reason travels as a refusal rather than as a corrupted file.
//
// ⚠ Precision about the route not taken: `(*yaml.Node).Encode` is closed to us (it round-trips
// internally and fails on the same input), but a HAND-BUILT node tree with an explicit quoted style is
// not — the emitter checks an explicit Style BEFORE the block-scalar branch (encode.go:557-570), so it
// renders such a value correctly. It is rejected on cost, not on possibility: it means a second,
// hand-written renderer for the whole schema, and it would not remove this gate.
//
// Cost is one extra parse per render. Measured in the report; the verb is $0 and capped either way.
func renderProved[T any](doc T, readBack func([]byte) (T, error), what string) ([]byte, error) {
b, err := yaml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("membank: marshal the %s: %w", what, err)
}
back, err := readBack(b)
if err != nil {
return nil, fmt.Errorf("membank: the %s the engine would write does not read back: %w", what, err)
}
if reflect.DeepEqual(back, doc) {
return b, nil
}
return nil, fmt.Errorf("membank: the %s the engine would write reads back as a DIFFERENT document%s",
what, firstDivergence(b, back))
}
// firstDivergence renders what came back and names the first line that differs from what would have
// been written, because "a different document" without a place to look is not actionable.
func firstDivergence[T any](written []byte, back T) string {
again, err := yaml.Marshal(back)
if err != nil {
return ""
}
w, r := strings.Split(string(written), "\n"), strings.Split(string(again), "\n")
for i := 0; i < len(w) || i < len(r); i++ {
lw, lr := "", ""
if i < len(w) {
lw = w[i]
}
if i < len(r) {
lr = r[i]
}
if lw != lr {
return fmt.Sprintf(" — line %d would be written as %q and reads back as %q", i+1, lw, lr)
}
}
return ""
}
func cloneFile(f seed.File) seed.File {
out := f
out.Terms = append([]seed.Term(nil), f.Terms...)
out.Voices = append([]seed.Voice(nil), f.Voices...)
out.Addresses = append([]seed.Address(nil), f.Addresses...)
return out
}
func cloneRejects(f seed.RejectFile) seed.RejectFile {
return seed.RejectFile{Rejects: append([]seed.Reject(nil), f.Rejects...)}
}