package pipeline // 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. // 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" ) // Refusal is an invocation the engine turned down before doing any work of its own: nothing reached a // provider, nothing was spent, and nothing this process would have written was written. It says nothing // about whether the BOOK is untouched — a resumed run refused at ingest has been paid for before — only // that THIS process changed nothing. 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) } // 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) }