diff --git a/platform/internal/pgstore/price_test.go b/platform/internal/pgstore/price_test.go index 42516d29..e22a167c 100644 --- a/platform/internal/pgstore/price_test.go +++ b/platform/internal/pgstore/price_test.go @@ -12,9 +12,10 @@ import ( func priced() Structure { in := twoChapters("k1") in.Structure = ingest.StructureDetected - in.Price = &ingest.BookPrice{ - ExpectedUSD: 2_090_000, BookOnceUSD: 2_000_000, StepMaxUSD: 69_828, SourceChars: 3000, - } + in.Price = &Projection{Expected: 2_090_000, BookOnce: 2_000_000, StepMax: 69_828} + // ⚠ ON THE CUT, NOT ON THE PRICE — the store keeps the two apart on purpose, and the type makes + // that the only way to say it (see Projection). + in.SourceChars = 3000 // ⚠ The price lives on the UNIT and only there: a chapter's cost is the sum of the units still to // deliver, so a chapter-level copy would be a second carrier free to drift from it. in.Chapters[0].Units[0].Expected, in.Chapters[0].Units[0].SourceChars = 40_000, 1200 @@ -201,13 +202,18 @@ func TestTheEnginesCharacterCountReachesTheLibraryRowBesideItsAccuracy(t *testin // count too, and «Знаков» fell back to the intake's approximation, which for an EPUB counts the runes // of a ZIP archive. The two facts are independent: one is about the text, the other about money. // -// Mutation caught: writing `source_chars` off `in.Price` again. +// ⚠ Since Projection was cut down to money alone, writing the count off the price is no longer +// EXPRESSIBLE — the field it would be read from does not exist. What this pin still catches is the +// other half of the same mistake: gating the write on the price's arrival. +// +// Mutation caught: writing a null `source_chars` whenever `in.Price` is nil. func TestTheCharacterCountSurvivesAPriceThisBuildCouldNotRead(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") in := priced() in.Price = nil // the money did not arrive… - in.SourceChars = 3000 // …and the engine's count of the text did + in.SourceChars = 3000 // …and the engine's count of the text did (priced() sets it; said again here + // because this test is ABOUT that figure and a reader should not have to go look) if err := s.SaveStructure(ctx, book, in); err != nil { t.Fatal(err) } diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index a54c323a..cc6ca24b 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -76,7 +76,7 @@ type Structure struct { // whole one. A book with no projection is one the platform REFUSES to sell rather than one it // prices with a constant (pricing's own package comment), so nil is written through: a projection // that goes stale is worse than one that is honestly absent. - Price *ingest.BookPrice + Price *Projection // Structure is where the chapter boundaries came from, verbatim from the engine. Empty when the // engine did not say — a manifest older than the landing — and stored as such: the decision it // feeds (ingest.ChapterOrdersOffered) treats what it does not know as untrusted. @@ -92,6 +92,30 @@ type Structure struct { SourceChars int64 } +// Projection is the MONEY half of the engine's a-priori figure, as the store holds it. +// +// ⛔ IT CARRIES NO CHARACTER COUNT, and the absence is the whole reason this type exists rather than +// the wire's own `ingest.BookPrice`. The engine publishes `source_chars` INSIDE its price object, +// because for the engine both are outputs of one projection; the store keeps them apart +// (Structure.SourceChars) because for the store they answer to different witnesses — a manifest can +// name the text honestly and fail on a MONEY key, and a book that lost its bill has no business +// losing its character count too. +// +// ⚠ Reusing the wire struct here put the same figure in TWO fields of one value, and a caller then +// filled one and not the other — which is not a hypothetical: it is how +// TestTheEnginesCharacterCountReachesTheLibraryRowBesideItsAccuracy went red. One fact, one carrier. +type Projection struct { + // Expected is the whole book's expected bill, book-level passes INCLUDED. + Expected money.MicroUSD + // BookOnce is the part of Expected that belongs to the BOOK and not to any chapter — a flat bond + // on the shipped arm, which is why an ORDER is priced from the chapters' own bills instead (see + // pricing.Hold and ingest.BookPrice.BookOnceUSD for the measurement behind it). + BookOnce money.MicroUSD + // StepMax is the largest single reservation any one call of this book can ask for: the floor of + // every hold, because a ceiling below it admits nothing. + StepMax money.MicroUSD +} + // StructureChapter is one chapter and its pairs, in reading order. type StructureChapter struct { // EngineID is the engine's content-derived chapter identity; Number its dense display ordinal. @@ -171,7 +195,7 @@ func (s *Store) SaveStructure(ctx context.Context, bookID string, in Structure) // tree would sell yesterday's book; an absent one refuses the sale, which is the honest half. var expected, bookOnce, stepMax *int64 if in.Price != nil { - e, b, sm := int64(in.Price.ExpectedUSD), int64(in.Price.BookOnceUSD), int64(in.Price.StepMaxUSD) + e, b, sm := int64(in.Price.Expected), int64(in.Price.BookOnce), int64(in.Price.StepMax) expected, bookOnce, stepMax = &e, &b, &sm } // ⚠ INDEPENDENT OF THE PRICE — see Structure.SourceChars. A book the engine could not price is diff --git a/platform/internal/readmodel/readmodel.go b/platform/internal/readmodel/readmodel.go index 448495f6..aedd46ce 100644 --- a/platform/internal/readmodel/readmodel.go +++ b/platform/internal/readmodel/readmodel.go @@ -327,7 +327,11 @@ func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, // moved nothing. So `priced` gates the per-chapter numbers too, and a book the engine could not // price is stored as not priced rather than as free. if p, ok := manifest.Priced(); ok { - in.Price = &p + // ⚠ MAPPED FIELD BY FIELD RATHER THAN CARRIED WHOLE, and `source_chars` is deliberately not + // among them: the store holds it on the CUT (in.SourceChars, set above), so that a book whose + // money did not arrive keeps the honest count of its text. pgstore.Projection carries no such + // field, which is what makes the omission impossible to make by accident. + in.Price = &pgstore.Projection{Expected: p.ExpectedUSD, BookOnce: p.BookOnceUSD, StepMax: p.StepMaxUSD} } else if why := manifest.PriceRefusal(); why != "" { // ⛔ SAID OUT LOUD, because the consequence is a book that materializes perfectly and can never // be SOLD. Everything else about it lands — the tree, the text, the reading surface — and only