package pipeline import ( "errors" "textmachine/backend/internal/store" ) // refusal.go: the shell contract's REFUSAL classes (row 165 / PD-196 of the platform). // // The defect this closes was not cosmetic. `tmctl` mapped every failure it did not recognise onto exit // 1, so "this source is unreadable", "this config is broken" and "another process holds the project" // arrived at an automated caller as one number — and the platform's intake, which retries a book five // times and then rejects it as `source_unreadable`, came within one step of deleting a user's upload // because an operator mistyped a key in a hand-written book.yaml. // // The form is a CLASS carried by a typed error, mapped to an exit code by cmd/tmctl. It is a class and // not a code here on purpose: the engine owns the vocabulary, the shell contract owns the numbers, and a // class this build of a reader has never heard of still lands inside the reserved band and reads as // "refused" rather than as "failed" — which is the difference between waiting and destroying data. // // THE BAND HAS TWO TIERS, and the letter of each matters: // // - The FLOOR — what membership in [10,19] alone guarantees — is «no work needs rolling back, and a // retry is SAFE». Safe means "will not harm", never "will help": retrying a broken config forever // helps nobody, but it destroys nothing either. // - EACH CLASS declares its own, stronger invariant on top of the floor, in its own comment. The five // original classes (config, source, lock, schema, decisions) all keep «nothing of the book's state // changed» (bank, decisions, database) — as a clause of the CLASS, not of the band. Stated that // narrowly on purpose: the translate path writes its pre-flight BACKUP before the store open can // refuse 12/13, so «nothing happened at all» is not true of the process even there — a backup is // additive and rotationless, which is what keeps the floor's «no rollback needed» intact. // // GUARDRAIL, now letter rather than habit: a consumer keys a DESTRUCTIVE action (deleting an upload, // dropping a book, rewriting state) on a specific CLASS it knows, never on band membership. Band // membership licenses waiting and retrying, nothing more — a class weaker than «nothing happened» (see // RefusalWriteIncomplete) can join the band without any consumer becoming wrong. // RefusalClass names WHY an invocation was turned down. Values are stable strings: a new class is a new // value here and a new number in cmd/tmctl's table, never a new branch in a consumer. type RefusalClass string const ( // RefusalBadConfig is a configuration this engine will not run: unreadable, unparseable, or invalid. // The operator's file is the thing to fix; the book's source is untouched and blameless. RefusalBadConfig RefusalClass = "config_invalid" // RefusalSourceUnreadable is a book whose SOURCE cannot be read or decoded. This is the one class // that says something about the user's text rather than about the operator's config. RefusalSourceUnreadable RefusalClass = "source_unreadable" // RefusalProjectLocked is another tmctl process owning the project. Nothing is wrong with anything — // the answer is to come back later. RefusalProjectLocked RefusalClass = "project_locked" // RefusalSchemaMismatch is a project database whose schema version is not this binary's. Nothing is // wrong with the book either: the answer is `tmctl migrate` (or, in the other direction, a newer // binary), and the two numbers a caller needs to tell those apart ride the message as the stable // token `schema_mismatch found= expected=` (store.SchemaMismatchError). // // It is its own class because read-only opens never migrate, so an engine upgrade refuses every // existing book until a write command touches it — and the platform's own pre-spawn call is one of // those read-only opens, so that write command never came (the deploy deadlock, backlog row 174). // A caller that can SEE this class self-heals ("caught it → migrate → retry") where it used to stop // the world over what looked like a broken project. RefusalSchemaMismatch RefusalClass = "schema_mismatch" // RefusalDecisionsRejected is a decision document that WAS read as one and that this engine will not // apply: it conflicts with the signed seed, it decides a term twice, it approves a term with no // rendering. Nothing about the deployment is wrong and nothing about the book is wrong. // // It is its own class and not the config one, on the axis that decides what a caller DOES with the // number. Both mean "a human acts", but not the same human: 10 means the OPERATOR fixes a // deployment — the platform's own reader treats it that way mechanically, exempting the failure // from a book's attempt budget as "a condition every book on the host shares" // (platform/internal/ingest/exit.go DeploymentFault) — while this one means the END USER re-decides, // about one document, on one book, with retry futile until they do. Filing it as a deployment fault // would hide a user's own mistake behind an outage that is not happening. // // Safe to add by construction, which is the whole point of a BAND: a reader that has never heard of // the number still sees it inside [10,19] and reads "refused, retry is safe" rather than "failed" // (ingest.Refused → OutcomeRefused), and new flags and codes travel engine-first. RefusalDecisionsRejected RefusalClass = "decisions_rejected" // RefusalWriteIncomplete is a decision document the engine ACCEPTED whose write did not complete — // covering both «nothing landed» (an environment failure met while both documents were still staged // under temp names) and «half landed» (a failure between the two renames). ONE class for both on // purpose: a consumer acts identically — nothing to roll back, re-send the same document — and the // difference rides the report's per-file truth (written_delta / written_rejects), not the number. // // Its invariant is the band FLOOR and deliberately nothing more: this is the one class that CANNOT // promise «nothing happened», which is why the floor is worded as «no rollback needed, retry safe» // rather than as innocence. The retry converges because the call is all-or-nothing and recomputed // from scratch — a landed half reports `already_applied`, an unlanded half is applied (proven by the // acceptance's half-write probe). It is NOT the config class: the deployment is fine and the // document is fine — the disk, at that moment, was not. RefusalWriteIncomplete RefusalClass = "write_incomplete" // RefusalBookIncomplete is a book file `tmctl build` will not write: the book has a HOLE — a unit not // yet translated, a unit whose text was withheld (a substantive flag, D2), a unit missing a member's // worth of text, or translated text the current cut of the book cannot place (ghost rows) — and the // caller did not say `--partial`. The default is the refusal because a reader-facing copy is // fail-closed (D29.1(б)): a file that is silently short of the book is the worst outcome the writer // can produce. The refusal lists every hole (chapter, unit, reason), and `--partial` writes the same // book with the notice on its first page and a mark at every hole (bookbuild.go). // // Nothing was written and nothing of the book's state changed — the class keeps the full «nothing // happened» clause. It is its own class, not the config one: the deployment is fine and the book is // fine; the book is simply not finished, and the caller decides whether an unfinished copy is wanted. RefusalBookIncomplete RefusalClass = "book_incomplete" ) // Refusal is an invocation the engine turned down: nothing reached a provider, nothing was spent, no // work needs rolling back and a retry is safe (the band floor). How much MORE than that holds — for // every class but RefusalWriteIncomplete, «nothing of the book's state changed» — is each class's own // clause, stated with the classes. It says nothing about whether the BOOK is untouched by history: a // resumed run refused at ingest has been paid for before. type Refusal struct { Class RefusalClass err error } func (e *Refusal) Error() string { return e.err.Error() } func (e *Refusal) Unwrap() error { return e.err } // refuse wraps err as a refusal of class c. A nil err is nil: the wrapper never invents a failure. func refuse(c RefusalClass, err error) error { if err == nil { return nil } return &Refusal{Class: c, err: err} } // RefuseConfig classifies a config-load failure for a caller OUTSIDE this package. The pre-flight backup // guard is one: it loads book.yaml before the runner exists, so it — not openRunner — is what a broken // config meets first on the `translate` path, and an unclassified error there would put every refusal // back on exit 1 no matter how carefully the runner classifies its own. func RefuseConfig(err error) error { return refuse(RefusalBadConfig, err) } // RefuseStoreOpen classifies a store-open failure. It is exported and it is the ONE place the mapping // lives, because two callers open a project store: openRunner below, and `tmctl migrate`, which opens // one with no runner at all. A second copy of this switch is how "another process holds it" and "this // binary does not match the schema" drift back into a single exit 1 on one of the two paths. // // An unrecognised failure is returned UNCHANGED rather than swept into the band: the band means "turned // down before doing any work", and a caller destroys or waits on data because of it. func RefuseStoreOpen(err error) error { var mismatch *store.SchemaMismatchError switch { case err == nil: return nil case errors.Is(err, store.ErrLocked): return refuse(RefusalProjectLocked, err) case errors.As(err, &mismatch): return refuse(RefusalSchemaMismatch, err) default: return err } } // refuseSource classifies a failure to obtain the book's text, and it classifies almost all of them as a // CONFIG fault. That is deliberate, and it is the most consequential decision in this file. // // RefusalSourceUnreadable is the verdict an automated intake acts on by DELETING the user's upload. So // it may only be returned for something no configuration knob can explain — and a read failure is not // that: a path that is not there, a permission, an I/O error, a vanished mount all say something about // the deployment (a template naming a filename the upload route did not use, a chown bug) and nothing // about the text. Neither is a DECODE failure, which was the first answer here and is wrong for the same // reason: decoding is driven by the book's declared `encoding` and `source_lang`, so "these bytes are // not text" and "you told me the wrong way to read them" are the same error. Two independent reviews // arrived at that case from opposite directions. // // What is left, and the only thing the engine can assert about the TEXT with no config in the way, is // sourceHasNoContent below: the bytes were read and cut, and there is no book in them. func refuseSource(err error) error { return refuse(RefusalBadConfig, err) } // sourceHasNoContent is the one refusal that IS about the user's text: reading and cutting the source // succeeded and produced nothing to translate. No `encoding`, `source_lang` or path setting explains an // empty result from a successful read, which is what makes it safe to act on. func sourceHasNoContent(err error) error { return refuse(RefusalSourceUnreadable, err) }