180 lines
11 KiB
Go
180 lines
11 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.
|
||
//
|
||
// 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=<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"
|
||
// 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)
|
||
}
|