textmachine/platform/internal/httpapi/bank.go

437 lines
17 KiB
Go
Raw Permalink 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 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)
}