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); // - `speech` and `decl` are neither. They are seed-only with no automatic producer at all, 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; // - `gender` was in that list and is not any more. It HAS an automatic producer since backlog row 210 // (the §2 classifier derives it from the source contexts), so it is now published and CARRIED FORWARD // on a promotion exactly as the aliases are — see termFromBank, where omitting it made approving a term // the act that erased its gender. It is still NOT ACCEPTED: a decision about a rendering states no // gender, and correcting one stays a seed edit. // // ⚠ 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") } } // THE WIRE FENCE, ASKED AT THE DOOR (wirefence.go, backlog row 271). An approved `dst` is rendered // verbatim into a system message on every paid call of the book, so a value that can end its own line // writes into that block rather than sitting in it. Refused HERE, before the term is resolved, for the // reason every other shape rule in this function is refused here: the refusal carries the decision's // own index, so a caller with ten corrections is told WHICH one to fix instead of being handed a // verdict about the file. Both fields the decision CARRIES are asked; a decision naming a term by `id` // borrows the bank's own surface, which the loader fenced when it read it. if why := WireUnfitJoined(d.Src, d.Dst); why != "" { return termKey{}, fmt.Errorf("%s", why) } 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, the // classified kind, and the entity's GENDER. Not its dst (the decision brings that) and not its status (the // promotion sets it). // // ⚠ GENDER JOINED THIS LIST WHEN IT GAINED A PRODUCER (backlog row 210), and leaving it out was measured to // be destructive rather than merely incomplete. The engine now derives an entity's gender in the §2 // classifier and banks it on the auto row; approving that term through this door built the delta row // WITHOUT it, and loadAutoBank then drops the auto row as a key clash — so the owner's approval, the one act // the whole stop exists to invite, was the act that erased the datum. It travels for the same reason the // aliases do: it is the engine's finding ABOUT THE ENTITY, and a decision about the RENDERING was not asked // to discard it. // // It is carried, not ACCEPTED: the Decision document still has no gender field, so a caller cannot state one // here — that door stays shut, and correcting a gender stays a seed edit. func termFromBank(e store.GlossaryEntry) seed.Term { t := seed.Term{Type: e.Type, Gender: e.Gender} 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 := ParseEngineBankSeed("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...)} }