textmachine/backend/internal/pipeline/refusal.go

180 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}