Give the store its own projection type without a character count, so one number cannot live in two fields free to drift apart

This commit is contained in:
heaven 2026-09-06 02:20:19 +03:00
parent 6069e52aac
commit 9845098f67
3 changed files with 42 additions and 8 deletions

View file

@ -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)
}

View file

@ -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

View file

@ -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