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