textmachine/backend/internal/pipeline/refusal.go

121 lines
6.5 KiB
Go

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.
// 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=<N> expected=<M>` (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"
)
// 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)
}
// 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)
}