812 lines
43 KiB
Go
812 lines
43 KiB
Go
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
|
|
// `<project_db>.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
|
|
// `<project_db>.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.ParseBankSeed("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
|
|
}
|