package httpapi import ( "encoding/json" "net/http" "time" "textmachine/platform/internal/ingest" ) // reading.go: the chapter tree, its pairs, the book's notes and its memory bank. // // One rule shapes all four: a collection answers ONE page and the revision that page is a picture // of, read in the same transaction. The client stamps a multi-page WALK with the lowest revision it // saw and uses that as the watermark of its next delta read (canon §Revision) — server-side that // means every page carries its own number and none of them carries a promise about the others. type wireChapter struct { ID string `json:"id"` Number *int `json:"number"` // Heading is the chapter's label as it comes from the DATA of the book, or null. A deployment // whose parser does not extract labels answers null and MUST NOT put a rendered ordinal here. Heading *string `json:"heading"` UnitsTotal int `json:"units_total"` UnitsDone int `json:"units_done"` NoteCount int `json:"note_count"` } type wireChapterPage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` StructureVersion int `json:"structure_version"` Chapters []wireChapter `json:"chapters"` } type wireUnit struct { ID string `json:"id"` Source string `json:"source"` // Target is non-empty exactly when State is `translated`, the empty string otherwise — never // absent, never null. Target string `json:"target"` State string `json:"state"` Notes []wireNote `json:"notes"` } type wireUnitPage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` StructureVersion int `json:"structure_version"` Units []wireUnit `json:"units"` } type wireNote struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` Severity string `json:"severity"` Code string `json:"code"` ChapterID string `json:"chapter_id"` // UnitID is optional and for one reason only: a note is about a pair, or about a whole chapter. UnitID *string `json:"unit_id,omitempty"` } type wireNotePage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` StructureVersion int `json:"structure_version"` Notes []wireNote `json:"notes"` } type wireTerm struct { ID string `json:"id"` Src string `json:"src"` Dst string `json:"dst"` Kind *string `json:"kind"` Status string `json:"status"` Origin string `json:"origin"` Sense string `json:"sense"` // The window is in chapter NUMBERS, which are not keys: it lives in the coordinates of the // current structure_version. null means "no boundary". SinceChapter *int `json:"since_chapter"` UntilChapter *int `json:"until_chapter"` } // wireBankPage carries the whole-bank aggregates on the FIRST page only — any response to a request // with no cursor, a delta read included. They are `omitempty` for that reason and for no other: // absence here means "does not apply to this page", one of the two places on this surface where it // does (canon §BankPage). // // ⚠ `pending_decisions` and `complete` are gone WITH the canon (0.5.0, PD-399): they counted for // the abolished per-term model and had degraded into a live count of `proposed` rows. "How many are // still undecided" is the engine's answer and rides the correction receipt (`signature`), not a // read of this table. type wireBankPage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` StructureVersion int `json:"structure_version"` Total *int `json:"total,omitempty"` Signed *int `json:"signed,omitempty"` // ⛔ RAW, and not a `*wireConsolidation` with `omitempty`, because this member has TWO different // absences and that tag can only spell one. On a later page it is ABSENT, like the counts above — // the aggregates ride the first page only. On the FIRST page it is `null` when nothing has // measured completeness, which the canon promises and a client must be able to tell from a bank // that is whole. A nil pointer under `omitempty` collapses both into "no key", so the state the // canon describes was unreachable and the comment that claimed otherwise was false by the tag one // line below it. Consolidation json.RawMessage `json:"consolidation,omitempty"` Terms []wireTerm `json:"terms"` } type wireConsolidation struct { Complete bool `json:"complete"` RenderBatchesDropped int `json:"render_batches_dropped"` ClassifyBatchesDropped int `json:"classify_batches_dropped"` Consolidated int `json:"consolidated"` Declined int `json:"declined"` Unanswered int `json:"unanswered"` NeverAsked int `json:"never_asked"` } // wireSigningStop is what the last signing stop asked about. `Unreadable` carries the fact an empty // list cannot: without it "this deployment could not read the question" and "the stop asked nothing" // are the same bytes, and a client would tell a person there is nothing to decide at the one moment // the product stops to ask them. type wireSigningStop struct { Unreadable bool `json:"unreadable"` Offered []wireOffered `json:"offered"` } type wireOffered struct { Src string `json:"src"` Dst string `json:"dst"` Kind *string `json:"kind"` Channel *string `json:"channel"` Freq *int `json:"freq"` Spread *int `json:"spread"` Conventions *int `json:"conventions"` Confidence *int `json:"confidence"` Invented bool `json:"invented"` Contradicts []string `json:"contradicts"` BankHolds []string `json:"bank_holds"` Variants []string `json:"variants"` } func (h *v0) listChapters(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } page, err := h.lib.ListChapters(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r), r.URL.Query().Get("cursor")) if err != nil { h.fail(w, r, err) return } out := wireChapterPage{ Revision: page.Revision, StructureVersion: page.StructureVersion, Chapters: make([]wireChapter, 0, len(page.Chapters)), } if page.NextCursor != "" { out.NextCursor = &page.NextCursor } for _, c := range page.Chapters { out.Chapters = append(out.Chapters, projectChapter(c)) } h.writeJSON(w, r, http.StatusOK, out) } // listUnits answers the pairs of ONE chapter. // // Per chapter and only per chapter: a book-wide pairs endpoint is never introduced, and the client's // whole memory model stands on that (canon §listUnits). A chapter that existed and no longer does is // `410`, so a client re-reads the tree rather than checking the address. func (h *v0) listUnits(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } page, err := h.lib.ListUnits(r.Context(), user, r.PathValue("bookId"), r.PathValue("chapterId"), h.pageLimit(r), r.URL.Query().Get("cursor")) if err != nil { h.fail(w, r, err) return } out := wireUnitPage{ Revision: page.Revision, StructureVersion: page.StructureVersion, Units: make([]wireUnit, 0, len(page.Units)), } if page.NextCursor != "" { out.NextCursor = &page.NextCursor } for _, u := range page.Units { out.Units = append(out.Units, projectUnit(u)) } h.writeJSON(w, r, http.StatusOK, out) } func (h *v0) listNotes(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } after, ok := h.afterVersion(w, r) if !ok { return } page, err := h.lib.ListNotes(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r), r.URL.Query().Get("cursor"), after) if err != nil { h.fail(w, r, err) return } out := wireNotePage{ Revision: page.Revision, StructureVersion: page.StructureVersion, Notes: make([]wireNote, 0, len(page.Notes)), } if page.NextCursor != "" { out.NextCursor = &page.NextCursor } unnamed := 0 for _, n := range page.Notes { note := projectNote(n) if note.Code == ingest.NoteCodeUnspecified { unnamed++ } out.Notes = append(out.Notes, note) } if unnamed > 0 { // A flag reason the contract's map does not name. Loud, because the answer is a line in that // map rather than a change here — and silent, it would look like a note the engine produced // without a reason. h.log.ErrorContext(r.Context(), "notes carry a flag reason this build cannot name; the contract's map needs a line", "notes", unnamed) } h.writeJSON(w, r, http.StatusOK, out) } // listBank answers the memory bank, and it is also the STATE of a signing stop — informationally, // never as a gate. Signing is ONE act over the whole bank: `resume` lifts the stop with the // decisions as they stand, and no counter here decides whether continuing may be offered (D39.144). func (h *v0) listBank(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } after, ok := h.afterVersion(w, r) if !ok { return } page, err := h.lib.ListBank(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r), r.URL.Query().Get("cursor"), after) if err != nil { h.fail(w, r, err) return } out := wireBankPage{ Revision: page.Revision, StructureVersion: page.StructureVersion, Terms: make([]wireTerm, 0, len(page.Terms)), } if page.NextCursor != "" { out.NextCursor = &page.NextCursor } if page.First { c := page.Counts out.Total, out.Signed = &c.Total, &c.Signed // Set on the first page WHATEVER the answer is, so that `null` reaches the client; the raw // member stays nil on later pages and is then omitted. out.Consolidation = projectConsolidation(page.Consolidation) } for _, t := range page.Terms { out.Terms = append(out.Terms, projectTerm(t)) } h.writeJSON(w, r, http.StatusOK, out) } // readBankSigningStop answers what the LAST signing stop asked about — the one screen in the product // where a person is asked for a decision (engine backlog row 224). // // It does not say whether a stop is standing: that is the run's status, and the past tense is the same // one the correction receipt already uses about this population. Uncursored and undeltaed, because a // term offered here has no id to name in a delta and the stop's ranking is the information. func (h *v0) readBankSigningStop(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { return } stop, err := h.lib.BankSigningStop(r.Context(), user, r.PathValue("bookId")) if err != nil { h.fail(w, r, err) return } out := wireSigningStop{Unreadable: stop.Unreadable, Offered: make([]wireOffered, 0, len(stop.Offered))} for _, t := range stop.Offered { out.Offered = append(out.Offered, projectOfferedTerm(t)) } h.writeJSON(w, r, http.StatusOK, out) } // The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the // per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed // ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the // abolished per-term decisions route — went on the owner's word of 22.08; the `bank_decisions` // table it fed is dead and nothing here reads it.