package pipeline import ( "bytes" "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "textmachine/backend/internal/config" "textmachine/backend/internal/membank" "textmachine/backend/internal/seed" "textmachine/backend/internal/store" "textmachine/backend/internal/text" ) // bankdecisions.go: the WIRING of the bank-decisions door — the lock, the reads, the writes and the // report. The decision logic itself is pure and lives in membank/decisions.go. // // It is a package-level function and not a Runner method on purpose. A Runner is the composite root of a // RUN: it loads the pipeline and model configs, resolves prompts, builds provider clients. This verb // spends nothing and calls nobody, and making it depend on a valid models.yaml would mean a book could // stop being decidable because of a file its decisions have nothing to do with. // BankDecisionsReport is what the verb prints — the whole of its answer, on stdout, in every outcome // that has one. A caller that is told "no" has to be told what to change. // // ITS FIELDS ARE OF TWO NATURES, and the split is the report's own law (the single-commit-point rule): // // - THE VERDICT — what was accepted, what was rejected and why, what pre-existing faults the files // carry, indexes, `replaced`, `depth`, `files`. A fact about the decisions against the state // BEFORE the call; it is not on disk and never will be, which is why the verb composes it at all. // Built once, by decisionVerdict, before the outcome exists. // - THE OUTCOME — mode, `changed`, the re-render warnings, `written_*`, `signature`. A fact about // the world AFTER. Filled in exactly one place, finishReport, by a switch over the explicit // outcome of the effect phase — so a new exit branch has nowhere to forget a field. type BankDecisionsReport struct { Version string `json:"report_version"` BookID string `json:"book_id"` // Mode names the outcome: `apply` (the write completed, or a byte-no-op — `changed` tells the two // apart) · `projection` (the 17-seam-inbound-law п.7 preview; a report printed by the same call // that already wrote is not a projection) · `refused` (the document was declined whole; the reasons // are in `rejected`) · `stopped` (a stop signal arrived before the first byte) · `write_incomplete` // (the document was accepted and the write did not complete; `written_*` says which file landed). Mode string `json:"mode"` // Depth says how far an accepted decision reaches — see membank.DecisionDepth. A caller renders its // own words from it; what it must not do is assume the corrected term re-forms the draft. Depth string `json:"depth"` // Changed reports whether THIS call moved a file on disk (in projection: whether it would). False on // a byte-no-op — then nothing is written AT ALL, not even identical bytes, so a retrying worker // cannot move a file's mtime, let alone its content. Changed bool `json:"changed"` Files BankDecisionPath `json:"files"` // WrittenDelta / WrittenRejects are the per-file truth of the write phase: true means THIS call // replaced that file on disk. They are what tells «nothing landed» from «half landed» under // mode `write_incomplete` — the class is one, the report carries the difference. WrittenDelta bool `json:"written_delta"` WrittenRejects bool `json:"written_rejects"` // CanonicalRewrite warns that a file this call would WRITE is not in the form the engine writes, so // applying will re-render it: its comments and its key order will not survive. Visible BEFORE the // mutation, which is the only moment at which it is actionable. // // It is the OR of the two fields below and it is kept because a consumer that only wants to raise a // dialog has one thing to read. The two are what it must render: the OR alone named a RISK without // naming the FILE at risk, and since a call routinely touches one document and not the other, it // warned about a file the call would never open — an operator's comments in the reject list raised // it for a set of pure approvals. CanonicalRewrite bool `json:"canonical_rewrite"` // CanonicalRewriteDelta / CanonicalRewriteRejects are the same warning per file, and each is false // for a document this call does not touch. CanonicalRewriteDelta bool `json:"canonical_rewrite_delta"` CanonicalRewriteRejects bool `json:"canonical_rewrite_rejects"` // PreexistingProblems are faults the two files ALREADY have — a delta that would not load, a // collision with the signed seed — that this call neither caused nor refuses over. They are here // because the alternative is a verb that answers "applied, exit 0" about a book whose next run is // already going to die at the bank boundary, and nothing else on this surface would say so. // ⚠ On a CAP refusal the list is empty because nothing below the verdict was measured, not because // the book is clean — signature.unreadable is the report's «unmeasured» flag for that state. PreexistingProblems []string `json:"preexisting_problems"` Accepted []membank.AcceptedDecision `json:"accepted"` Rejected []membank.RejectedDecision `json:"rejected"` // Signature is the one thing this call can honestly say about the bank-mining STOP. Signature SignatureState `json:"signature"` } // SignatureState measures the last-written signature map against the book's decisions. INFORMATIONAL, // exactly as D39.144 demoted it — it counts, it does not gate. // // ⚠ IT DOES NOT ANSWER «погаснет ли стоп», in either direction, and a consumer must not read it as if // it did. The stop is the flag model's (mining.go): a run with `--verify-bank` stops only when its OWN // freshly-mined map holds a cluster outside the presented memory. This field cannot compute that from // either end — the next run's mined set does not exist yet (computing it means re-ingesting and // re-chunking the whole book, which is what makes `status` expensive and is not what a $0 decision verb // does), and the presented memory would not close the gap (memory without the next map still decides // nothing) — so the door does not open the database for it at all, and `undecided: 0` promises nothing // about the next run's behaviour. Deciding surfaces is the owner's RIGHT, not the stop's demand. // // What the numbers ARE for: a signing screen. «Of the surfaces the last stop offered, how many has the // owner not yet spoken about» — counted by the SAME rule the run uses to fold decisions into the bank // (ownerHandled over unsignedEngineSurfaces), so the count agrees with what the next run will treat as // decided content. type SignatureState struct { // Map is the signature map's path, or empty when the book has none — no run has reached the bank // boundary yet, and there is nothing to count against. Map string `json:"map"` // Surfaces is how many surfaces that map asks about; Undecided how many of them are still neither // approved in the delta nor declined in the rejects AFTER this call. Surfaces int `json:"surfaces"` Undecided int `json:"undecided"` // Unreadable says the two numbers mean NOTHING for this call — the map or the delta could not be // parsed, or the call was refused before it could measure them (a cap refusal never opens the // book). Without it a state this door could not measure was byte-identical to the state it most // wants to report, «nothing left undecided». Unreadable bool `json:"unreadable"` } // BankDecisionPath names the two files the verb owns. The paths are ABSOLUTE: the engine resolves them // against the book's directory, and a consumer reading this report runs in a different working directory // (the platform's own process), where a relative path resolves to something else entirely. type BankDecisionPath struct { MinedDelta string `json:"mined_delta"` MinedRejects string `json:"mined_rejects"` } // ErrDecisionsRefused is "the document was read as a decisions document and this engine will not apply // it" — the carrier of RefusalDecisionsRejected, whose own comment says why that is a class of its own // rather than the config one. A document that never parsed as a decisions document at all is the CONFIG // class instead (see the top of ApplyBankDecisions): the caller's wiring is what is wrong there. // // The per-decision reasons ride the report, which is printed either way. var ErrDecisionsRefused = errors.New("the decision document was refused; nothing was written") // ApplyBankDecisions applies one decision document to a book's memory-bank decision files. // // $0 by construction: it opens no provider client, reads the bank read-only and writes two YAML files. // // THE PROJECTION TOUCHES NO DECISION FILE, and that is the whole of what it promises. It does create // `.lock` in the book's directory when none is there, because the lock is the arbiter below // and a projection computed without it is a projection of nothing. "Changes nothing" is about the two // documents and the bank; it was never true of the lock file, and the difference matters to anyone // diffing a book directory after a preview. // // ⚠ THE ARBITER COVERS THE PROJECT, AND SINCE FIX-PACK-2 THAT IS ALMOST ENOUGH. The flock is on // `.lock`, and the two files written are pure functions of the book's directory and its // book_id (config.Book.MinedDelta — the path keys are retired): two DIFFERENT books can no longer // share a decision file, which retires the original cross-book race (two configs declaring ONE delta, // one side's approvals lost with exit 0 — the ⛔ ping the previous pack left open). What REMAINS is the // same-book spelling: `project_db` is still declarable, so two configs in ONE directory with ONE // book_id but different project_db values write the same pair of files under two different locks — // including past the flock of a live run (reproduced by the fix2 review workflow: a second config with // a scratch project_db applied decisions while the real project's lock was held). That is an operator // keeping a second config of the same book, not an ordinary deployment; closing it means a second // arbiter or a refusal at load, which touches 17-seam-inbound-law п.2 and stays a ping, not this // door's own decision. // // THE LOCK IS THE ARBITER, and it is the project's own flock — the one a run holds for its whole life. // Taken non-blocking, and a busy one refuses with RefusalProjectLocked (exit 12), never with the config // class: the engine RE-READS the seed in the middle of a run (mining.go), so a write that landed under a // live run would enter its snapshot non-deterministically. The projection mode takes it too — a // projection computed against state another process is rewriting is not a projection of anything. func ApplyBankDecisions(ctx context.Context, cfgPath, decisionsPath string, dryRun bool) (BankDecisionsReport, error) { book, doc, err := openDecisionRequest(cfgPath, decisionsPath) if err != nil { // The DECISIONS class prints a report in EVERY spelling, the two caps included: an all-or-nothing // verb's report of reasons IS its product, and the contract used to have two carriers that // disagreed (the class promised a report, the cap path printed a stderr line). Config-class // failures before the lock still speak on stderr alone — there the wiring itself is broken and a // report about a book this call never opened would say nothing. var refusal *Refusal if book != nil && errors.As(err, &refusal) && refusal.Class == RefusalDecisionsRejected { return capRefusalReport(book, err), err } return BankDecisionsReport{}, err } lock, err := store.LockProject(book.ProjectDB) if err != nil { return BankDecisionsReport{}, RefuseStoreOpen(err) } defer lock.Release() st, err := readBookState(book) if err != nil { return BankDecisionsReport{}, err } if err := ctx.Err(); err != nil { return BankDecisionsReport{}, err } res := membank.ApplyDecisions(membank.ApplyInput{ Bank: st.bank, Seed: st.seed.Terms, Voices: st.seed.Voices, Pairs: st.seed.Pairs, Ruby: st.ruby, Delta: st.delta, Rejects: st.rejects, Decisions: doc.Decisions, }) // ONE verdict, ONE commit point. The verdict half is composed here and never patched again; every // exit below names its outcome and finishReport fills the outcome half — a new branch cannot forget // a field, because no branch owns any. verdict := decisionVerdict(book, res) if len(res.Rejected) > 0 { return finishReport(verdict, outcomeRefused, book, st, res, writtenFiles{}), refuse(RefusalDecisionsRejected, ErrDecisionsRefused) } deltaChanged, rejectsChanged := changedFiles(st, res) if dryRun { return finishReport(verdict, outcomeProjection, book, st, res, writtenFiles{delta: deltaChanged, rejects: rejectsChanged}), nil } if !deltaChanged && !rejectsChanged { // The byte-no-op still RE-PROVES DURABILITY. The write-incomplete contract says «re-send the same // document», and when the original failure was the directory sync the retry converges exactly // here, with the bytes already on disk — without this call the one thing that exit 15 warned // about would never be re-proven, and the retry would answer 0 (review-workflow finding). A // directory that cannot be flushed keeps refusing with the same class until it heals; an // ordinary no-op pays one fsync. if err := syncDir(filepath.Dir(book.MinedDelta)); err != nil { return finishReport(verdict, outcomeWriteFailed, book, st, res, writtenFiles{}), refuse(RefusalWriteIncomplete, fmt.Errorf("nothing was written by this call, but the directory holding the decision files still cannot be flushed — their durability is unproven: %w", err)) } return finishReport(verdict, outcomeNoop, book, st, res, writtenFiles{}), nil } // SIGTERM means STOP, and for an all-or-nothing $0 verb the answer is the whole of it: drop the work // and release the lock, having written nothing. There is no partial result worth finishing — the law // gives SIGTERM the sense «finish the chunk you are PAYING for and let go» (17-seam-inbound-law п.2), // and this verb pays for nothing. Checked at the last moment before the first byte, which is the only // place where the difference between «stopped» and «applied» is still available. // // The context is cancelled by signal.NotifyContext in main and by nothing else, so a cancelled one is // somebody asking this process to stop; tmctl maps it to exit 5 (graceful stop). ⚠ What the PLATFORM // records for 5 is `stopped` — and only when it has a stop request on file: without one the case // falls through to `failed` (platform/internal/runs/reconcile.go:700-712 and :756, read not edited). // It is NOT `paused`; that is exit 4, the resumable ceiling verdict, and PD-113 forbids reusing it. // // The longest stretch this cannot interrupt is one membank.ApplyDecisions, which is pure and takes no // context by charter — maxDecisions bounds it at ~11 s in the worst measured shape. But the reason // that stretch is SAFE is not its length: the writes are last and each is atomic, so a process killed // anywhere before this line has written nothing, and one killed between the two renames leaves one // proven file and a retry that converges. if err := ctx.Err(); err != nil { return finishReport(verdict, outcomeStopped, book, st, res, writtenFiles{}), err } wrote, werr := writeDecisionFiles(book, res, deltaChanged, rejectsChanged) if werr != nil { return finishReport(verdict, outcomeWriteFailed, book, st, res, wrote), refuse(RefusalWriteIncomplete, werr) } return finishReport(verdict, outcomeWritten, book, st, res, wrote), nil } // openDecisionRequest loads the book and the request, and refuses everything that can be judged before // the project is locked — so a malformed call never takes the lock a run might be waiting for. The book // is returned WITH a refusal once it has loaded, because a cap refusal owes the caller a report and the // report needs the book's identity and paths. func openDecisionRequest(cfgPath, decisionsPath string) (*config.Book, membank.DecisionsDoc, error) { book, err := config.LoadBook(cfgPath) if err != nil { return nil, membank.DecisionsDoc{}, RefuseConfig(err) } raw, err := readDecisions(decisionsPath) if err != nil { return book, membank.DecisionsDoc{}, err } doc, err := membank.DecodeDecisions(raw) if err != nil { return book, membank.DecisionsDoc{}, RefuseConfig(err) } if doc.BookID != book.BookID { return book, membank.DecisionsDoc{}, RefuseConfig(fmt.Errorf("pipeline: the decisions are for book %q and this configuration is book %q", doc.BookID, book.BookID)) } // The bound that actually holds the call inside a caller's timeout — see maxDecisions. Checked after // the decode, because it is a fact about the REQUEST and not about the file. if n := len(doc.Decisions); n > maxDecisions { return book, membank.DecisionsDoc{}, refuse(RefusalDecisionsRejected, fmt.Errorf( "pipeline: the document carries %d decisions and this engine applies at most %d in one act (beyond that the call stops fitting the caller's timeout and is killed on every retry) — split it", n, maxDecisions)) } return book, doc, nil } // bookState is everything the decision pass reads about a book, plus the BYTES the two decision files // hold right now — which is what the write gates compare against. type bookState struct { bank []store.GlossaryEntry ruby []store.RubyReading seed membank.BankSeed delta seed.File deltaRaw []byte rejects seed.RejectFile rejectsRaw []byte } // readBookState reads all of it under the lock, in one place, so no caller can accidentally judge a // decision against half a book. func readBookState(book *config.Book) (bookState, error) { var st bookState var err error if st.bank, st.ruby, err = bankRows(book); err != nil { return st, err } // The WHOLE seed, not only its terms: its voice/address rows are what make some decisions unsafe // (see membank.inspectDocuments), and reading half of it would hide that. if book.GlossarySeed != "" { if st.seed, err = membank.LoadBankSeed(book.GlossarySeed); err != nil { return st, RefuseConfig(err) } } if st.delta, st.deltaRaw, err = readDelta(book); err != nil { return st, RefuseConfig(err) } if st.rejects, st.rejectsRaw, err = readRejects(book); err != nil { return st, RefuseConfig(err) } return st, nil } // applyOutcome is what the effect phase actually did — the value the ONE outcome constructor switches // over. Every exit of ApplyBankDecisions names exactly one of these. type applyOutcome int const ( outcomeProjection applyOutcome = iota // --dry-run: measured, nothing touched outcomeRefused // the document was declined whole; nothing touched outcomeNoop // every decision already in the files; nothing touched, by design outcomeStopped // a stop signal arrived before the first byte; nothing touched outcomeWritten // the write completed outcomeWriteFailed // the write did not complete; writtenFiles says how far it got ) // writtenFiles is the write phase's per-file truth. For the projection outcome it carries "would write". type writtenFiles struct{ delta, rejects bool } // decisionVerdict builds the VERDICT half of the report — the fact about the decisions against the // state before the call (see BankDecisionsReport). Nothing here may depend on how the call ends. func decisionVerdict(book *config.Book, res membank.ApplyResult) BankDecisionsReport { return BankDecisionsReport{ Version: membank.DecisionsReportVersion, BookID: book.BookID, Depth: membank.DecisionDepth, Files: BankDecisionPath{MinedDelta: absPath(book.MinedDelta), MinedRejects: absPath(book.MinedRejects)}, Accepted: orEmpty(res.Accepted), Rejected: orEmpty(res.Rejected), PreexistingProblems: orEmpty(res.Preexisting), } } // finishReport fills the OUTCOME half — mode, changed, the re-render warnings, written_*, signature — // by a switch over the explicit outcome. It is the report's single commit point: four review rounds // each found one more exit branch hand-patching its own subset of these fields, and the only fix that // closes the class is a constructor with no subsets to choose from. func finishReport(rep BankDecisionsReport, o applyOutcome, book *config.Book, st bookState, res membank.ApplyResult, files writtenFiles) BankDecisionsReport { // "Would applying re-render a file the engine did not write?" — asked against the CURRENT documents // re-rendered unchanged, so it reports the file's form and not the decisions' effect. Per file, and // only for the file this call would actually touch: a warning about a document the call does not // open is a warning about nothing (a reject list full of an operator's comments used to raise it for // a call that only approves). warn := func() { rep.CanonicalRewriteDelta = res.DeltaTouched && notCanonical(st.deltaRaw, st.delta) rep.CanonicalRewriteRejects = res.RejectsTouched && notCanonicalRejects(st.rejectsRaw, st.rejects) rep.CanonicalRewrite = rep.CanonicalRewriteDelta || rep.CanonicalRewriteRejects } switch o { case outcomeProjection: rep.Mode, rep.Changed = "projection", files.delta || files.rejects warn() rep.Signature = signatureState(book, st.seed.Terms, docsFromResult(st, res)) case outcomeRefused: // A REFUSED call writes nothing: no re-render to warn about (a destructive-action dialog raised // for a call that wrote nothing is how a real warning gets trained out of an operator — the pure // layer rolls the documents back but leaves *Touched standing as a record of what the fold did), // no accepted work to list (the pure layer already returns none), and the measured state is the // disk's, not the refused result's. rep.Mode, rep.Changed = "refused", false rep.Signature = signatureState(book, st.seed.Terms, docsFromDisk(st)) case outcomeNoop: rep.Mode, rep.Changed = "apply", false warn() // false in practice: a byte-no-op's documents are already in the engine's own form rep.Signature = signatureState(book, st.seed.Terms, docsFromResult(st, res)) case outcomeStopped: // Accepted is cleared like on the refused path: a list of decisions each stamped `state: applied` // is a claim about work, and this call did none — the accepted list is what a consumer iterates. rep.Mode, rep.Changed, rep.Accepted = "stopped", false, []membank.AcceptedDecision{} rep.Signature = signatureState(book, st.seed.Terms, docsFromDisk(st)) case outcomeWritten: rep.Mode, rep.Changed = "apply", true rep.WrittenDelta, rep.WrittenRejects = files.delta, files.rejects warn() rep.Signature = signatureState(book, st.seed.Terms, docsReRead(book)) case outcomeWriteFailed: // Accepted is cleared HERE too, and for the harder reason: with one file landed the list would be // half true, and a consumer recording deliveries from it cannot know which half. The retry // re-sends the same document and converges — the landed half comes back `already_applied`. rep.Mode, rep.Changed = "write_incomplete", files.delta || files.rejects rep.WrittenDelta, rep.WrittenRejects = files.delta, files.rejects rep.Accepted = []membank.AcceptedDecision{} warn() // The POST-state, re-read from disk: for an outcome that touched the world the report describes // the files as they now are, never the bytes the call intended. rep.Signature = signatureState(book, st.seed.Terms, docsReRead(book)) } return rep } // changedFiles opens or closes the write gate for each document. TWO gates PER FILE, and both have to // open before a byte of it is written. // // The first is whether a decision actually changed THAT document. Per-file and not per-call: a decline // changes the reject list, and re-rendering the delta beside it would destroy an operator's formatting // of a file the call had no opinion about. A call whose every decision was already applied opens neither // gate and writes nothing at all. // // The second is the bytes themselves, which is what makes the no-op a BYTE no-op rather than a rewrite // that happens to produce the same content. The bytes are the ones the pure layer already PROVED // readable (membank.ApplyResult.DeltaBytes); rendering them again here would be a second renderer of the // same document, and the one thing this door must never do is write bytes that nothing checked. // // ⚠ NIL BYTES ARE NEVER WRITTEN, and that guard is the difference between a report and a data loss. A // document that could not be RENDERED yields nil bytes, and when that failure is PRE-EXISTING the pure // layer reports it instead of refusing (which is right — a book whose files are already broken must stay // repairable). Without the nil check this saw `DeltaTouched && changedDoc(raw, nil, …)` → true and wrote // a ZERO-BYTE file over every approval the owner had made. func changedFiles(st bookState, res membank.ApplyResult) (delta, rejects bool) { delta = res.DeltaBytes != nil && res.DeltaTouched && changedDoc(st.deltaRaw, res.DeltaBytes, emptyDelta(res.Delta)) rejects = res.RejectBytes != nil && res.RejectsTouched && changedDoc(st.rejectsRaw, res.RejectBytes, len(res.Rejects.Rejects) == 0) return delta, rejects } // writeDecisionFiles performs the mutation: BOTH documents are staged completely — written, synced, // chmodded under temp names — before either rename, so the whole class of environment failures (a full // disk, a permission, an I/O error) is met before a single byte of either document has moved, and a call // it strikes reports «nothing landed», having changed nothing. // // What this is NOT: atomicity of the PAIR against a dying process. The window between the two renames // remains, and closing it needs a journal across two files — a mechanism this door has not earned. The // residue is covered by convergence: the call is all-or-nothing and recomputed from scratch, so a retry // of the same document converges from either half-state, and a killed process prints no report. // ⚠ What a half-state leaves is stated precisely, because the older wording ("every half-state leaves // the term UNDECIDED rather than wrongly decided") stopped being true when the order changed: under // rejects-first an interrupted DECLINE leaves the decision RECORDED while the delta row is still on // disk. That is not "wrongly decided" — it is decided correctly and half-written — and it is only safe // because the bank loader drops a delta row whose surface is on the reject list (mining.go // loadMinedDelta). Without that filter this order would have bought recoverability with a window in // which a paid run injects a term the owner declined. The RENAME ORDER is what makes // that true, and it is REJECTS FIRST. It used to be delta-first with a comment calling the order // arbitrary — "the mirror order swaps which verb loses, and convergence is what actually carries both". // The second half of that sentence was the load-bearing one and it was false for a decline: dropping the // delta row is exactly what makes refuseSeedConflicts fire on the re-send, so delta-first did not swap // which verb loses, it chose the verb that CANNOT recover. The order below is argued in place. // // The per-file return is the report's: which file this call replaced on disk, however far it got. func writeDecisionFiles(book *config.Book, res membank.ApplyResult, deltaChanged, rejectsChanged bool) (writtenFiles, error) { var wrote writtenFiles var deltaStage, rejectsStage *stagedFile var err error if deltaChanged { if deltaStage, err = stageFileAtomic(book.MinedDelta, res.DeltaBytes); err != nil { return wrote, err } } if rejectsChanged { if rejectsStage, err = stageFileAtomic(book.MinedRejects, res.RejectBytes); err != nil { if deltaStage != nil { deltaStage.abort() } return wrote, err } } // REJECTS FIRST, and the order is NOT arbitrary — see the header. Whichever document lands // alone, the re-send has to converge, and only this order lets it for BOTH verbs: // // rejects-first, decline killed between the renames → the reject is recorded and the delta // STILL HOLDS the row, so refuseSeedConflicts stays silent (its own precondition holds) and // the re-send drops the row and no-ops the reject: converged. // rejects-first, approve killed between → the reject is withdrawn and the delta lacks the row, // so the re-send merges the row and no-ops the withdrawal: converged. // delta-first, decline killed between → the row is GONE and no reject was recorded, and the two // files are then byte-for-byte the state in which declining that surface is genuinely inert. // No predicate over them can tell the half-state from the inert one, so the re-send is refused // and the term is stranded neither approved nor declined, with no channel forward. // // That last line is why this is a swap and not a preference: the delta is the document the // seed-conflict refusal READS, so the document that decides must not be the one that lands first. if rejectsStage != nil { if err := rejectsStage.commit(); err != nil { if deltaStage != nil { deltaStage.abort() } return wrote, err } wrote.rejects = true } if deltaStage != nil { if err := deltaStage.commit(); err != nil { return wrote, err } wrote.delta = true } // The decision files are the USER's words, so their durability is not left to the kernel's leisure: // the renames' directory entries are flushed here (both files live in the book's directory — the // paths are conventional). A failure is the write-incomplete class like any other write failure — // the renames are visible but not proven durable, and the report's per-file truth still names what // landed. if err := syncDir(filepath.Dir(book.MinedDelta)); err != nil { return wrote, fmt.Errorf("the files are renamed but their directory entries are not proven durable (a host crash could still lose them): %w", err) } return wrote, nil } // decidedDocs is the pair of decision documents signatureState measures the map against, and whether // they could be obtained at all. Three sources, one per kind of outcome: the would-be result, the disk // as read under the lock, and the disk re-read after a write. type decidedDocs struct { delta []byte rejects map[string]bool broken bool // the documents could not be read or parsed — the numbers cannot be measured } // docsFromResult is the state the call intends: the proven bytes it would write (falling back to the // disk for a document it does not touch) and the result's reject set. func docsFromResult(st bookState, res membank.ApplyResult) decidedDocs { delta := res.DeltaBytes if delta == nil { delta = st.deltaRaw } return decidedDocs{delta: delta, rejects: rejectSurfaces(res.Rejects)} } // docsFromDisk is the state as it was read under the lock — what an outcome that touched nothing // measures against (the refused result's own documents are not the world's). func docsFromDisk(st bookState) decidedDocs { return decidedDocs{delta: st.deltaRaw, rejects: rejectSurfaces(st.rejects)} } // docsReRead is the POST-state: the two files re-read from disk after the write phase, however far it // got. A file that cannot be re-read or re-parsed marks the pair broken — «could not measure», never // zeroes. func docsReRead(book *config.Book) decidedDocs { var out decidedDocs raw, err := os.ReadFile(book.MinedDelta) switch { case err == nil: out.delta = raw case errors.Is(err, fs.ErrNotExist): // absent = no decisions, the same standing readDelta gives it default: out.broken = true return out } rejRaw, err := os.ReadFile(book.MinedRejects) switch { case err == nil: f, perr := seed.DecodeRejects(rejRaw) if perr != nil { out.broken = true return out } out.rejects = rejectSurfaces(f) case errors.Is(err, fs.ErrNotExist): default: out.broken = true } return out } // signatureState measures how much of the last run's signature map the given documents leave undecided. // See SignatureState for what the number does and does not mean. // // The delta bytes go through the real loader, so "approved" here means what it means to the run — // including the loader's default for a row that states no status at all. // // Every failure on this path is silent and leaves the state marked unreadable. It is a report field // about another artifact: a book whose signature map is missing or malformed still has decisions to // apply, and refusing them over a sidecar would be the door failing at its own job for a footnote. func signatureState(book *config.Book, seedRows []store.GlossaryEntry, docs decidedDocs) SignatureState { path := signatureMapPath(book.ProjectDB) raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return SignatureState{} // no run has reached the bank boundary: nothing to count against } // Anything ELSE — a permission, an EIO, a vanished mount, a raced unlink — is a state this door // could not measure, and it must not come back as {surfaces:0, undecided:0}, which is what // «everything is decided» looks like. Same reason as the parse branch below. return SignatureState{Map: absPath(path), Unreadable: true} } if docs.broken { return SignatureState{Map: absPath(path), Unreadable: true} } sig, err := seed.DecodeSignatureMap(raw) if err != nil { // A map that will not parse must NOT come back as {surfaces:0, undecided:0} — byte-identical to // «everything is decided», which is the one answer this field must never give by accident. return SignatureState{Map: absPath(path), Unreadable: true} } var minedRows []store.GlossaryEntry if len(docs.delta) > 0 { bs, perr := membank.ParseEngineBankSeed("mined-delta", docs.delta) if perr != nil { return SignatureState{Map: absPath(path), Unreadable: true} } minedRows = bs.Terms for i := range minedRows { minedRows[i].Source = "mined" // as loadMinedDelta stamps them, so unsignedEngineSurfaces judges them } } // The SAME entry set the run judges: the signed seed AND the delta, filtered by the run's own // "does this row count as banked" rule. A surface the owner has since written into glossary_seed by // hand is handled there, and counting it as undecided here would be a warning about nothing. all := make([]store.GlossaryEntry, 0, len(seedRows)+len(minedRows)) all = append(append(all, seedRows...), minedRows...) handled := ownerHandled(unsignedEngineSurfaces(all), docs.rejects) st := SignatureState{Map: absPath(path)} for _, t := range sig.Terms { nk := text.NormalizeSourceKey(t.Src) if nk == "" { continue } st.Surfaces++ if !handled[nk] { st.Undecided++ } } return st } // capRefusalReport is the report of a document refused at one of the two caps — before the lock, before // any read of the book. Everything below the verdict is unmeasured and says so: the signature block is // marked unreadable when a map exists, never zeroed into «everything is decided». func capRefusalReport(book *config.Book, err error) BankDecisionsReport { rep := decisionVerdict(book, membank.ApplyResult{}) rep.Mode = "refused" rep.Rejected = []membank.RejectedDecision{{Index: -1, Reason: err.Error()}} mapPath := signatureMapPath(book.ProjectDB) if _, statErr := os.Stat(mapPath); statErr == nil || !errors.Is(statErr, fs.ErrNotExist) { rep.Signature = SignatureState{Map: absPath(mapPath), Unreadable: true} } return rep } // TWO caps, because ONE of them cannot bound what matters. The decision document is the only input to // this engine whose size a USER chooses, and the engine had no bound on it at all. // // Why a bound is not optional: the engine's answer to running out of memory is `fatal error: out of // memory` and exit 2, and the platform reads 2 as «the command DID its work, some units need a human» // (platform/internal/ingest/exit.go) — the death of the engine reads as a successful run. That mis-read // is a general engine class and is pinged, not fixed here; these bounds keep this verb from being the // one that reaches it. // // ⚠ THE BYTE CAP DOES NOT BOUND TIME, and a first version of this comment claimed it did. Measured // (`/usr/bin/time -f '%M %e'`, this machine, idle), the cost per decision depends on the SHAPE of the // document, and the cheapest shape is the one a byte cap admits most of — `{"action":"decline", // "src":"t1"}` is ~35 bytes against ~85 for an approval with a note: // // approvals with a note: 1 000 → 0.6 s · 5 000 → 5.2 s · 10 000 → 15.7 s · 20 000 → 50 s // minimal declines: 5 000 → 11.3 s · 12 000 → 58.0 s · 20 000 → 153 s · 28 900 → 308 s // // 28 900 minimal declines are 1 029 369 bytes — UNDER a 1 MiB cap — and take five minutes. The // platform's budget for one engine call is 60 seconds (platform/internal/runs/reconcile.go), so such a // document is killed on every attempt, forever, and the refusal that would have told the caller to split // it never fires — which is why the bound that matters is the COUNT and not the bytes. const ( // maxDecisionsBytes bounds what is READ, before anything is parsed: a pre-parse guard against a file // that is not a decision document at all. Generous on purpose — it is not the interesting bound. maxDecisionsBytes = 1 << 20 // maxDecisions bounds what is APPLIED, and it is the one chosen from time. 5 000 is ~11 s in the // worst shape measured above — five times inside the platform's budget, so a host half this speed // still fits. For scale it is twenty-five signing acts (the miner proposes at most 200 surfaces per // act) and eighty-six times the stand book's whole seed. // // It is low because the pass is Θ(N²) by construction — every per-decision helper scans the whole // document — and raising it means indexing those scans first. That is a ping, not this pack's work. maxDecisions = 5000 ) // readDecisions reads the decision document under the cap. // // Read through a limit rather than stat-then-read: a stat is a fact about the file a moment ago, and the // path may not be a regular file at all (a fifo or a /proc entry stats as zero bytes and then delivers // whatever it likes). Reading one byte past the cap is what tells the two apart. // // Over the cap is the DECISIONS class, not the config one: the document is well-formed and the // deployment is fine — the caller asked for more than this engine will do in one act, and the answer is // to split it. That is a fact about the request, which is what class 14 says. func readDecisions(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, RefuseConfig(fmt.Errorf("pipeline: read decisions %s: %w", path, err)) } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, maxDecisionsBytes+1)) if err != nil { return nil, RefuseConfig(fmt.Errorf("pipeline: read decisions %s: %w", path, err)) } if len(raw) > maxDecisionsBytes { return nil, refuse(RefusalDecisionsRejected, fmt.Errorf( "pipeline: the decision document %s is over %d bytes — more than this engine reads in one act; split it", path, maxDecisionsBytes)) } return raw, nil } // bankRows reads the book's bank AND its captured ruby readings — one open for both, because they are // two halves of the same question ("what does the run see?") and a second open is a second lock dance. // // A project that has never been opened has no database and therefore neither — an ordinary state, and // the same one an absent decision file means: nothing has been decided yet. A term can still be ADDED by // its full tuple; only an `id` has nothing to resolve against. func bankRows(book *config.Book) ([]store.GlossaryEntry, []store.RubyReading, error) { if _, err := os.Stat(book.ProjectDB); errors.Is(err, fs.ErrNotExist) { return nil, nil, nil } s, err := store.OpenReadOnly(book.ProjectDB) if err != nil { return nil, nil, RefuseStoreOpen(err) } defer s.Close() rows, err := s.GlossaryForBook(book.BookID) if err != nil { return nil, nil, fmt.Errorf("pipeline: read the bank of %s: %w", book.BookID, err) } ruby, err := s.RubyReadingsForBook(book.BookID) if err != nil { return nil, nil, fmt.Errorf("pipeline: read the ruby readings of %s: %w", book.BookID, err) } return rows, ruby, nil } // readDelta returns the mined delta as a document and as the bytes on disk. An absent CONVENTIONAL file // is the empty document with nil bytes — "no decisions yet" — while an absent DECLARED one is an error, // the same rule the run path follows (decisionFilePresent). func readDelta(book *config.Book) (seed.File, []byte, error) { present, err := decisionFilePresent(book.MinedDelta) if err != nil || !present { return seed.File{}, nil, err } raw, err := os.ReadFile(book.MinedDelta) if err != nil { return seed.File{}, nil, fmt.Errorf("pipeline: read mined-delta %s: %w", book.MinedDelta, err) } f, err := seed.DecodeFile(raw) if err != nil { return seed.File{}, nil, fmt.Errorf("pipeline: parse mined-delta %s: %w", book.MinedDelta, err) } return f, raw, nil } func readRejects(book *config.Book) (seed.RejectFile, []byte, error) { present, err := decisionFilePresent(book.MinedRejects) if err != nil || !present { return seed.RejectFile{}, nil, err } raw, err := os.ReadFile(book.MinedRejects) if err != nil { return seed.RejectFile{}, nil, fmt.Errorf("pipeline: read mined-rejects %s: %w", book.MinedRejects, err) } f, err := seed.DecodeRejects(raw) if err != nil { return seed.RejectFile{}, nil, fmt.Errorf("pipeline: parse mined-rejects %s: %w", book.MinedRejects, err) } return f, raw, nil } // notCanonical reports whether the file on disk differs from what the engine would write for the SAME // content — an operator's hand-formatting, comments, a different key order. func notCanonical(raw []byte, current seed.File) bool { if raw == nil { return false } rendered, err := membank.RenderSeedFile(current) return err != nil || !bytes.Equal(raw, rendered) } func notCanonicalRejects(raw []byte, current seed.RejectFile) bool { if raw == nil { return false } rendered, err := membank.RenderRejectFile(current) return err != nil || !bytes.Equal(raw, rendered) } // changedDoc reports whether the document on disk has to be replaced. An ABSENT file (raw == nil) is // replaced only by a document that says something: creating an empty one where "nobody has decided // anything" was the state is exactly the file-in-advance this door exists to abolish. func changedDoc(raw, next []byte, resultEmpty bool) bool { if raw == nil { return !resultEmpty } return !bytes.Equal(raw, next) } // emptyDelta reports a seed document with nothing in any of its sections. func emptyDelta(f seed.File) bool { return len(f.Terms) == 0 && len(f.Voices) == 0 && len(f.Addresses) == 0 } // orEmpty renders a nil slice as an empty one, so every list in the report serializes as `[]` and never // as `null`: a consumer iterating it has one shape to handle, and "nothing was refused" is a value // rather than an absence (the discipline BookExport.Chunks already keeps). func orEmpty[T any](v []T) []T { if v == nil { return []T{} } return v } // absPath makes a path readable by a consumer running elsewhere. A failure to resolve leaves the path as // the engine holds it, which is still the truth about where the engine looks. func absPath(p string) string { if abs, err := filepath.Abs(p); err == nil { return abs } return p }