package httpapi import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "log/slog" "mime/multipart" "net/http" "os" "strconv" "strings" "time" "unicode" "unicode/utf8" "textmachine/platform/internal/auth" "textmachine/platform/internal/books" "textmachine/platform/internal/ingest" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/runner" "textmachine/platform/internal/runs" ) // Library is the library surface as the handlers below need it, as an interface, so this package // keeps knowing nothing about SQL. // // Every method but one is a read. The one write is RenameBook, and it lives here rather than behind // a dependency of its own because it is the same object under the same mount: a deployment that can // show a library can rename a book in it, and a second `mounts` predicate would only be able to // disagree with the first. type Library interface { ListBooks(ctx context.Context, userID string, limit int, cursor string) (pgstore.Library, error) GetBook(ctx context.Context, userID, bookID string) (pgstore.Book, *pgstore.Run, error) RenameBook(ctx context.Context, userID, bookID, title string) (pgstore.Book, *pgstore.Run, error) ReadUsage(ctx context.Context, userID string) (pgstore.Usage, error) ListChapters(ctx context.Context, userID, bookID string, limit int, cursor string) (pgstore.ChapterPage, error) ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (pgstore.UnitPage, error) ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.NotePage, error) ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (pgstore.BankPage, error) BankSigningStop(ctx context.Context, userID, bookID string) (pgstore.BankSigningStop, error) ReadStream(ctx context.Context, userID, bookID string) (pgstore.StreamState, error) ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]pgstore.Frame, error) } // Runs is the write side: the run lifecycle, as the HTTP layer needs to see it. type Runs interface { Order(ctx context.Context, userID, bookID string) (runs.Options, error) Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error) Stop(ctx context.Context, userID, runID string) (pgstore.Run, error) Resume(ctx context.Context, userID, runID string) (pgstore.Run, error) } // Intake is the book upload, as the HTTP layer needs to see it. The service takes a READER: the // file is streamed to its place on disk and is never held in this process's memory. type Intake interface { Accept(ctx context.Context, in books.Intake) (pgstore.Book, error) } // contractSurface is EVERY route of the /v0 surface, in one list. // // A list rather than a dozen mux.Handle calls so that "every contract route" is something code can // enumerate: the session guard, the body limit and the tests all walk this, and a route added // without them is not expressible. var contractSurface = []struct { method, path string // mounts says which dependency this route needs. An instance without it leaves the path a guarded // 404 rather than a handler that answers 500 on every call: an instance with no engine binary is a // read replica, not a broken one. mounts func(Deps) bool handler func(*v0) http.HandlerFunc // body overrides the default request limit. Only the upload has one (PD-35/PD-72): a book is tens // of megabytes and every other route carries a few kilobytes of JSON. body func(Deps) int64 }{ // `/capabilities` is answered by every instance, read model or not: it is how a client learns which // contract version it is talking to, and an instance that hid it would be one a client cannot // decide to refuse. {method: "GET", path: "/capabilities", handler: func(h *v0) http.HandlerFunc { return h.capabilities }}, {method: "GET", path: "/books", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBooks }}, {method: "GET", path: "/books/{bookId}", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.getBook }}, // The rename (canon §updateBook, declared since the surface's first version and unmounted until // now — `PATCH` appeared in this file zero times, so the only name a reader ever saw was the one // the intake derived from their file name: unified backlog row 274). {method: "PATCH", path: "/books/{bookId}", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.updateBook }}, {method: "GET", path: "/books/{bookId}/chapters", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listChapters }}, {method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }}, {method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }}, {method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }}, // What the last signing stop asked about (canon 0.16.0, engine backlog row 224). Under the library // mount and not the correction one: reading what a stop asks is not the same capability as being // able to answer it, and a deployment that cannot take corrections still has to show the question. {method: "GET", path: "/books/{bookId}/bank/signing-stop", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.readBankSigningStop }}, // The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for // `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS // the canon's 1 MiB document ceiling, so it rides the default deliberately. {method: "POST", path: "/books/{bookId}/bank/corrections", mounts: hasBank, handler: func(h *v0) http.HandlerFunc { return h.bankCorrections }}, // The stream is registered outside the compression layer — compressing a stream buffers it, which // is the one thing the contract forbids of this route — and that holds structurally: it does not // go through writeJSON, which is where compression lives. {method: "GET", path: "/books/{bookId}/events", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.streamEvents }}, {method: "GET", path: "/usage", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.usage }}, {method: "GET", path: "/books/{bookId}/run-options", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.runOptions }}, {method: "POST", path: "/books/{bookId}/runs", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.startRun }}, {method: "POST", path: "/runs/{runId}/stop", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.stopRun }}, {method: "POST", path: "/runs/{runId}/resume", mounts: hasRuns, handler: func(h *v0) http.HandlerFunc { return h.resumeRun }}, {method: "POST", path: "/books", mounts: hasIntake, handler: func(h *v0) http.HandlerFunc { return h.createBook }, body: func(d Deps) int64 { return d.Upload.MaxBytes }}, // The export door (canon §createExport/§getExport). Unmounted it answers the guarded 404, and // `Capabilities.export_formats` is empty by the SAME fact — a deployment that declared a format // it cannot build would be one whose first click discovers an empty promise. {method: "POST", path: "/books/{bookId}/exports", mounts: hasExports, handler: func(h *v0) http.HandlerFunc { return h.createExport }}, {method: "GET", path: "/books/{bookId}/exports/{exportId}", mounts: hasExports, handler: func(h *v0) http.HandlerFunc { return h.getExport }}, // The artifact itself — the address `Export.url` names. It carries no `operationId` because no // generated client calls it: a browser NAVIGATES to it. It is listed HERE with the rest because // it is a route of this surface, and everything this table gives a route — the session guard, // the body cap, the "no session is 401" pin — is what an unlisted one would silently lack. {method: "GET", path: "/books/{bookId}/exports/{exportId}/content", mounts: hasExports, handler: func(h *v0) http.HandlerFunc { return h.downloadExport }}, } func hasLibrary(d Deps) bool { return d.Library != nil } func hasRuns(d Deps) bool { return d.Runs != nil } func hasIntake(d Deps) bool { return d.Intake != nil } func hasBank(d Deps) bool { return d.Bank != nil } func hasExports(d Deps) bool { return d.Exports != nil } // contractRoutes registers the /v0 surface. // // Every path is written with the version prefix in the PATTERN and registered on the same mux as the // ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and // the pattern never comes back, which would put raw paths carrying book ids into the access log. func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) { h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, bank: d.Bank, exports: d.Exports, upload: d.Upload, caps: d.Capabilities, keys: d.Keys, log: d.Log} for _, r := range contractSurface { if r.mounts != nil && !r.mounts(d) { continue } limit := int64(DefaultMaxBody) if r.body != nil { limit = r.body(d) } mux.Handle(r.method+" "+APIPrefix+r.path, guard(limit, r.handler(h))) } } type v0 struct { lib Library runs Runs intake Intake bank Bank exports Exports upload UploadLimits caps Capabilities keys IdempotencyKeys log *slog.Logger } // The wire shapes below are the contract's, field for field. They are written out // rather than generated because a generated struct would still need the projection written by hand, // and a second copy of the field names is what makes a divergence invisible. // // Every one of them is an ALLOWLIST: a field not named here never reaches a client, and neither does // a word from inside the translation machinery — which is why the projections below translate // vocabularies instead of forwarding them. // // As of 0.6.0 no member here is ahead of the announced canon: `Run.stop_requested` landed in // 0.4.0's Run (0.5.0 keeps it required), and the through-run bar with its `stage` member is what // 0.6.0's Progress declares. // `Progress` is the canon's through-run bar (0.6.0, D39.160): one monotonic fraction across both // waves, and the `stage` caption derived by the PLATFORM from the same counters as the bar — // never a forwarded engine string. `stage` is an OPEN vocabulary by the canon's own second // exception: a client renders an unknown value neutrally, and new values do not move the version. type wireProgress struct { Done int `json:"done"` Total int `json:"total"` Stage string `json:"stage"` ETASeconds *int `json:"eta_seconds"` } type wireBook struct { ID string `json:"id"` Revision int64 `json:"revision"` Title string `json:"title"` SourceLang string `json:"source_lang"` TargetLang string `json:"target_lang"` Status string `json:"status"` RejectReason *string `json:"reject_reason"` StructureVersion int `json:"structure_version"` ShapeEpoch int `json:"shape_epoch"` ChapterCount int `json:"chapter_count"` ChaptersDone int `json:"chapters_done"` CharacterCount *int64 `json:"character_count"` // CharacterCountExact says WHICH number `character_count` is, and it exists because for two of // the three source shapes this platform accepts the old one was not a character count at all: an // EPUB is a ZIP archive and the intake counted the non-continuation bytes of the COMPRESSED // stream, which is a property of the container and not of the same order of magnitude (unified // backlog row 282). The engine's manifest now carries the real figure — runes of the ingested // text, spaces included — and it is preferred whenever it exists. // // ⚠ A FLAG BESIDE THE NUMBER, and deliberately NOT a changed meaning for `null`: `null` already // says «the book is still arriving», and re-using it for «we do not trust the number» would // destroy a meaning the contract already promises. Form ratified by the orchestrator, act // D39.201 §5(б), on two lawful shapes the zone put up. CharacterCountExact bool `json:"character_count_exact"` // Structure is where the chapter boundaries came from, in the ENGINE's own word, passed through // untouched. A client needs it to know what a chapter NUMBER is worth before it offers an order // phrased in one — see OrderOptions.chapter_orders. // // ⚠ THE VOCABULARY IS OPEN AND GROWS ADDITIVELY, so this comment does not list it: an earlier // edition named three words, the engine shipped a fourth (`delimited`, 06.09), and the list went // stale in place while reading like a promise. The canon holds the words; this holds the shape. // // ⛔ NULL IS NOT A WORD OF THAT VOCABULARY AND NOT `none`. `none` is a book the engine LOOKED at // and found one chapter in — a statement about the book. Null is the absence of any statement: // no manifest has been read. ⚠ That is NOT the same as «still arriving»: a book can be fully here // and still answer null, because the cut has not been run. Pinned by // TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull, whose book is `not_started`. Structure *string `json:"structure"` AddedAt time.Time `json:"added_at"` NoteCount int `json:"note_count"` } type wireBookPage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` Books []wireBook `json:"books"` } type wireRun struct { ID string `json:"id"` BookID string `json:"book_id"` Revision int64 `json:"revision"` Status string `json:"status"` StopForSigning bool `json:"stop_for_signing"` StopRequested bool `json:"stop_requested"` // OrderedChapters, OrderedUnits and DeliveredChapters are what the run bought and what it has // handed over. `ordered_chapters` is what `ceiling_chapters` was called until 0.11.0, renamed // because the word «ceiling» belonged to money and this number never was money — it is how much // BOOK was sold. // // ⛔ EXACTLY ONE OF THE FIRST TWO IS SET, and which one says what unit this run is measured in — // its bar, its delivery, all of it. A run sold in chapters carries `ordered_chapters`; one sold in // CHARACTERS carries `ordered_units` and a null here, because the chapter figure such a run used // to publish was the SPAN it reached into and overstated what was bought by up to a whole chapter. OrderedChapters *int `json:"ordered_chapters"` // OrderedUnits is how much a run sold in CHARACTERS bought, in the engine's own output units, and // null for a run sold in chapters. ⚠ It is not the number of characters the buyer typed: that // figure is resolved into units when the order is placed and is not stored. OrderedUnits *int `json:"ordered_units"` // DeliveredChapters is null for a run whose order was phrased in CHARACTERS. Such a run is // measured in units end to end, and `0` here would read as «nothing happened» while work is being // done and paid for; its progress is the `progress` pair, counted in the unit it was sold in. // // ⚠ NULL IS THE DISCRIMINATOR THE WIRE ACTUALLY HAS, and it is the only one: `ordered_chapters` is // a positive span for a character order too, so it separates nothing. Null here means «read the // bar as units»; `0` with `ordered_chapters: 0` means a re-pass. DeliveredChapters *int `json:"delivered_chapters"` // TermConsistencyFunded is the order form's promise, kept after the click: whether THIS run's // reservation has room for the book-wide pass that keeps a book's terms consistent. The form // answers the same question before the click, and the balance can move between the two. TermConsistencyFunded bool `json:"term_consistency_funded"` Progress wireProgress `json:"progress"` // ProgressLagging says the numbers above are behind the run, and the run is going on. It answers // what no number in `progress` can: that they stopped moving for a reason of OURS, not the book's. // // ⚠ ALWAYS SENT, `false` when the projection is keeping up — absence would be a second way of // writing false. And it is a statement about THIS PLATFORM's projection, never about anybody // having touched the book: the commonest condition behind it cannot tell a foreign writer from // this very run writing under a fresh stream id (runs/reconcile.go, `may be this very run, alive // and writing`), so no wording built on this field may claim interference. ProgressLagging bool `json:"progress_lagging"` // RebillConsentMicroUSD is what continuing this run is permission FOR: what it may spend again on // work already paid for. Null where no such consent stands. // // The figure is the run's own hold, and that provenance is the point of showing it: the number a // user signs is the one the platform can fund, not a projection of what re-making the corrected // text would cost — that projection does not exist on this side of the seam. RebillConsentMicroUSD *int64 `json:"rebill_consent_micro_usd"` PausedReason *string `json:"paused_reason"` FailureReason *string `json:"failure_reason"` StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at"` } type wireBookDetail struct { Revision int64 `json:"revision"` Book wireBook `json:"book"` Run *wireRun `json:"run"` } // wireEstimate is the PAIR a buyer is shown, and it is a pair because a projection has spread: «we // expect this to cost about X, we will reserve up to Y, and you are billed by fact» (D39.196 §1, // unified backlog row 276). A single number would become «you said 1.14 and took 1.31». type wireEstimate struct { ExpectedMicroUSD int64 `json:"expected_micro_usd"` HoldMicroUSD int64 `json:"hold_micro_usd"` } // wireMoneyLimit is the money slider's own bounds. The minimum is the engine's INDIVISIBLE step, so // the slider cannot express an order no run could move under; the maximum is the balance as it is. // // ⚠ IT IS NOT A RANGE, and a client must not read it as one: on an account that cannot afford a // single reservation the minimum is ABOVE the maximum, which is not a defect but the honest shape of // «nothing here is buyable». `order.verdict` says so in a word — `covers_none` — and that is what a // client renders instead of a slider. The retired CeilingBounds carried the same caveat in the canon // for the same reason. type wireMoneyLimit struct { MinMicroUSD int64 `json:"min_micro_usd"` MaxMicroUSD int64 `json:"max_micro_usd"` } // wireOrderOptions is what the buyer is told BEFORE the click. type wireOrderOptions struct { ChaptersLeft int `json:"chapters_left"` AffordableChapters int `json:"affordable_chapters"` // Verdict answers the buyer's real question rather than handing them arithmetic to do: // `covers_all`, `covers_part`, `covers_none`. Verdict string `json:"verdict"` // ChapterOrders says whether an order may be phrased in CHAPTERS at all. FALSE means this book // takes an order in CHARACTERS, or the whole book, and `structure` says why — a client that // offered a chapter slider anyway would be selling a number that does not name what the reader // thinks it names. ChapterOrders bool `json:"chapter_orders"` // Structure is where the cut came from, verbatim, so the client can SAY it: null when no manifest // has answered yet. Structure *string `json:"structure"` // SourceChars is how much text is left to translate, in runes — the unit of a character order. SourceChars int64 `json:"source_chars"` // TermConsistencyFunded says whether this order's reservation has room for the BOOK-WIDE pass // that keeps a book's terms consistent, on top of the chapters themselves. // // ⚠ FALSE IS NOT AN ERROR AND IT MUST NOT BE SILENT — which is why it is a member of its own // rather than folded into `verdict`. The pass DEGRADES rather than halts when the ceiling refuses // it, so the book still arrives and only its terms wander; a buyer who is not told pays and // discovers it in the text. Consistency of terms across a book is the owner's first stated // priority (D39.198), and a refusal that takes the shape of silence is indistinguishable from // normal work (D39.202 §3). Ratified 05.09 by the orchestrator. TermConsistencyFunded bool `json:"term_consistency_funded"` // Estimate is the quote for the DEFAULT order: everything that is left of this book. Estimate wireEstimate `json:"estimate"` } type wireRunOptions struct { Order wireOrderOptions `json:"order"` // BalanceMicroUSD is the account's money AS IT IS — holds already debited from it. BalanceMicroUSD int64 `json:"balance_micro_usd"` Limit wireMoneyLimit `json:"limit"` Blocked *Blocked `json:"blocked"` } // wireRunRequest is one purchase. THE THREE KINDS OF ORDER (D39.196 §1) plus the re-pass, and none // of them is guessed: every member is a pointer, so «absent» is a fact and not a zero. // // With neither `chapters` nor `characters` the order is THE WHOLE BOOK, which is the ratified // default — «не тронута ни одна ручка ⇒ заказ = вся книга». They are mutually exclusive, and so is // `re_pass`, which buys no volume at all. type wireRunRequest struct { StopForSigning *bool `json:"stop_for_signing"` Chapters *int `json:"chapters"` Characters *int64 `json:"characters"` RePass *bool `json:"re_pass"` // RetiredCeilingChapters is `ceiling_chapters` — the member this minor RENAMED — and it is read // for one reason: to REFUSE it. // // ⛔ THE «IGNORE UNKNOWN PROPERTIES» RULE DOES NOT COVER A RETIRED ONE, and the difference is a // purchase. That rule exists so a client generated against a LATER minor is not broken by a field // this build has never heard of; ignoring one from an EARLIER minor is the opposite trade. A // 0.10.0 client sends `{"stop_for_signing":false,"ceiling_chapters":60}` meaning «sixty chapters», // and to a reader that ignores it that request says «no volume named» — which is now the ratified // default for THE WHOLE BOOK. The client asks for sixty chapters and is charged a hold on the // entire remainder, silently, with a 202 in its hand. // // So it is 400 with the member named. The minor is a breaking change by design (canon // §Versioning: inside major 0 a differing minor carries them), and a client that has not moved // deserves to be told which field moved rather than to be quietly re-quoted. RetiredCeilingChapters *int `json:"ceiling_chapters"` } type wireUsage struct { State string `json:"state"` RemainingPercent int `json:"remaining_percent"` HaltReason *string `json:"halt_reason"` } func (h *v0) listBooks(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } lib, err := h.lib.ListBooks(r.Context(), user, h.pageLimit(r), r.URL.Query().Get("cursor")) if err != nil { h.fail(w, r, err) return } out := wireBookPage{Revision: lib.Revision, Books: make([]wireBook, 0, len(lib.Books))} if lib.NextCursor != "" { out.NextCursor = &lib.NextCursor } for _, b := range lib.Books { out.Books = append(out.Books, projectBook(b)) } h.writeJSON(w, r, http.StatusOK, out) } func (h *v0) getBook(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } book, run, err := h.lib.GetBook(r.Context(), user, r.PathValue("bookId")) if err != nil { h.fail(w, r, err) return } // ONE counter per book (canon §Revision: every book-scoped read and every frame of that book // carry the same number). The store answers a run's revision from its BOOK on every path that // hands one out — the card here, and the stop and resume handles — so this layer projects what it // was given rather than carrying a second copy of the rule. out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)} if run != nil { wr := projectRun(*run) out.Run = &wr } h.writeJSON(w, r, http.StatusOK, out) } // updateBook is the canon's `updateBook`: a merge patch (RFC 7386) whose only member is `title`. // // ⚠ WHAT A RENAME REACHES, AND WHAT IT MUST NOT. The canon is explicit — «A title is DISPLAY and // reaches nothing else — not the translation, whose configuration is written once at intake and // never rewritten» — and the engine's own code says why that is the only affordable reading and not // merely the tidy one: `title` is folded into the brief hash (backend/internal/config/book.go, // BriefHash), which is the first field of the snapshot payload, so a title written into a book's // `book.yaml` moves every request hash and makes the next run of an unfinished book refuse until the // whole book is re-bought. What the user DOES see change is everything the platform owns: the card, // the library listing, and the file name of every export built afterwards (exportName in // exports.go) — which is the name that lands in their downloads folder. // // ⚠ ALREADY-BUILT EXPORTS NEED NOTHING DONE TO THEM — not rebuilt, not marked, not expired. The // name a reader's browser saves is computed AT DOWNLOAD from the book's current title // (exports.go, `exportName`, called inside `downloadExport`), so it is not baked into the artifact // at all: an export built yesterday under the old name arrives today under the new one. Measured // live, because "it should follow from the call site" is not a decision: an export was built under // one title, the book renamed after it, and the same artifact downloaded again — // `filename*=UTF-8”Имя ПОСЛЕ сборки.txt`. This answers the fork the pack posed (rebuild, mark, or // leave) with the only one of the three that costs nothing and can be checked. // // ⚠ The title INSIDE the file (`dc:title`) is not this handler's to move, and that is a boundary // rather than an omission: the engine reads it from `book.yaml` at build time, and the owner decided // on 05.09 that a book's name reaches the reader's copy through a translation stage of its own with // the owner's signature (unified backlog row 284, which names this row's own 274 as its kin). A // second delivery path built here would be a second writer of one field. // // The request's Content-Type is not inspected, like every other body on this surface: a refusal this // surface has never made is not one to invent for a route whose canon lists no `415`. func (h *v0) updateBook(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // Decoded into raw members rather than a struct, because a merge patch needs all THREE states a // struct cannot give: absent (leave alone), null (RFC 7386 says "remove", which this surface // narrows to a refusal), and a value. A `*string` collapses the first two. var patch map[string]json.RawMessage body, err := io.ReadAll(r.Body) if err != nil { h.log.InfoContext(r.Context(), "the body of a book patch did not arrive", "err", err) Invalid(w, r) return } if err := json.Unmarshal(body, &patch); err != nil { Invalid(w, r) return } if patch == nil { // A body of the bare literal `null` decodes into a NIL map without error, and every member // then reads as absent — so it would have been answered 200 as an empty patch. The canon // declares this body `required: true` with `schema: BookPatch` (`type: object`), and `null` is // not an object: it is the one JSON value that means "no document", which is exactly what a // required body may not be. Measured before it was fixed: `curl -X PATCH -d null` → `200`. Invalid(w, r) return } raw, present := patch["title"] if !present { // A patch that changes nothing is not an error and does not move the revision: «a member // absent from the patch is left alone». The card is answered as a read of it would answer. book, _, err := h.lib.GetBook(r.Context(), user, r.PathValue("bookId")) if err != nil { h.fail(w, r, err) return } h.writeJSON(w, r, http.StatusOK, projectBook(book)) return } title, item := patchedTitle(raw) if item != nil { Invalid(w, r, *item) return } book, _, err := h.lib.RenameBook(r.Context(), user, r.PathValue("bookId"), title) if err != nil { h.fail(w, r, err) return } h.writeJSON(w, r, http.StatusOK, projectBook(book)) } // patchedTitle judges the patch's `title` member and answers either the new name or what is wrong // with it. // // The bounds are the canon's own (`BookPatch.title`: minLength 1, maxLength 200) and the maximum is // the intake's constant rather than a second copy of the number — the two describe one column. // // ⚠ Refused rather than repaired, in both directions, and that is the difference between this and // the intake: the intake TRUNCATES a name it derived from a file, because the user did not choose it // and there is nothing to refuse. Here the user typed it, and silently storing something other than // what they typed would answer a card they did not ask for. func patchedTitle(raw json.RawMessage) (string, *Item) { // `null` FIRST, because RFC 7386 gives it a meaning this surface deliberately does not grant: // "remove this member". A book without a title is not a state this API has — the field is // required on `Book`, and the intake fills it from the file name when nobody gives one — so // there is nothing to grant, and quietly restoring the derived name would be a rename the user // never asked for (canon §updateBook). if string(raw) == "null" { return "", &Item{Pointer: "/title", Code: ItemMalformed} } var title string if err := json.Unmarshal(raw, &title); err != nil { return "", &Item{Pointer: "/title", Code: ItemMalformed} } if strings.TrimSpace(title) == "" { // A name of spaces satisfies `minLength: 1` and is still a blank row in a library. Refused, // not trimmed: see above. return "", &Item{Pointer: "/title", Code: ItemMalformed} } if i := strings.IndexFunc(title, unwritableInATitle); i >= 0 { // ⚠ THIS ONE IS NOT TASTE, IT IS A 500 THIS SURFACE WOULD OTHERWISE ANSWER. Measured live // before the guard: `{"title":"be\u0000fore"}` reached Postgres, which cannot store U+0000 in // a `text` column, and the client got `500 internal_error` — a code this operation's canon // does not list and which puts an ERROR line in the deployment's log for a request the caller // simply got wrong. return "", &Item{Pointer: "/title", Code: ItemMalformed} } if utf8.RuneCountInString(title) > books.MaxTitle { return "", &Item{Pointer: "/title", Code: ItemTooLong} } return title, nil } // unwritableInATitle reports a rune that must not appear in a book's display name. // // CONTROL characters (Unicode Cc — which is C0 and C1, so NUL, the newline, the escape) and the two // line separators U+2028/U+2029. A title is one line of text on a shelf; none of these can be part of // a name, U+0000 cannot even be stored, and the rest are how a name comes to overwrite what is // printed beside it. // // ⚠ AND DELIBERATELY NOT THE FORMAT CATEGORY (Cf). U+200E/U+200F and ZWJ/ZWNJ are ordinary content in // Hebrew, Arabic, Devanagari and Persian, and banning "everything invisible" here would quietly // break a language pair this repository does not contain yet — which is the generality invariant, not // a hypothetical. The engine's own inbound fence draws the line in the same place and for the same // reason (backend/internal/membank, the wire fence: controls and bidi OVERRIDES refused, bidi MARKS // allowed). func unwritableInATitle(r rune) bool { return unicode.IsControl(r) || r == '\u2028' || r == '\u2029' } func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } bookID := r.PathValue("bookId") opts, err := h.runs.Order(r.Context(), user, bookID) if err != nil { h.fail(w, r, err) return } out := wireRunOptions{ Order: wireOrderOptions{ ChaptersLeft: opts.ChaptersLeft, AffordableChapters: opts.AffordableChapters, Verdict: opts.Verdict, ChapterOrders: opts.ChapterOrders, SourceChars: opts.SourceChars, TermConsistencyFunded: opts.Whole.BondFunded, Estimate: wireEstimate{ ExpectedMicroUSD: int64(opts.Whole.Expected), HoldMicroUSD: int64(opts.Whole.Hold), }, }, BalanceMicroUSD: int64(opts.Balance), Limit: wireMoneyLimit{ MinMicroUSD: int64(opts.MinHold), MaxMicroUSD: int64(opts.Balance), }, } if opts.Structure != "" { structure := opts.Structure out.Order.Structure = &structure } // Why a start is not on offer as the balance alone would suggest — and the two reasons are // ordered, because only one of them can be carried. // // A run of this book's OWN comes first: it is not a shorter scale but no start at all, and it is // the answer the door will give whatever the money says. Without it the form put `covers_all` in // front of a buyer whose click the door was already going to refuse (PD-455). Another book's hold // is the second: there a start IS on offer, only shorter than the account could otherwise afford, // and naming that book is what lets a client offer the user somewhere to go (canon // §RunOptions.blocked). // // ⛔ AND A REFUSAL THIS SURFACE HAS NO WORD FOR IS SAID OUT LOUD RATHER THAN DROPPED. The predicate // is one definition with two bindings, but the mapping from its answer to a wire member is a // SECOND, hand-kept thing: a refusal added to it tomorrow would reach the door and leave this form // saying `covers_all`, which is PD-455 all over again and silent. It cannot be a compile error — // the predicate answers an `error` — so it is an operator's line instead. if opts.Refusal != nil && !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) { h.log.ErrorContext(r.Context(), "the order form has no word for a refusal admission would give", "err", opts.Refusal) } // ⚠ The door's other refusals have no line here because this path cannot reach them: an unpriced // book is answered `not_priced` above, and a book that is not ready to be translated — still // arriving, still being cut, rejected, or owing its chapter tree — is not priced either, since one // transaction of the materializer writes the price and the tree together (pgstore/readmodel.go, // `update books set … expected_micro_usd …` beside writeChapters). A word invented for them here // would be a wire value nothing can produce. switch { case errors.Is(opts.Refusal, pgstore.ErrRunInFlight): out.Blocked = &Blocked{Code: CauseRunInFlight, BookID: bookID} case opts.BlockedBy != "": out.Blocked = &Blocked{Code: CauseCreditHeld, BookID: opts.BlockedBy} } h.writeJSON(w, r, http.StatusOK, out) } func (h *v0) startRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // Unknown properties are IGNORED, not refused: RunRequest does not declare // additionalProperties: false, and inside 0.x a minor bump is where optional fields appear — a // server that rejected the whole request would break a client generated against a later contract // for a field it was free to ignore. // Read whole rather than streamed: the bytes are also this request's idempotency fingerprint. // // ⚠ A body that does not arrive — over the route's cap, or cut off — is `400`, like every other // malformed JSON request and like the JSON write next door. It answered `413 payload_too_large`, // a code the canon does not list among this operation's responses and defines as "over // `intake_max_bytes`" — which is about an UPLOAD, and nothing here is one. var req wireRunRequest body, err := io.ReadAll(r.Body) if err != nil { h.log.InfoContext(r.Context(), "the body of a run request did not arrive", "err", err) Invalid(w, r) return } if err := json.Unmarshal(body, &req); err != nil { Invalid(w, r) return } rePass := req.RePass != nil && *req.RePass if req.StopForSigning == nil { Invalid(w, r, Item{Pointer: "/stop_for_signing", Code: ItemMissing}) return } // ⚠ NOTHING IS REQUIRED BESIDES THAT, and the change is the order form's (D39.196 §1): a request // naming no volume is the WHOLE BOOK, which is the ratified default rather than an omission. What // `ceiling_chapters` used to guard — «a run started without a declared ceiling spends past the // limit the user is entitled to set» — is now guarded by the thing that always guarded the money: // the hold, which is computed from the order the server itself resolved. // // The kinds of order are mutually exclusive, and both at once is 400 rather than a guess: which // of the two the caller meant is exactly the quiet half-belief this surface refuses elsewhere. if req.RetiredCeilingChapters != nil { // Named, not silently ignored — see wireRunRequest.RetiredCeilingChapters. Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemMalformed}) return } if req.Chapters != nil && req.Characters != nil { Invalid(w, r, Item{Pointer: "/characters", Code: ItemMalformed}) return } if rePass && (req.Chapters != nil || req.Characters != nil) { // A re-pass buys no volume, so a volume beside it would describe nothing. pointer := "/chapters" if req.Characters != nil { pointer = "/characters" } Invalid(w, r, Item{Pointer: pointer, Code: ItemMalformed}) return } // The schema's own minimums belong HERE and not in the service: a request that violates the // schema is malformed, and answering it with 409 would tell the client the options had moved — so // it would re-read run-options and retry, forever, a request that can never succeed. if req.Chapters != nil && *req.Chapters < 1 { Invalid(w, r, Item{Pointer: "/chapters", Code: ItemOutOfRange}) return } if req.Characters != nil && *req.Characters < 1 { Invalid(w, r, Item{Pointer: "/characters", Code: ItemOutOfRange}) return } // Fingerprinted over the BODY: this request is small and entirely declared, so "the same // request" is decidable exactly. key, ok := h.beginIdempotent(w, r, user, body, fingerprintIsTheRequest) if !ok { return } defer key.release(r.Context()) // every exit settles the key; `complete` below cancels it in := runs.StartRequest{ UserID: user, BookID: r.PathValue("bookId"), VerifyBank: *req.StopForSigning, RePass: rePass, } if !rePass { in.Chapters, in.Characters = req.Chapters, req.Characters } run, err := h.runs.Start(r.Context(), in) if err != nil { h.fail(w, r, err) return } key.complete(r.Context(), http.StatusAccepted, "", h.writeJSON(w, r, http.StatusAccepted, projectRun(run)), nil) } // stopRun is the product "stop" action (canon §stopRun). The engine stops gracefully on a signal // and the reconciler turns the exit into a status; what this call does is record that the stop was // OURS — which is the only way the exit can afterwards be told from a crash (register row PD-152). func (h *v0) stopRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } run, err := h.runs.Stop(r.Context(), user, r.PathValue("runId")) if err != nil { h.fail(w, r, err) return } h.writeJSON(w, r, http.StatusAccepted, projectRun(run)) } // resumeRun continues a run that was stopped (canon §resumeRun). func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId")) if errors.Is(err, runs.ErrSourceGone) { // The same fact as at admission, in THIS operation's own vocabulary. `book_not_ready` is the // word for a book that is still arriving or was rejected, and it is what a start answers; here // the caller is asking about a RUN, and what it is being told is that this run cannot be // continued — with the cause that says why waiting will not help. It is the shape the canon // already uses for the two axes that override its status table (`ceiling_reached`, // `credit_unavailable`): the same code, a narrower cause. h.log.ErrorContext(r.Context(), "resume refused: the book's source directory is gone", "err", err) FailCause(w, r, CodeRunNotResumable, CauseSourceGone) return } if err != nil { h.fail(w, r, err) return } h.writeJSON(w, r, http.StatusAccepted, projectRun(run)) } // maxIntakeField bounds one text field of the intake form, and maxIntakeParts how many parts may // arrive before the file. Neither is the contract's business: they are what keeps a form with a // megabyte-long title, or a hundred thousand empty parts, from being work this process does. const ( maxIntakeField = 1 << 10 maxIntakeParts = 16 ) // createBook receives a book (canon §createBook). // // It is the only route on this surface that streams. The body is read part by part through // r.MultipartReader and the file is handed to the intake as a READER, so a 60 MB upload costs a // buffer and not 60 MB of this process — which is what ParseMultipartForm, the reflex alternative, // would have cost in memory or in a second copy through a temporary file. // // ⚠ The FILE PART MUST COME LAST, and since 0.3.0 that is the contract's rule and not only this // implementation's: a streaming reader hands parts over in wire order, and the book's row — which is // what makes an upload visible while it arrives and findable when it dies halfway — cannot be // written before the languages that row requires. A part sent after the file is REFUSED and never // ignored (canon §createBook), which is the half 0.2.3 got wrong: it was legal to lose one silently. func (h *v0) createBook(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } // A book is minutes of upload on a domestic connection, and the server's ReadTimeout — the whole // of what bounds a half-fed request (PD-2) — covers the entire body. It is EXTENDED here for this // route and never cleared: net/http's own documentation points at a per-request deadline for // exactly this case, and clearing one instead is the mistake that re-created PD-2 (STACK §12). if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(h.upload.Deadline)); err != nil { // Not fatal: a ResponseWriter that cannot carry a deadline is a test double, and the real // server's own timeout then still applies. h.log.DebugContext(r.Context(), "upload deadline not extended", "err", err) } parts, err := r.MultipartReader() if err != nil { Invalid(w, r) return } in := books.Intake{UserID: user} titleGiven := false // The file's own digest, taken as the bytes go past on their way to disk. It is what "the same // request" means for this route, and it is why a repeat is read before it is answered. digest := sha256.New() // Inactive until claimed; deferred from here so every exit settles it. `complete` cancels it. key := &idempotent{} defer func() { key.release(r.Context()) }() for n := 0; ; n++ { if n >= maxIntakeParts { // No field to point at: a form with too many parts is reported by the root code alone // (canon §ErrorItem). Invalid(w, r) return } part, err := parts.NextPart() if errors.Is(err, io.EOF) { // Every field and no file: the form the contract requires was not sent. Invalid(w, r, Item{Pointer: "/file", Code: ItemMissing}) return } if err != nil { h.uploadFailed(w, r, err) return } if part.FormName() == "file" { if bad := missingIntakeFields(in, titleGiven); len(bad) > 0 { // Either those fields were not sent, or they were sent AFTER the file — and the second // is indistinguishable from the first to a reader that streams, which is exactly what // the contract's `missing_or_late` says. Invalid(w, r, bad...) return } in.Filename = part.FileName() // The FILE is digested as it goes past, and that digest is what settles "the same request" // for this route: the form declares its parts and not its bytes, so two different books // arrive under one key with the same declaration. in.File = io.TeeReader(part, digest) // The claim is taken HERE — after the declared parts are known and before a single byte of // the file is written. Later would create the duplicate the key exists to prevent; earlier // would have nothing to fingerprint. claimed, ok := h.beginIdempotent(w, r, user, intakeFingerprint(in, titleGiven), bodyDecidesIdentity) if !ok { return } key = claimed if key.replay != nil { // A repeat of a completed attempt. Its bytes are read and thrown away — nothing is // written and no book is created — and only then is it decided whether this is the // same request or another one wearing its key. if _, err := io.Copy(io.Discard, in.File); err != nil { // The body did not arrive, so identity cannot be established. Whatever else is true, // the stored answer is not owed to a request nobody could read. h.uploadFailed(w, r, err) return } // A part after the file is refused HERE too. It is refused for a fresh key, and a repeat // that carried one and was answered 201 would make the same body legal or not depending // on which key it wore. switch next, err := parts.NextPart(); { case err == nil: Invalid(w, r, Item{Pointer: "/" + next.FormName(), Code: ItemMissingOrLate}) return case !errors.Is(err, io.EOF): h.uploadFailed(w, r, err) return } key.replayIfIdentical(w, r, digest.Sum(nil)) return } // The file is the LAST part: everything after it is refused rather than ignored, and the // refusal has to happen before the bytes are consumed — so the remainder is checked by // handing the intake a reader that fails at the end of the body. See trailingParts. break } value, err := readField(part) if err != nil { // A field longer than this deployment reads is named rather than folded into "the request // could not be read": the canon asks the 400 to say which part was wrong. if errors.Is(err, books.ErrBadIntake) { Invalid(w, r, Item{Pointer: "/" + part.FormName(), Code: ItemTooLong}) return } h.uploadFailed(w, r, err) return } switch part.FormName() { case "title": // The EMPTY STRING is a value and not an absence: it means "name it from the file" // (canon §BookIntake.title). Anything else is a name the person chose, and no later // parse overwrites it. in.Title, titleGiven = value, true case "source_lang": in.SourceLang = value case "target_lang": in.TargetLang = value } // An unknown field is IGNORED rather than refused, for the same reason an unknown JSON // property is: inside 0.x a minor bump is where optional fields appear, and a server that // rejected the whole upload would break a client generated against a later contract. } // A part after the file is REFUSED and never ignored, and the refusal has to undo the upload: // answering 400 while the book stays in the library would leave the user with an error and a // book. It is done by failing the READ — the intake's own "the upload did not finish" path then // removes the row and the directory, which is the path that already exists for exactly this. trailing := &trailingParts{parts: parts} in.File = io.MultiReader(in.File, trailing) // A `408` is not a completed attempt, so the same key may be presented again (canon §createBook): // that is what the deferred release does for every failure below. book, err := h.intake.Accept(r.Context(), in) if err != nil { if errors.Is(err, errTrailingPart) { h.log.InfoContext(r.Context(), "an upload carried a part after the file and was refused") Invalid(w, r, Item{Pointer: "/" + trailing.name, Code: ItemMissingOrLate}) return } h.uploadFailed(w, r, err) return } // Location names the book card, as a URI reference resolved against this request's URL: a client // follows it as given rather than rebuilding the address from an identifier of its own. location := APIPrefix + "/books/" + book.ID w.Header().Set("Location", location) key.complete(r.Context(), http.StatusCreated, location, h.writeJSON(w, r, http.StatusCreated, projectBook(book)), digest.Sum(nil)) } // intakeFingerprint is what a claim can be taken on: the DECLARED parts, known before a byte of the // file has been read. // // It does not settle identity by itself and is not asked to. The file's own digest does, on the way // past, and a repeat is compared against it before anything is replayed (idempotent.replayIfIdentical). // // ⚠ The request's `Content-Length` is NOT in here. It stood in for the file's size and was a proxy // for neither half of the question: it counts the multipart framing, so a retry from another client // library differs, and a chunked body declares nothing at all — which left two different files under // one key indistinguishable (PD-262). func intakeFingerprint(in books.Intake, titleGiven bool) []byte { return []byte(strconv.FormatBool(titleGiven) + "\x00" + in.Title + "\x00" + in.SourceLang + "\x00" + in.TargetLang + "\x00" + in.Filename) } // missingIntakeFields names what the form did not carry before its file part. func missingIntakeFields(in books.Intake, titleGiven bool) []Item { var out []Item if !titleGiven { out = append(out, Item{Pointer: "/title", Code: ItemMissingOrLate}) } if in.SourceLang == "" { out = append(out, Item{Pointer: "/source_lang", Code: ItemMissingOrLate}) } if in.TargetLang == "" { out = append(out, Item{Pointer: "/target_lang", Code: ItemMissingOrLate}) } return out } // errTrailingPart is a form that carried something after its file. var errTrailingPart = errors.New("httpapi: a form part arrived after the file") // trailingParts is what turns "the file is the last part" from this implementation's constraint into // the contract's refusal. // // It reads as an empty tail of the file and, when the file's own bytes are exhausted, asks the // multipart reader whether anything follows. A part that does FAILS the read, which is what makes // the refusal undo the upload: the intake treats a read that failed as an upload that did not // finish and removes the row and the directory it had already written. The part's NAME is kept // beside the error because the caller reports it as the offending field. type trailingParts struct { parts *multipart.Reader checked bool name string } func (t *trailingParts) Read([]byte) (int, error) { if t.checked { return 0, io.EOF } t.checked = true part, err := t.parts.NextPart() if err != nil { return 0, io.EOF // the form ended where it should: after the file } t.name = part.FormName() if t.name == "" { t.name = "form" } return 0, errTrailingPart } // readField reads one text field of the form, refusing one that is too long rather than silently // keeping its first kilobyte. func readField(part io.Reader) (string, error) { b, err := io.ReadAll(io.LimitReader(part, maxIntakeField+1)) if err != nil { return "", err } if len(b) > maxIntakeField { return "", fmt.Errorf("%w: a form field is too long", books.ErrBadIntake) } return strings.TrimSpace(string(b)), nil } // uploadFailed maps the ways an upload ends badly. // // The size refusal is the reason PD-72 was opened and is closed with this route: MaxBytesReader is // what enforces the cap, and *http.MaxBytesError is how it says so — the same error whether the // client announced the size or simply kept sending. func (h *v0) uploadFailed(w http.ResponseWriter, r *http.Request, err error) { var tooLarge *http.MaxBytesError switch { case errors.As(err, &tooLarge): Fail(w, r, CodePayloadTooLarge) case errors.Is(err, os.ErrDeadlineExceeded): // The body did not finish inside the route's own deadline. 408 is what RFC 9110 §15.5.9 calls // exactly this, and it tells a client that RETRYING is the remedy — which a 500 does not. h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline") Fail(w, r, CodeRequestTimeout) case errors.Is(err, books.ErrMalformedLanguage): // WHICH code is malformed is in the error's own tail; both are named when only one is, because // a pair is what was rejected and pointing at one of the two would be a guess about which the // person meant to change. Invalid(w, r, Item{Pointer: "/source_lang", Code: ItemMalformed}, Item{Pointer: "/target_lang", Code: ItemMalformed}) case errors.Is(err, books.ErrUnsupportedPair): Invalid(w, r, Item{Pointer: "/source_lang", Code: ItemUnsupportedPair}, Item{Pointer: "/target_lang", Code: ItemUnsupportedPair}) case errors.Is(err, books.ErrNoBookInSource): // Answered now rather than as a book that appears in the library and vanishes minutes later; // nothing was kept (backlog row 285). Invalid(w, r, Item{Pointer: "/file", Code: ItemNoBook}) case errors.Is(err, books.ErrStructureNotDeliverable): // Our reach, not their file: the item code lets a client phrase it as the temporary limit it // is (backlog rows 283/325). Invalid(w, r, Item{Pointer: "/file", Code: ItemNoChapterStructure}) case errors.Is(err, books.ErrBadIntake): Invalid(w, r) case errors.Is(err, context.Canceled), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): // The client went away mid-body. Nothing reaches it; the line is what an operator sees. h.log.InfoContext(r.Context(), "upload did not finish", "err", err) Invalid(w, r) default: h.fail(w, r, err) } } func (h *v0) usage(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } u, err := h.lib.ReadUsage(r.Context(), user) if err != nil { h.fail(w, r, err) return } out := wireUsage{State: usageState(u.RemainingPercent, u.Spendable), RemainingPercent: u.RemainingPercent} // ⚠ Its own vocabulary, AccountHaltReason, and not the run's. A run stops for reasons that say // nothing about the account, and lighting an account-wide state from one would tell a user with // money that they have none (canon §AccountHaltReason). The two happen to share a value today and // the types are separate on purpose. if reason := ingest.ContractHaltReason(u.PausedReason); reason != "" { out.HaltReason = &reason } h.writeJSON(w, r, http.StatusOK, out) } // lowCredit is the share at which the interface warns. The threshold belongs to the platform and // never travels: a client that computed it from the percentage would carry a second copy of the // policy, and the two would diverge on the day it changes (canon §Usage). const lowCredit = 10 // spendable, not the percentage, decides "exhausted". The share is floored, so a large grant with a // small remainder rounds to 0% — and the account screen then said "nothing left" while the run dialog // offered a 300-chapter scale that started successfully. The two screens now answer from the same // fact. func usageState(percent int, spendable bool) string { switch { case !spendable: return "exhausted" case percent <= lowCredit: return "low" default: return "ok" } } // principal reads the caller established by the middleware. Absent means the guard did not run, // which is a wiring defect rather than an unauthenticated request — so it is a 500, and it is // logged: answering 401 would hide a route mounted without its guard. func principal(w http.ResponseWriter, r *http.Request) (string, bool) { p, ok := caller(w, r) return p.UserID, ok } // caller is the same reading for the one route that needs more of the principal than its id: the // event stream re-asks, mid-flight, whether the session behind it is still usable (PD-379). Both // facts come from the same value on purpose — a handler holding an id it cannot re-check is the // state that vulnerability was made of. func caller(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) { p, ok := auth.FromContext(r.Context()) if !ok || p.UserID == "" { Fail(w, r, CodeInternalError) return auth.Principal{}, false } return p, true } // pageLimit reads the `limit` parameter. // // It NEVER refuses. Over the maximum the page size is clamped and answered, and anything else — a // zero, a negative, a word — falls back to the deployment's default: the canon says outright that "a // deployment that validates this parameter against the schema has to exempt it from rejection" // (§Limit), because answering the default instead of clamping is what made "ask for more, get fewer // rows than a smaller request" discoverable only by experiment. The clamp itself lives in the store, // next to the page it bounds. func (h *v0) pageLimit(r *http.Request) int { raw := r.URL.Query().Get("limit") if raw == "" { return 0 // the deployment's default (GET /capabilities) } n, err := strconv.Atoi(raw) if err != nil || n < 1 { return 0 } return n } // afterVersion reads the delta parameter shared by the notes and the bank. func (h *v0) afterVersion(w http.ResponseWriter, r *http.Request) (*int64, bool) { raw := r.URL.Query().Get("after_version") if raw == "" { return nil, true } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n < 0 { Invalid(w, r, Item{Pointer: "/after_version", Code: ItemMalformed}) return nil, false } return &n, true } // fail maps a domain error onto the contract's codes. // // Neither title nor detail ever carries engine or database text (canon §Problem): what a client puts // on a screen is drawn from the CODE, and an error string written for an operator would otherwise // become the sentence a reader gets. func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { var held *runs.CreditHeldError switch { case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun): Fail(w, r, CodeNotFound) case errors.Is(err, pgstore.ErrNoChapter): // The chapter existed and does not any more: a book cut again leaves the old ids GONE rather // than absent, and the remedy differs — the client re-reads the tree instead of checking the // address (canon §listUnits). Fail(w, r, CodeGone) case errors.Is(err, pgstore.ErrBadCursor): FailCause(w, r, CodeInvalidRequest, CauseCursorInvalid) case errors.Is(err, pgstore.ErrVersionTooOld): FailCause(w, r, CodeInvalidRequest, CauseVersionTooOld) case errors.Is(err, books.ErrBadIntake): Invalid(w, r) case errors.Is(err, pgstore.ErrRunInFlight): Fail(w, r, CodeRunInFlight) case errors.Is(err, runs.ErrSourceGone): // The same 409 as below — the book is there and cannot be translated — with the cause that // says waiting will not help. Logged at ERROR because it is also the operator's business: a // directory under the books root went missing while its row lives on. h.log.ErrorContext(r.Context(), "run refused: the book's source directory is gone", "err", err) FailCause(w, r, CodeBookNotReady, CauseSourceGone) case errors.Is(err, runs.ErrBookNotReady): // The book is still being received or was rejected. 409 and not 404: the book exists and the // client can see it — what it cannot do is start a translation of it yet. Fail(w, r, CodeBookNotReady) case errors.Is(err, runs.ErrNotStoppable): Fail(w, r, CodeRunNotStoppable) case errors.Is(err, runs.ErrStopRequested): // A live run under a stop the user already asked for. Its own cause because the remedy is the // one thing the other causes of this code rule out: wait, then make this same call. FailCause(w, r, CodeRunNotResumable, CauseStopRequested) case errors.Is(err, runs.ErrCeilingReached): // A run stopped at a limit is not continued by `resume`: the limit travels with the START of // a run, so the remedy is a NEW run with a larger one (canon §resumeRun). FailCause(w, r, CodeRunNotResumable, CauseCeilingReached) case errors.Is(err, runs.ErrCreditUnavailable): // The run itself has room left; the account does not. The remedy is money, not a new run. FailCause(w, r, CodeRunNotResumable, CauseCreditUnavailable) case errors.Is(err, runs.ErrNotResumable): Fail(w, r, CodeRunNotResumable) case errors.As(err, &held): WriteProblem(w, r, Problem{Code: CodeCeilingUnavailable, Cause: &Cause{Code: CauseCreditHeld}, Blocked: &Blocked{Code: CauseCreditHeld, BookID: held.BookID}}) case errors.Is(err, runs.ErrBalanceCannotCarry), errors.Is(err, pgstore.ErrInsufficientCredit): // ⛔ `credit_unavailable`, NOT `bounds_moved`, and the difference is where the buyer goes next. // `bounds_moved` promises that re-reading the options and retrying will work; here nothing // moved and retrying is futile — what is missing is money. This is the refusal the order form // exists to make honest, and it used to travel under the other word. FailCause(w, r, CodeCeilingUnavailable, CauseCreditUnavailable) case errors.Is(err, runs.ErrCeilingOutOfBounds): // 409 and not 400: the request was legal when the options were read, and a hold taken for // another book between that read and this call is what moved them (canon §startRun). FailCause(w, r, CodeCeilingUnavailable, CauseBoundsMoved) case errors.Is(err, runs.ErrNotPriced): // 409: the book is there and can be read; what cannot happen yet is a QUOTE over it. It is not // 400 (the request was fine) and not 500 (nothing is broken) — the engine has simply not been // asked about this book since it learnt to publish prices, and the materializer's own debt is // what asks. FailCause(w, r, CodeCeilingUnavailable, CauseNotPriced) case errors.Is(err, runs.ErrChapterOrdersUnavailable): FailCause(w, r, CodeCeilingUnavailable, CauseChapterOrdersUnavailable) case errors.Is(err, runs.ErrRePassUnavailable): // Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a // resnapshot run already walked it in (canon §RunRequest.re_pass). FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable) case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete), errors.Is(err, runs.ErrStorageUnavailable): // A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145), // no way to record how a unit ended, or no book storage under it. The last one is a book whose // directory is missing together with the marker of the volume it lived on, and it is the // host's fault rather than the book's — answering it as the book's is what PD-192 cost the // intake, and there the answer had no way back. h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err) Fail(w, r, CodeServiceUnavailable) default: h.log.ErrorContext(r.Context(), "request failed", "err", err) Fail(w, r, CodeInternalError) } }