package httpapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "textmachine/platform/internal/ingest" "textmachine/platform/internal/runs" ) // Bank is the correction door, as the HTTP layer needs to see it. type Bank interface { ApplyBankCorrections(ctx context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error) } // bank.go: the correction door, `POST /books/{bookId}/bank/corrections` (canon 0.5.0, // §applyBankCorrections) — the HTTP half over runs.ApplyBankCorrections. // // The handler owns the WIRE form and everything the canon orders measured before the engine is // spawned: the strict decode (an undeclared member refuses the request — this schema declares // `additionalProperties: false`, unlike the tolerant JSON routes next door), the whole-form // validation into `400 invalid_request`, the 1 MiB body → `413`, and the 5000-correction ceiling. // The count ceiling is measured HERE on the wire form; the BYTE ceiling is measured twice — the // body cap here, and the RENDERED document against the engine's own cap in runs (the envelope adds // a few bytes over the wire's) — so the engine's copies of both caps, which it classifies as its // refusal class 14, are never reached and the canon's 400/413 words are kept. // maxCorrections is the canon's hard ceiling on one act (§BankCorrectionsRequest, mirrored from the // engine's own maxDecisions — chosen there so one call fits a caller's timeout). const maxCorrections = 5000 // termKinds is the wire's closed `TermKind` vocabulary (canon §TermKind). The engine accepts any // non-empty string through this door; the wire is deliberately narrower — a kind the reading // surface does not know cannot be set through it. var termKinds = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true} type wireCorrectionsRequest struct { BookID *string `json:"book_id"` // Preview is REQUIRED, not defaulted: which of the two acts this call is must be said. Preview *bool `json:"preview"` Corrections []wireCorrection `json:"corrections"` } // wireCorrection is one correction as the wire declares it. Every member is presence-sensitive — // the tuple form requires ALL FOUR of its members precisely so a caller cannot omit `sense` and // silently name a DIFFERENT term (an unknown key legally ADDS one here, so the price of the // omission would be a quiet parallel row, not a refusal). Presence-sensitive means every member // must tell «absent» from «sent as null»: a plain pointer collapses the two, and the schema's // presence rules (`oneOf` on the identity, `not: required` on a decline's dst/kind) are about KEYS, // value irrelevant — a client that serializes every optional field as null walked straight through // them (workflow finding, P9). Hence nullable* for every member, not just the windows. type wireCorrection struct { Action nullableString `json:"action"` ID nullableString `json:"id"` Src nullableString `json:"src"` Sense nullableString `json:"sense"` SinceChapter nullableInt `json:"since_chapter"` UntilChapter nullableInt `json:"until_chapter"` Dst nullableString `json:"dst"` Kind nullableString `json:"kind"` Note nullableString `json:"note"` } // nullableInt tells an ABSENT member from an explicit `null` from a number — the window members are // `integer|null` and required in the tuple form, so all three states carry meaning. type nullableInt struct { Given bool Value *int } func (n *nullableInt) UnmarshalJSON(b []byte) error { n.Given = true if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { return nil } var v int if err := json.Unmarshal(b, &v); err != nil { return err } n.Value = &v return nil } // nullableString is nullableInt's shape for the string members. None of them is `string|null` in // the schema, so `Given && Value == nil` is always a type violation — but it must be SEEN to be // refused, which a *string cannot do. type nullableString struct { Given bool Value *string } func (n *nullableString) UnmarshalJSON(b []byte) error { n.Given = true if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { return nil } var v string if err := json.Unmarshal(b, &v); err != nil { return err } n.Value = &v return nil } // str reads the member's value where validation has already established it is a present, non-null // string. func (n nullableString) str() string { return *n.Value } func (h *v0) bankCorrections(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } body, err := io.ReadAll(r.Body) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { // The canon's 1 MiB document ceiling, measured on the wire BEFORE anything is rendered // or spawned (§applyBankCorrections). The route's body cap is exactly the document cap. Fail(w, r, CodePayloadTooLarge) return } h.log.InfoContext(r.Context(), "the body of a correction request did not arrive", "err", err) Invalid(w, r) return } // STRICT, unlike the run request next door, and the difference is the canon's own: this schema // declares `additionalProperties: false` on both levels — an unknown member is a caller // believing it set something, and the quiet version of that is a correction half-applied. dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() var req wireCorrectionsRequest if err := dec.Decode(&req); err != nil || dec.More() { // dec.More(): the body is ONE document; trailing bytes after it are a second one nobody // will read, which is the same quiet half-belief the strict decode refuses. Invalid(w, r) return } if items := validateCorrections(req, r.PathValue("bookId")); len(items) > 0 { Invalid(w, r, items...) return } receipt, err := h.bank.ApplyBankCorrections(r.Context(), runs.BankCorrectionsInput{ UserID: user, BookID: r.PathValue("bookId"), Preview: *req.Preview, Decisions: renderDecisions(req.Corrections), }) if err != nil { h.bankFail(w, r, err) return } out, err := projectBankReceipt(receipt) if err != nil { // A word of the engine's this build cannot map — the seam's vocabulary moved. Refused rather // than forwarded: an unmapped value here would put a wave name on the wire. h.log.ErrorContext(r.Context(), "the correction receipt could not be projected", "err", err) Fail(w, r, CodeInternalError) return } h.writeJSON(w, r, http.StatusOK, out) } // bankFail maps the door's own refusals; everything else falls through to the shared table. func (h *v0) bankFail(w http.ResponseWriter, r *http.Request, err error) { var refused *runs.ErrBankRefused switch { case errors.As(err, &refused): // All-or-nothing: one refused correction refuses the set, and the refused one is what the // user has to see (canon §applyBankCorrections). The receipt does NOT ride the refusal — // the envelope grows the refusal members instead. p := Problem{Code: CodeBankCorrectionsRefused, Refusals: make([]CorrectionRefusal, 0, len(refused.Refusals))} for _, f := range refused.Refusals { pointer := "" if f.Index >= 0 { pointer = "/corrections/" + strconv.Itoa(f.Index) } p.Refusals = append(p.Refusals, CorrectionRefusal{Pointer: pointer, Detail: f.Reason}) } WriteProblem(w, r, p) case errors.Is(err, runs.ErrBankDocumentTooLarge): // The rendered document is over the engine's byte ceiling — the same fact the body cap // answers, measured on the form the engine actually reads (the envelope's few bytes above // the wire's). One word for one fact: 413, split the document. Fail(w, r, CodePayloadTooLarge) case errors.Is(err, runs.ErrBankIncomplete): // The document was ACCEPTED and did not land whole; the remedy is to re-send the SAME one. // Its own code, because the addressee differs from every other 503: the machine retries // verbatim, no one re-decides. Fail(w, r, CodeBankCorrectionsIncomplete) case errors.Is(err, runs.ErrBankUnavailable): Fail(w, r, CodeServiceUnavailable) default: h.fail(w, r, err) } } // validateCorrections is the canon's whole-form validation, every finding with its JSON Pointer. // The rules are the schema's own (§BankCorrectionsRequest, §BankCorrection); what the schema leaves // to the service — an unknown id, a window that ends before it begins, contradictions the SET // introduces — stays the engine's and comes back as `409 bank_corrections_refused`. func validateCorrections(req wireCorrectionsRequest, pathBook string) []Item { var items []Item switch { case req.BookID == nil || *req.BookID == "": items = append(items, Item{Pointer: "/book_id", Code: ItemMissing}) case *req.BookID != pathBook: // The deliberate second carrier of one fact: a set computed for one book landing in another // is not a mistake anything downstream could notice (canon §BankCorrectionsRequest.book_id). items = append(items, Item{Pointer: "/book_id", Code: ItemMalformed}) } if req.Preview == nil { items = append(items, Item{Pointer: "/preview", Code: ItemMissing}) } switch n := len(req.Corrections); { case n == 0: items = append(items, Item{Pointer: "/corrections", Code: ItemMissing}) case n > maxCorrections: // The count ceiling, measured on the WIRE form before the engine is spawned — the engine // classifies its own copy of this cap as a refusal of the set, the canon says the schema // bound is a 400: split the document. // // And the ceiling is a POINT OF STOPPING, not only an item: walking the elements past it // itemized every flaw of an oversized document, and a megabyte of minimal corrections came // back as tens of megabytes of pointers — a ~100× amplification a caller does not even need // a session for more than once (reviewer finding, P9). Whoever hit the cap gets the cap. items = append(items, Item{Pointer: "/corrections", Code: ItemOutOfRange}) return items } for i, c := range req.Corrections { items = append(items, validateCorrection(c, "/corrections/"+strconv.Itoa(i))...) } return items } func validateCorrection(c wireCorrection, at string) []Item { var items []Item // None of the string members is nullable in the schema: an explicit `null` on any of them is a // type violation, refused as malformed — and refused HERE, before the presence rules below read // `Given`, so a null never doubles as a value. for _, m := range []struct { n nullableString name string }{{c.Action, "/action"}, {c.ID, "/id"}, {c.Src, "/src"}, {c.Sense, "/sense"}, {c.Dst, "/dst"}, {c.Kind, "/kind"}, {c.Note, "/note"}} { if m.n.Given && m.n.Value == nil { items = append(items, Item{Pointer: at + m.name, Code: ItemMalformed}) } } if len(items) > 0 { return items } approve := false switch { case !c.Action.Given: items = append(items, Item{Pointer: at + "/action", Code: ItemMissing}) case c.Action.str() == "approve": approve = true case c.Action.str() == "decline": default: items = append(items, Item{Pointer: at + "/action", Code: ItemMalformed}) } // The identity: an existing row by `id` XOR a term by its FULL tuple — both at once could name // two different terms, and the caller would never learn which one was taken. Presence is the // KEY's presence: an explicit null was already refused above, so it cannot smuggle a member in // or out of either form. tupleGiven := c.Src.Given || c.Sense.Given || c.SinceChapter.Given || c.UntilChapter.Given switch { case c.ID.Given && tupleGiven: items = append(items, Item{Pointer: at, Code: ItemMalformed}) case c.ID.Given: if c.ID.str() == "" { items = append(items, Item{Pointer: at + "/id", Code: ItemMalformed}) } default: // The tuple form, all four members or nothing: a partial key is ANOTHER key, and an unknown // key legally ADDS a term here — so the omission's price would be a quiet parallel row. if !c.Src.Given || c.Src.str() == "" { items = append(items, Item{Pointer: at + "/src", Code: ItemMissing}) } if !c.Sense.Given { items = append(items, Item{Pointer: at + "/sense", Code: ItemMissing}) } items = append(items, requireWindow(c.SinceChapter, at+"/since_chapter")...) items = append(items, requireWindow(c.UntilChapter, at+"/until_chapter")...) } if approve { if !c.Dst.Given || c.Dst.str() == "" { // An approved term with no rendering is not a weak approval — it would fail the next run. items = append(items, Item{Pointer: at + "/dst", Code: ItemMissing}) } if c.Kind.Given && !termKinds[c.Kind.str()] { // The wire's `TermKind` is the READING surface's closed vocabulary; a kind it does not // know cannot be set through this door even though the engine itself would take it. items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed}) } } else if c.Action.Given { // A rendering on a decline says the caller meant to approve, and half of that is not // something to guess at; `kind` is forbidden with it. The rule is about the KEY: a decline // carrying `dst: null` believed it said something about the rendering just as loudly. if c.Dst.Given { items = append(items, Item{Pointer: at + "/dst", Code: ItemMalformed}) } if c.Kind.Given { items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed}) } } return items } func requireWindow(n nullableInt, at string) []Item { switch { case !n.Given: return []Item{{Pointer: at, Code: ItemMissing}} case n.Value != nil && *n.Value < 1: return []Item{{Pointer: at, Code: ItemOutOfRange}} } return nil } // renderDecisions projects the validated wire form into the seam's vocabulary. The `null` window // becomes the seam's 0 and an empty `sense` stays empty — by this point the two forms mean the same // thing to the engine (canon: «null-семантика окна — как у BankTerm; проекция null→0 — платформа»). func renderDecisions(cs []wireCorrection) []ingest.BankDecision { out := make([]ingest.BankDecision, 0, len(cs)) for _, c := range cs { d := ingest.BankDecision{Action: c.Action.str()} if c.ID.Given { d.ID = c.ID.str() } else { d.Src, d.Sense = c.Src.str(), c.Sense.str() if v := c.SinceChapter.Value; v != nil { d.SinceChapter = *v } if v := c.UntilChapter.Value; v != nil { d.UntilChapter = *v } } if c.Dst.Given { d.Dst = c.Dst.str() } if c.Kind.Given { d.Kind = c.Kind.str() } if c.Note.Given { d.Note = c.Note.str() } out = append(out, d) } return out } // The receipt's wire shapes (canon §BankCorrectionsReceipt), an allowlist like every projection. type wireBankReceipt struct { Preview bool `json:"preview"` Changed bool `json:"changed"` Depth string `json:"depth"` Accepted []wireAcceptedCorrection `json:"accepted"` PreexistingFaults int `json:"preexisting_faults"` // Signature is `null` when no run has reached a signing stop yet — nothing to count against. Signature *wireSignatureCount `json:"signature"` } type wireAcceptedCorrection struct { Index int `json:"index"` Action string `json:"action"` ID string `json:"id"` Src string `json:"src"` Dst *string `json:"dst"` State string `json:"state"` // Displaced says the correction overwrote an earlier word — a fact, not an error. The // itemization of WHAT stays server-side: its vocabulary is the engine's free text. Displaced bool `json:"displaced"` } type wireSignatureCount struct { Surfaces int `json:"surfaces"` Undecided int `json:"undecided"` Unreadable bool `json:"unreadable"` } // projectBankReceipt translates the service's receipt into contract words, refusing any value it // cannot map: the engine's vocabulary is wave names, and forwarding an unmapped one would leak the // pipeline's architecture through the one field a screen quotes. func projectBankReceipt(rec runs.BankReceipt) (wireBankReceipt, error) { depth, err := contractDepth(rec.Depth) if err != nil { return wireBankReceipt{}, err } out := wireBankReceipt{ Preview: rec.Preview, Changed: rec.Changed, Depth: depth, Accepted: make([]wireAcceptedCorrection, 0, len(rec.Accepted)), PreexistingFaults: rec.PreexistingFaults, } for _, a := range rec.Accepted { if a.Action != "approve" && a.Action != "decline" { return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the action %q", a.Action) } if a.State != "applied" && a.State != "already_applied" { return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the state %q", a.State) } w := wireAcceptedCorrection{ Index: a.Index, Action: a.Action, ID: a.ID, Src: a.Src, State: a.State, Displaced: len(a.Replaced) > 0, } if a.Action == "approve" { dst := a.Dst w.Dst = &dst } out.Accepted = append(out.Accepted, w) } if rec.Signature != nil { out.Signature = &wireSignatureCount{ Surfaces: rec.Signature.Surfaces, Undecided: rec.Signature.Undecided, Unreadable: rec.Signature.Unreadable, } } return out, nil } // contractDepth translates the engine's depth into the contract's open vocabulary. `edit_wave` is // a WAVE name and forbidden on the wire; `refinement` is the product word with the same two halves // of meaning — applied when a run next refines the text, and the draft is not re-formed. func contractDepth(engine string) (string, error) { if engine == ingest.DepthEditWave { return "refinement", nil } return "", fmt.Errorf("httpapi: the engine's correction depth %q has no contract word", engine) }