Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

inventory.gno

56.83 Kb · 1337 lines
   1// Package inventory is a per-owner inventory sub-ledger whose cost of goods
   2// sold is DERIVED from the lots on hand rather than asserted by a caller.
   3//
   4// # What this realm is for
   5//
   6// A general ledger takes a debit code, a credit code and an amount from
   7// whoever is posting. That is the right design for a ledger, and it means
   8// gno.land/r/.../books structurally CANNOT know that an entry was a shipment,
   9// or that the cost charged for that shipment is the cost the goods were
  10// actually bought at. Nothing stops a shipper from booking a flattering
  11// number, and nothing in the book can tell afterwards.
  12//
  13// This realm closes that gap for one class of entry. It holds each item's
  14// cost lots as state, and when goods ship it computes the charge from those
  15// lots by the item's declared costing method. The amount is never a
  16// parameter: [Ship] takes a QUANTITY and returns the cost it derived.
  17//
  18// The resulting claims are narrow and checkable by anyone, which is the point.
  19// Per item, from p/lots' own cross-foot:
  20//
  21//	receivedCost == chargedCost + costOnHand
  22//
  23// and, across a whole register, against the ledger the realm posted to:
  24//
  25//	balance(1300) == sum of costOnHand over items
  26//	balance(5000) == sum of chargedCost over items
  27//	balance(3900) == sum of receivedCost over items
  28//
  29// All four are exported — [ItemCrossFoot], [CrossFootInventory],
  30// [CrossFootCOGS], [CrossFootContributed] — and all are FREE reads,
  31// deliberately; see "Why the invariants are free reads" below.
  32//
  33// Note what the three ledger identities are NOT. Each compares a walk of the
  34// lots against an account balance, and the two are maintained by different
  35// code: the pool counts for itself inside p/lots, while this realm posts to
  36// the book. A disagreement is therefore a real signal and not a tautology.
  37// That is the same reason p/lots keeps counters beside its lots.
  38//
  39// # The chart is three accounts and is fixed
  40//
  41//	1300  Inventory                (asset,   debit-normal)
  42//	5000  Cost of goods sold       (expense, debit-normal)
  43//	3900  Inventory contributed    (equity,  credit-normal)
  44//
  45// A receipt is debit 1300 / credit 3900. A shipment is debit 5000 / credit
  46// 1300 for the DERIVED cost. There is no OpenAccount entrypoint and the
  47// underlying book is created with a hard cap of three accounts, so the chart
  48// cannot grow. The accounting equation offsets at 0 = 0 on an empty register
  49// and after every posting, because 1300 + 5000 == 3900 is the register-wide
  50// restatement of each pool's own cross-foot.
  51//
  52// # 3900 IS A PLUG, AND THIS REALM IS FORCED TO PICK ONE
  53//
  54// This is the design's one unavoidable assertion about the world, so it is
  55// stated here rather than buried.
  56//
  57// gno.land/r/.../assets faced the same question about an asset's cost basis
  58// and answered by NOT BOOKING IT: the only honest credit legs were a
  59// liability ("2000 Acquisition payable") or an equity plug ("3000 Contributed
  60// capital"), both of which assert how the asset was funded, which that realm
  61// does not know. It kept the cost in register state as data instead.
  62//
  63// That answer is not available here, and the difference is structural rather
  64// than a matter of taste. The product IS cost of goods sold. A shipment debits
  65// 5000 and credits 1300, so 1300 must carry a balance, so the receipt must
  66// reach the book — and a debit to 1300 needs a credit leg. The choice is
  67// therefore not "book it or not" but "which plug", and every candidate
  68// asserts something unobservable:
  69//
  70//   - 2000 Accounts payable asserts the goods were bought on credit and that
  71//     a liability is outstanding. This realm cannot see a payment, so the
  72//     liability would never be relieved and would grow without bound.
  73//   - 1000 Cash asserts the goods were paid for in cash from a balance this
  74//     realm does not hold and cannot verify. It holds no coins at all.
  75//   - 3900 Inventory contributed asserts the goods were contributed by the
  76//     owner. It is wrong in exactly the same way the other two are, and it is
  77//     the only one of the three that is SELF-CONSISTENT without an
  78//     unobservable second event: a contribution is complete on arrival.
  79//
  80// So 3900 was chosen because it needs no follow-up transaction to stay
  81// coherent, not because it is true. Read it as "goods arrived from somewhere
  82// this ledger does not model". A register whose goods were in fact purchased
  83// on credit is understating liabilities and overstating equity by exactly
  84// balance(3900), and no code path here can detect that. Pairing this
  85// sub-ledger with a real payables ledger is out of scope for v1 and is the
  86// honest fix.
  87//
  88// # Costing method is per item and is FIXED at creation
  89//
  90// An item is added by [AddFIFO], [AddLIFO] or [AddWAVG] — three entrypoints
  91// rather than one taking a method code, following r/assets' Place /
  92// PlaceDeclining precedent, so a caller never has to know an enum's numbering.
  93//
  94// p/lots fixes the method at pool construction and documents why: changing it
  95// mid-life would recost nothing already charged but would change what the next
  96// shipment costs, which is a restatement a sub-ledger must not be able to make
  97// silently. This realm inherits that and adds nothing: there is no entrypoint
  98// that changes an item's method. An owner who must switch opens a new SKU and
  99// discloses it.
 100//
 101// The three methods differ observably, which is the point of having them:
 102// FIFO and LIFO charge different amounts whenever unit costs differ, and WAVG
 103// re-anchors every receipt into a single merged lot so it holds exactly one
 104// lot and can never hit the per-item lot cap.
 105//
 106// That exemption is not a free win, and the trade is the other way round from
 107// how it reads. WAVG escapes the LOT cap and in exchange acquires a refusal
 108// FIFO and LIFO do not have: a merged lot must satisfy p/lots' per-lot
 109// overflow precondition `Cost <= maxInt64/Qty`, and two lots that each satisfy
 110// it separately can merge into one that does not. So under WAVG a receipt can
 111// be refused for the pool's ACCUMULATED HISTORY rather than for anything about
 112// the receipt — the same receipt a FIFO item would have taken. It is a far
 113// corner of the arithmetic and not a practical bound, but it is the reason
 114// [LotCapacity] is the wrong thing to watch for a WAVG item: its figure is a
 115// bound WAVG will never approach, so a caller sizing headroom from it is
 116// reading the one limit that does not apply. See p/lots' Pool.Receive.
 117//
 118// # A ZERO-COST POSTING IS SKIPPED, AND THAT IS NOT A SHORTCUT
 119//
 120// p/journal rejects a posting of zero (ErrInvalidAmount) — correctly; a zero
 121// entry is not an event. But both of this realm's postings can legitimately be
 122// zero:
 123//
 124//   - [Receive] with cost 0 is free goods, which p/lots accepts (ErrBadCost is
 125//     only for a NEGATIVE cost).
 126//   - [Ship] out of lots that cost nothing derives a charge of 0.
 127//
 128// In both cases the pool is updated and NOTHING is posted. The identities
 129// above are preserved exactly, because a zero contribution to the ledger side
 130// is matched by a zero contribution to the walk side. The same choice r/assets
 131// makes for a degenerate declining schedule, for the same reason.
 132//
 133// # What this realm does NOT do
 134//
 135//   - No custody. It holds no coins, has no withdrawal path, and refuses a
 136//     user call that arrives carrying any. A cost is a number an owner typed.
 137//   - No write-off, shrinkage or revaluation. Shrinkage is a real inventory
 138//     event and its absence is a real limitation: there is currently no way to
 139//     remove goods without charging them to 5000, so a loss is indistinguish-
 140//     able from a sale. The posting it needs is a debit to a 5-series loss
 141//     account against a credit to 1300, which means a fourth account and a
 142//     fourth entrypoint. Deliberately deferred to keep the chart fixed at
 143//     three for v1, and named here rather than discovered later.
 144//   - No revenue. A shipment books its COST, never a sale price. Revenue
 145//     belongs to a receivables ledger (r/invoices) and tying the two together
 146//     would require this realm to learn what was sold for how much.
 147//   - No period close. p/journal can seal a book up to a HEIGHT; sealing a
 148//     derived book could only make the next derivation unpostable. The
 149//     capability exists in the engine and is deliberately not exposed.
 150//   - No bulk receive or ship. One item per call — see the bound below.
 151//   - No truth. A sub-ledger is a set of claims by its owner. With no custody
 152//     there is nothing tying "received 100 units at cost 5000" to a real
 153//     holding. The invariants here prove that the postings follow from the
 154//     stored lots; they prove nothing about the world.
 155//
 156// # Why the invariants are free reads, and what the bound costs
 157//
 158// r/books exposes Rederive as an owner-only crossing function, to contain the
 159// cost of a full re-walk. This realm diverges, deliberately and for the same
 160// reason r/assets does: the derivation IS the product, and a claim only its
 161// owner can check is not a claim worth making. [Rederive], [ItemCrossFoot],
 162// the three register cross-foots and [Render] are all free reads, so any third
 163// party can verify a register from vm/qeval with no key.
 164//
 165// MaxLotsPerItem is 64 against p/lots' own MaxLotsLimit of 1024, and the gap
 166// is not timidity. p/lots' audit left the cost of its O(live lots) walks
 167// explicitly UNPRICED and recorded that a realm rendering many pools must
 168// measure its own gas. This realm is that realm, so the figures are measured
 169// rather than reasoned about, under the pinned onyx toolchain.
 170//
 171// There are TWO cost axes, not one, and this paragraph used to name only the
 172// first: MaxItemsPerRegister*MaxLotsPerItem live lots, and separately
 173// MaxEntriesPerRegister postings. It called 64 items of 64 live lots "the worst
 174// case this realm can hold", which is false — a register can sit at the lot cap
 175// and the entry cap AT ONCE. It was also claimed in review that the two are
 176// mutually exclusive by construction; that is wrong too. A one-unit FIFO
 177// shipment draws from the oldest lot, which holds 100 units, so 64 of them
 178// never exhaust it: 64 items x 64 receipts then 64 items x 64 one-unit
 179// shipments leaves 4096 live lots and exactly 8192 entries. The figures below
 180// were taken on a register asserted in-fixture to sit at all three caps
 181// simultaneously, so the combined shape is measured and not argued about.
 182//
 183// Free reads, net of a trivial-read baseline on the same register:
 184//
 185//	Rederive                 506,486,523
 186//	Render, register page    186,179,474
 187//	CrossFoot                 96,213,741
 188//	Render, item page          6,467,621
 189//	Render, index                192,716
 190//
 191// The dearest is 16.9% of the 3,000,000,000 maxGasQuery. The two axes do not
 192// compose in any single read's price, because no single read walks both:
 193// Rederive walks postings and reads no lot, while CrossFoot and Render walk
 194// lots and read three accounts plus EntryCount. Measured rather than inferred —
 195// Rederive at the lot cap agrees with the same read on a lot-cheap register
 196// (506,495,345) to 0.002%, which is what proves the lot axis does not price it.
 197// So the ceiling is the MAX of the two axes and never their product.
 198//
 199// A bound is not a price, and one item per call is what keeps the write side
 200// cheap: [Receive] and [Ship] each touch exactly one pool. Measured on the same
 201// toolchain, net of the same kind of baseline:
 202//
 203//	Receive creating the 64th lot        930,676
 204//	Ship of all OnHand, dropping all 64 lots  11,837,593
 205//	Receive refused at the lot cap       304,454
 206//
 207// The dearest write is 2.3% of the dearest read, so the read side is what the
 208// caps exist for. See DEPLOY_READINESS.md for the transcript, the per-header
 209// GAS pairing rule, and the two limits of these figures: they came from
 210// throwaway test files that were deleted rather than committed, so they are
 211// reproducible but NOT guarded by the suite; and gno test's GAS line covers a
 212// whole test function, so a figure is only a call's own price when the fixture
 213// that enables it lives in a separate test. Both halves bit: the refused
 214// receipt first measured at 53,123,019 — 174x its real price — because its
 215// 64-lot fixture shared its GAS line.
 216//
 217// # Deploy order
 218//
 219// This realm imports p/journal and p/lots, so on any chain the order is
 220// p/journal, p/lots, then this realm. The order is stated and the deployment
 221// STATE of either import deliberately is not: a sentence about what is
 222// deployed is true at signing time at best, and addpkg freezes it — r/assets
 223// carries exactly that fossil. The import relation is the durable fact.
 224package inventory
 225
 226import (
 227	"chain/runtime"
 228	"chain/runtime/unsafe"
 229	"strconv"
 230	"strings"
 231
 232	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
 233	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/lots"
 234	"gno.land/p/nt/avl/v0"
 235	"gno.land/p/nt/markdown/sanitize/v0"
 236)
 237
 238// RealmPath is this realm's own path, used to build Render links.
 239const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/inventory"
 240
 241// The fixed chart. The codes follow r/books' convention, where an account's
 242// LEADING DIGIT fixes its kind: 1 asset, 2 liability, 3 equity, 4 revenue,
 243// 5 expense. A six-series code would be an invalid account under that
 244// convention, which is why cost of goods sold is 5000 and not the 6000 some
 245// charts use.
 246const (
 247	InventoryCode = "1300"
 248	InventoryName = "Inventory"
 249
 250	COGSCode = "5000"
 251	COGSName = "Cost of goods sold"
 252
 253	ContribCode = "3900"
 254	ContribName = "Inventory contributed"
 255)
 256
 257// MaxItemsPerRegister caps one owner's SKU count. Every register-wide
 258// cross-foot walks every item's lots, and this bound is half of what keeps
 259// those reads affordable for everyone.
 260const MaxItemsPerRegister = 64
 261
 262// MaxLotsPerItem caps one item's LIVE lots, well below p/lots' MaxLotsLimit of
 263// 1024. It is the other half of the read bound: the worst case a register-wide
 264// walk must visit is MaxItemsPerRegister*MaxLotsPerItem lots.
 265//
 266// Exhausted lots are dropped by p/lots, so this bounds concurrent lots and not
 267// the number of receipts an item may ever take. A WAVG item merges every
 268// receipt into one lot and so can never reach it — which means this is NOT the
 269// binding constraint for a WAVG item, and that item has a merge-overflow
 270// refusal of its own instead. See the package doc.
 271const MaxLotsPerItem = 64
 272
 273// MaxEntriesPerRegister caps one owner's journal, far below journal's
 274// MaxEntriesLimit of 1000000 because Rederive is linear in the posting count
 275// and is a free read here.
 276//
 277// A register that fills refuses further receipts and shipments rather than
 278// dropping history. There is deliberately no Archive — it would destroy the
 279// record the invariants are checked against — so the remedy is a second
 280// register under a second address, which is a real limitation and not a
 281// hidden one.
 282const MaxEntriesPerRegister = 8192
 283
 284// MaxSKULen bounds a SKU, which is an avl key, a Render table cell and a
 285// Render path component all at once.
 286const MaxSKULen = 32
 287
 288// MaxItemNameLen bounds an item's human name. Every name is a candidate for
 289// Render output.
 290const MaxItemNameLen = 64
 291
 292// RenderItemLimit caps how many items one register page tabulates.
 293const RenderItemLimit = 25
 294
 295// RenderLotLimit caps how many lots one item page tabulates.
 296const RenderLotLimit = 20
 297
 298// maxInt64 is the largest int64. It is a precondition of the counter guards
 299// below, so it is pinned BY BEHAVIOUR in the suite rather than by a test that
 300// restates the literal: TestMaxInt64IsTheInt64Maximum proves it wraps when
 301// incremented.
 302const maxInt64 int64 = 9223372036854775807
 303
 304// register is one owner's inventory sub-ledger and the book its postings go
 305// to.
 306//
 307// The book and every pool are POINTERS and none is ever returned by any
 308// exported function — p/journal consumer obligation 4 and the identical rule
 309// for p/lots: a *Book is a mutable handle and a *Pool is a mutable handle, and
 310// handing either across a realm boundary would hand out the right to post or
 311// to consume.
 312type register struct {
 313	book     *journal.Book
 314	items    *avl.Tree // sku -> *item
 315	openedAt int64
 316}
 317
 318// item is one stock-keeping unit and its cost pool.
 319type item struct {
 320	// sku is carried in the value as well as the tree key. The key IS the
 321	// sku here — unlike r/assets, whose keys are zero-padded ids — so this
 322	// is a convenience rather than a second encoding to keep in step.
 323	sku     string
 324	name    string
 325	pool    *lots.Pool
 326	addedAt int64
 327
 328	// The posted counters, maintained INDEPENDENTLY of the pool. Each is the
 329	// cumulative amount this realm actually posted for this item, so
 330	// comparing one against the pool's own figure compares two things rather
 331	// than restating one. That is the whole of [Invariant].
 332	postedRecv int64
 333	postedCOGS int64
 334}
 335
 336// regs maps an owner's address string to that owner's *register.
 337var regs = avl.NewTree()
 338
 339// regCount is maintained alongside the tree so the index page never has to
 340// iterate it.
 341var regCount int
 342
 343// ---------------------------------------------------------------------------
 344// Guards
 345// ---------------------------------------------------------------------------
 346
 347// refuseCoins panics if a user call arrives carrying coins.
 348//
 349// This realm has no withdrawal path, so accepted coins would be stranded
 350// permanently. The guard is on IsUserCall rather than IsUser deliberately:
 351// OriginSend is only meaningful for a direct MsgCall, and the MsgRun
 352// ephemeral can consume the envelope before forwarding control, so IsUser
 353// would be the wrong predicate.
 354//
 355// The IsCurrent() check is a separate concern and is not about coins: this is
 356// a non-crossing helper reading identity off a realm PARAMETER, which is the
 357// designation-forgery shape (security.md Class 2) whether or not a caller
 358// exercising it exists today. It is a no-op at every call site here, and that
 359// claim has one more hop in this realm than in its siblings: addItem is itself
 360// non-crossing and forwards the cur it was given, so the chain is
 361// AddFIFO/AddLIFO/AddWAVG -> addItem -> here. Every link forwards a crossing
 362// entrypoint's OWN first cur, which is runtime-current by construction and
 363// stays current through any number of non-crossing calls — so the guard still
 364// never fires, and the extra hop is exactly why having it at the point of USE
 365// rather than at the entrypoints is the right placement. It is kept because
 366// this realm is IMMUTABLE after addpkg. p/coinio guards the same shape in
 367// Receive, Payout and Sweep; this is that house precedent applied.
 368//
 369// No count of the call sites is written down, per CLAUDE.md § 4a.
 370func refuseCoins(cur realm) {
 371	if !cur.IsCurrent() {
 372		panic("inventory: refuseCoins called with a realm that is not current")
 373	}
 374	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
 375		panic("inventory: this realm holds no coins and has no withdrawal path; send none")
 376	}
 377}
 378
 379// mustOwnRegister returns the CALLER'S register, and there is no variant that
 380// returns anyone else's. Every mutating entrypoint goes through this, so
 381// ownership is implicit in the call.
 382//
 383// "Ownership is never a parameter" is what this comment used to conclude, and
 384// it is true of the SIGNATURE and was never true of the realm VALUE: the owner
 385// is derived from cur, so an uncurrent cur IS an ownership parameter. That is
 386// the same Class 2 shape refuseCoins carries above and the more consequential
 387// half of it, because this is the function that decides who owns the register
 388// rather than one that only refuses coins. The addItem hop described above
 389// applies here too. The guard is a no-op today for the same reason, kept for
 390// the same reason, and its call sites are likewise not counted here. See
 391// AUDIT.md.
 392func mustOwnRegister(cur realm) *register {
 393	if !cur.IsCurrent() {
 394		panic("inventory: mustOwnRegister called with a realm that is not current")
 395	}
 396	owner := cur.Previous().Address()
 397	r, ok := reg(owner)
 398	if !ok {
 399		panic("inventory: no register for " + owner.String() + "; call OpenRegister first")
 400	}
 401	return r
 402}
 403
 404// reg looks an owner's register up. avl's Get returns a bare `any` and signals
 405// absence with nil, so the nil test IS the lookup result and not a defensive
 406// extra — every value this realm stores is a non-nil pointer.
 407func reg(owner address) (*register, bool) {
 408	v := regs.Get(owner.String())
 409	if v == nil {
 410		return nil, false
 411	}
 412	return v.(*register), true
 413}
 414
 415func mustReg(owner address) *register {
 416	r, ok := reg(owner)
 417	if !ok {
 418		panic("inventory: no register for " + owner.String())
 419	}
 420	return r
 421}
 422
 423func mustItem(r *register, sku string) *item {
 424	v := r.items.Get(sku)
 425	if v == nil {
 426		panic("inventory: no item " + sanitize.InlineText(sku))
 427	}
 428	return v.(*item)
 429}
 430
 431// checkSKU validates a SKU against a deliberately narrow charset:
 432// A-Z, a-z, 0-9, '.', '_' and '-'.
 433//
 434// The restriction buys three things at once, which is why it is worth imposing
 435// on a caller. A SKU is an avl KEY, so its byte order is the Render table's
 436// row order. It is a Render PATH COMPONENT, so a '/' would forge a route and a
 437// '?' or '#' would truncate one. And it is a markdown TABLE CELL, so a '|'
 438// would forge a column. Escaping at each use site would be three chances to
 439// miss one; a charset is checked once, here, on the way in.
 440//
 441// Case is significant and "sku-1" and "SKU-1" are different items. That is a
 442// real sharp edge for a human operator and is the honest reading of a key:
 443// folding case would mean this realm, not the owner, decides which SKUs
 444// collide.
 445func checkSKU(sku string) {
 446	if sku == "" {
 447		panic("inventory: sku must not be blank")
 448	}
 449	if len(sku) > MaxSKULen {
 450		panic("inventory: sku too long, max " + strconv.Itoa(MaxSKULen))
 451	}
 452	for i := 0; i < len(sku); i++ {
 453		c := sku[i]
 454		switch {
 455		case c >= 'A' && c <= 'Z':
 456		case c >= 'a' && c <= 'z':
 457		case c >= '0' && c <= '9':
 458		case c == '.' || c == '_' || c == '-':
 459		default:
 460			panic("inventory: sku may use only letters, digits, '.', '_' and '-'")
 461		}
 462	}
 463}
 464
 465func checkItemName(name string) {
 466	if strings.TrimSpace(name) == "" {
 467		panic("inventory: name must not be blank")
 468	}
 469	if len(name) > MaxItemNameLen {
 470		panic("inventory: name too long, max " + strconv.Itoa(MaxItemNameLen))
 471	}
 472}
 473
 474// ---------------------------------------------------------------------------
 475// Writes
 476// ---------------------------------------------------------------------------
 477
 478// OpenRegister creates the caller's register and opens its three accounts. It
 479// panics if the caller already has one.
 480//
 481// The chart is opened HERE rather than on demand, and the book is created with
 482// a cap of exactly three accounts. Both facts are load-bearing: they are why
 483// this realm is its book's only writer and why the cross-foot identities are
 484// exact rather than approximate.
 485func OpenRegister(cur realm) {
 486	refuseCoins(cur)
 487	owner := cur.Previous().Address()
 488	key := owner.String()
 489	if regs.Has(key) {
 490		panic("inventory: this address already has a register")
 491	}
 492
 493	b := journal.MustNew(3, MaxEntriesPerRegister)
 494	b.MustOpen(InventoryCode, InventoryName, journal.Asset)
 495	b.MustOpen(COGSCode, COGSName, journal.Expense)
 496	b.MustOpen(ContribCode, ContribName, journal.Equity)
 497
 498	regs.Set(key, &register{
 499		book:     b,
 500		items:    avl.NewTree(),
 501		openedAt: runtime.ChainHeight(),
 502	})
 503	regCount++
 504}
 505
 506// AddFIFO adds an item whose shipments are costed oldest-lot-first.
 507func AddFIFO(cur realm, sku, name string) {
 508	addItem(cur, sku, name, lots.FIFO)
 509}
 510
 511// AddLIFO adds an item whose shipments are costed newest-lot-first.
 512func AddLIFO(cur realm, sku, name string) {
 513	addItem(cur, sku, name, lots.LIFO)
 514}
 515
 516// AddWAVG adds an item whose receipts are merged into a single re-anchored lot
 517// and whose shipments are costed at the blended unit cost.
 518//
 519// A WAVG item holds exactly one lot however many receipts it takes, so it can
 520// never reach MaxLotsPerItem. It can still refuse a receipt: the MERGE has an
 521// overflow precondition the other two methods never evaluate, and it is a
 522// property of the pool's accumulated history rather than of the receipt. See
 523// the package doc; the practical consequence is that [LotCapacity] is not the
 524// figure to watch for a WAVG item.
 525func AddWAVG(cur realm, sku, name string) {
 526	addItem(cur, sku, name, lots.WAVG)
 527}
 528
 529func addItem(cur realm, sku, name string, m lots.Method) {
 530	refuseCoins(cur)
 531	r := mustOwnRegister(cur)
 532	checkSKU(sku)
 533	checkItemName(name)
 534
 535	if r.items.Has(sku) {
 536		panic("inventory: item " + sanitize.InlineText(sku) + " already exists")
 537	}
 538	if r.items.Size() >= MaxItemsPerRegister {
 539		panic("inventory: register is full, max " + strconv.Itoa(MaxItemsPerRegister) + " items")
 540	}
 541
 542	p, err := lots.NewPool(m, MaxLotsPerItem)
 543	if err != nil {
 544		panic(err)
 545	}
 546	r.items.Set(sku, &item{
 547		sku:     sku,
 548		name:    name,
 549		pool:    p,
 550		addedAt: runtime.ChainHeight(),
 551	})
 552}
 553
 554// Receive takes qty units costing cost in total into the item's pool and posts
 555// debit 1300 / credit 3900, returning the entry id.
 556//
 557// A cost of ZERO is accepted — free goods — and posts nothing; the returned id
 558// is then 0, which is not a valid entry id and is the signal that no entry was
 559// made. See the package doc.
 560//
 561// Panics if the item does not exist, if qty is not positive, if cost is
 562// negative, if the pool's lot cap is reached, if a WAVG merge would breach the
 563// per-lot overflow precondition — a refusal that depends on the item's history
 564// and not on this receipt, see [AddWAVG] — or if the book is full. Every
 565// one of those aborts the whole transaction, which is what makes the
 566// pool-then-post order safe: an error return would NOT revert the pool update,
 567// a panic does.
 568func Receive(cur realm, sku string, qty, cost int64) uint64 {
 569	refuseCoins(cur)
 570	r := mustOwnRegister(cur)
 571	it := mustItem(r, sku)
 572
 573	if err := it.pool.Receive(qty, cost); err != nil {
 574		panic(err)
 575	}
 576
 577	// Guard the realm's own counter before committing it. The bound argument
 578	// says this cannot fire — postedRecv tracks pool.ReceivedCost() exactly,
 579	// and p/lots already refused to let that overflow — and it is written
 580	// anyway, because "unreachable" is an argument about code that may be
 581	// edited and the cost of being wrong here is a silently negative
 582	// inventory figure. p/lots' CostOnHand makes the same trade for the same
 583	// reason.
 584	if it.postedRecv > maxInt64-cost {
 585		panic("inventory: posted receipt counter would overflow")
 586	}
 587	it.postedRecv += cost
 588
 589	if cost == 0 {
 590		return 0
 591	}
 592	return r.book.MustPost(runtime.ChainHeight(), "receive "+sku, []journal.Posting{
 593		{Account: InventoryCode, Side: journal.Debit, Amount: cost},
 594		{Account: ContribCode, Side: journal.Credit, Amount: cost},
 595	})
 596}
 597
 598// Ship consumes qty units from the item's pool and posts debit 5000 /
 599// credit 1300 for the cost it DERIVED, returning that cost.
 600//
 601// The cost is never a parameter. It is whatever the item's costing method
 602// charges against the lots on hand, which is the whole product.
 603//
 604// A derived cost of ZERO posts nothing — goods that cost nothing to acquire
 605// cost nothing to ship. The return value is then 0 and so is indistinguishable
 606// from "no entry made", deliberately: both facts are the same fact here.
 607//
 608// Panics if the item does not exist, if qty is not positive, if qty exceeds
 609// the quantity on hand (p/lots fails closed with ErrInsufficient and consumes
 610// nothing), or if the book is full.
 611func Ship(cur realm, sku string, qty int64) int64 {
 612	refuseCoins(cur)
 613	r := mustOwnRegister(cur)
 614	it := mustItem(r, sku)
 615
 616	charge, err := it.pool.Consume(qty)
 617	if err != nil {
 618		panic(err)
 619	}
 620
 621	// Same trade as Receive's guard, and the same bound argument: postedCOGS
 622	// tracks pool.ChargedCost(), whose accumulation p/lots guards.
 623	if it.postedCOGS > maxInt64-charge {
 624		panic("inventory: posted COGS counter would overflow")
 625	}
 626	it.postedCOGS += charge
 627
 628	if charge == 0 {
 629		return 0
 630	}
 631	r.book.MustPost(runtime.ChainHeight(), "ship "+sku, []journal.Posting{
 632		{Account: COGSCode, Side: journal.Debit, Amount: charge},
 633		{Account: InventoryCode, Side: journal.Credit, Amount: charge},
 634	})
 635	return charge
 636}
 637
 638// Rename changes an item's human name. The SKU, the costing method and every
 639// lot are immutable; a name is presentation only and nothing derives from it.
 640func Rename(cur realm, sku, name string) {
 641	refuseCoins(cur)
 642	r := mustOwnRegister(cur)
 643	it := mustItem(r, sku)
 644	checkItemName(name)
 645	it.name = name
 646}
 647
 648// ---------------------------------------------------------------------------
 649// Reads — register
 650// ---------------------------------------------------------------------------
 651
 652// HasRegister reports whether an address has opened a register.
 653func HasRegister(owner address) bool { return regs.Has(owner.String()) }
 654
 655// Registers is how many registers exist across the realm.
 656func Registers() int { return regCount }
 657
 658// OpenedAt is the height an owner's register was opened at.
 659func OpenedAt(owner address) int64 { return mustReg(owner).openedAt }
 660
 661// ItemCount is how many items an owner's register holds.
 662func ItemCount(owner address) int { return mustReg(owner).items.Size() }
 663
 664// Height is the current chain height, so a reader can date every other figure
 665// on this page without a second query.
 666func Height() int64 { return runtime.ChainHeight() }
 667
 668// SKUs lists an owner's SKUs in key order, starting at or after from and
 669// returning at most limit of them. An empty from starts at the beginning.
 670//
 671// This enumerator exists because its absence is a known defect elsewhere in
 672// this portfolio: r/books exposes no account enumerator, so a third party
 673// cannot discover what to ask it about without already knowing. A sub-ledger
 674// whose invariants are per-item is useless to a verifier who cannot list the
 675// items, so the list is part of the product rather than an afterthought.
 676//
 677// A non-positive limit returns nothing rather than everything. The cap is the
 678// item cap, so a caller may always page the whole register.
 679func SKUs(owner address, from string, limit int) []string {
 680	r := mustReg(owner)
 681	if limit <= 0 {
 682		return nil
 683	}
 684	if limit > MaxItemsPerRegister {
 685		limit = MaxItemsPerRegister
 686	}
 687	out := make([]string, 0, limit)
 688	r.items.Iterate(from, "", func(k string, _ any) bool {
 689		if len(out) >= limit {
 690			return true
 691		}
 692		out = append(out, k)
 693		return false
 694	})
 695	return out
 696}
 697
 698// ---------------------------------------------------------------------------
 699// Reads — item
 700// ---------------------------------------------------------------------------
 701
 702// HasItem reports whether an owner's register holds a SKU.
 703func HasItem(owner address, sku string) bool {
 704	r, ok := reg(owner)
 705	if !ok {
 706		return false
 707	}
 708	return r.items.Has(sku)
 709}
 710
 711// ItemName is an item's human name.
 712func ItemName(owner address, sku string) string {
 713	return mustItem(mustReg(owner), sku).name
 714}
 715
 716// Method is an item's costing method, as "fifo", "lifo" or
 717// "weighted-average".
 718func Method(owner address, sku string) string {
 719	return mustItem(mustReg(owner), sku).pool.Method().String()
 720}
 721
 722// AddedAt is the height an item was added at.
 723func AddedAt(owner address, sku string) int64 {
 724	return mustItem(mustReg(owner), sku).addedAt
 725}
 726
 727// OnHand is the quantity of an item on hand, DERIVED by walking its live lots
 728// rather than read from a counter — which is what makes [ItemCrossFootQty] a
 729// comparison rather than a tautology.
 730func OnHand(owner address, sku string) int64 {
 731	return mustItem(mustReg(owner), sku).pool.QtyOnHand()
 732}
 733
 734// CostOnHand is the carrying value of an item's live lots: the figure this
 735// item contributes to the 1300 balance. Derived, for the same reason as
 736// [OnHand].
 737func CostOnHand(owner address, sku string) int64 {
 738	c, err := mustItem(mustReg(owner), sku).pool.CostOnHand()
 739	if err != nil {
 740		panic(err)
 741	}
 742	return c
 743}
 744
 745// Received is an item's cumulative received quantity and cost.
 746func Received(owner address, sku string) (qty, cost int64) {
 747	p := mustItem(mustReg(owner), sku).pool
 748	return p.ReceivedQty(), p.ReceivedCost()
 749}
 750
 751// Charged is an item's cumulative shipped quantity and the cost charged out
 752// for it. The cost figure is derived: no caller has ever supplied it.
 753func Charged(owner address, sku string) (qty, cost int64) {
 754	p := mustItem(mustReg(owner), sku).pool
 755	return p.ConsumedQty(), p.ChargedCost()
 756}
 757
 758// Receipts is how many receipts an item has taken, including those whose lots
 759// are exhausted and dropped. It is NOT the live lot count — see [LotCount].
 760func Receipts(owner address, sku string) int64 {
 761	return mustItem(mustReg(owner), sku).pool.ReceiptCount()
 762}
 763
 764// LotCount is how many LIVE lots an item holds. Exhausted lots are dropped.
 765func LotCount(owner address, sku string) int {
 766	return mustItem(mustReg(owner), sku).pool.LotCount()
 767}
 768
 769// LotAt reports the i'th live lot of an item in receipt order, and whether i
 770// was in range. It returns primitives rather than a p/lots.Lot so that the
 771// figures are readable from vm/qeval without the caller knowing that type.
 772func LotAt(owner address, sku string, i int) (qty, cost, consumed, remaining, remainingCost int64, ok bool) {
 773	l, found := mustItem(mustReg(owner), sku).pool.Lot(i)
 774	if !found {
 775		return 0, 0, 0, 0, 0, false
 776	}
 777	rc, err := l.RemainingCost()
 778	if err != nil {
 779		panic(err)
 780	}
 781	return l.Qty, l.Cost, l.Consumed, l.Remaining(), rc, true
 782}
 783
 784// Capacity reports how much room an owner's register has left.
 785func Capacity(owner address) (itemsLeft, entriesLeft int) {
 786	r := mustReg(owner)
 787	return MaxItemsPerRegister - r.items.Size(), MaxEntriesPerRegister - r.book.EntryCount()
 788}
 789
 790// LotCapacity reports how many further live lots one item can hold. A WAVG
 791// item merges into one lot, so its figure is a bound it will never approach —
 792// and reading headroom off it for a WAVG item is therefore reading the wrong
 793// limit, because the refusal that CAN bind there is a merge overflow this
 794// figure knows nothing about. See [AddWAVG].
 795func LotCapacity(owner address, sku string) int {
 796	return MaxLotsPerItem - mustItem(mustReg(owner), sku).pool.LotCount()
 797}
 798
 799// ---------------------------------------------------------------------------
 800// Reads — the ledger
 801// ---------------------------------------------------------------------------
 802
 803// Balance returns an account's balance in its NORMAL direction and whether the
 804// code exists. All three of this realm's accounts are normal, so all three
 805// read non-negative.
 806func Balance(owner address, code string) (int64, bool) {
 807	return mustReg(owner).book.Balance(code)
 808}
 809
 810// RawBalance returns debits minus credits regardless of kind. Summed over the
 811// three accounts it is exactly zero in a balanced book, which is what makes it
 812// the useful figure for a consistency check and the wrong one for a report.
 813func RawBalance(owner address, code string) (int64, bool) {
 814	a, ok := mustReg(owner).book.Account(code)
 815	if !ok {
 816		return 0, false
 817	}
 818	return a.RawDelta(), true
 819}
 820
 821// AccountKind returns an account's kind as a word, and whether it exists.
 822func AccountKind(owner address, code string) (string, bool) {
 823	a, ok := mustReg(owner).book.Account(code)
 824	if !ok {
 825		return "", false
 826	}
 827	return a.Kind.String(), true
 828}
 829
 830// InventoryDebit is the 1300 balance: the value of goods on hand per the
 831// ledger.
 832func InventoryDebit(owner address) int64 {
 833	b, ok := mustReg(owner).book.Balance(InventoryCode)
 834	if !ok {
 835		panic("inventory: register has no " + InventoryCode + " account")
 836	}
 837	return b
 838}
 839
 840// COGSDebit is the 5000 balance: everything ever charged out as cost of goods
 841// sold.
 842func COGSDebit(owner address) int64 {
 843	b, ok := mustReg(owner).book.Balance(COGSCode)
 844	if !ok {
 845		panic("inventory: register has no " + COGSCode + " account")
 846	}
 847	return b
 848}
 849
 850// ContributedCredit is the 3900 balance. Read it as "goods arrived from
 851// somewhere this ledger does not model" — see the package doc on why 3900 is a
 852// plug this realm is forced to pick.
 853func ContributedCredit(owner address) int64 {
 854	b, ok := mustReg(owner).book.Balance(ContribCode)
 855	if !ok {
 856		panic("inventory: register has no " + ContribCode + " account")
 857	}
 858	return b
 859}
 860
 861// EntryCount is how many entries an owner's book holds.
 862func EntryCount(owner address) int { return mustReg(owner).book.EntryCount() }
 863
 864// Totals are the book's total debits and credits.
 865func Totals(owner address) (debits, credits int64) {
 866	b := mustReg(owner).book
 867	return b.TotalDebits(), b.TotalCredits()
 868}
 869
 870// IsBalanced reports whether total debits equal total credits.
 871func IsBalanced(owner address) bool { return mustReg(owner).book.IsBalanced() }
 872
 873// Equation reports the accounting equation: debit-normal against
 874// credit-normal, and whether they offset. For this realm that is
 875// 1300 + 5000 against 3900, which offsets at 0 = 0 on an empty register and
 876// after every posting.
 877//
 878// READ THE SIGN BEFORE PRESENTING EITHER FIGURE. journal partitions RAW
 879// DELTAS — debits minus credits — so creditNormal is NEGATIVE whenever 3900
 880// carries a credit balance, and `offsets` means debitNormal + creditNormal
 881// == 0, not debitNormal == creditNormal. The textbook "assets = equity" has
 882// both sides positive; this does not. A caller rendering a balance sheet
 883// wants -creditNormal. The first test written against this function asserted
 884// the right magnitude with the wrong sign and was red for it, which is why
 885// this paragraph exists rather than a reader discovering it.
 886//
 887// What `offsets` does NOT prove is journal's obligation 7, inherited here
 888// verbatim: the two subtotals are a partition of the same addends, so
 889// reclassifying every account in the chart changes both and leaves `offsets`
 890// true. A Kind set wrongly at Open time is invisible to it. What pins this
 891// realm's chart is the PAIR — debitNormal == InventoryDebit + COGSDebit and
 892// creditNormal == -ContributedCredit — and the suite asserts exactly that.
 893//
 894// Sharper than that, because the partition is arithmetically inert: the two
 895// subtotals are added back together, so `offsets` reduces to
 896// Σ debits == Σ credits over the whole chart, which is the FIRST of the three
 897// conjuncts [IsBalanced] checks. A balanced book therefore always offsets, and
 898// `offsets` carries no fact [IsBalanced] has not already carried more
 899// strongly. The informative half of this function is the PAIR OF FIGURES, not
 900// the boolean — the figures move when a Kind changes and the boolean does not.
 901// (The two walks accumulate in different orders, so a mid-sum overflow could
 902// in principle separate them. At 8192 entries it would take amounts no
 903// register can reach, and it is not a second independent check.)
 904func Equation(owner address) (debitNormal, creditNormal int64, offsets bool) {
 905	return mustReg(owner).book.AccountingEquation()
 906}
 907
 908// Rederive re-walks every posting in the book and reports whether the account
 909// totals agree with the journal.
 910//
 911// It is a FREE read, unlike r/books' owner-only equivalent, for the reason
 912// given in the package doc. The cost is linear in the posting count, which is
 913// what MaxEntriesPerRegister bounds.
 914func Rederive(owner address) (debits, credits int64, agrees bool) {
 915	return mustReg(owner).book.RederiveFromJournal()
 916}
 917
 918// ---------------------------------------------------------------------------
 919// The invariants
 920// ---------------------------------------------------------------------------
 921
 922// Invariant is the per-item derivation check, and it is this realm's central
 923// claim:
 924//
 925//	postedCOGS == pool.ChargedCost()
 926//
 927// The left figure is what this realm posted to 5000 for the item; the right is
 928// what the pool says it charged. They are maintained by different code in
 929// different packages, so a false here means the realm posted an amount its own
 930// lots do not imply — which no code path in this realm can produce, and which
 931// is exactly why it is worth exporting. A claim is worth more checkable by a
 932// stranger than asserted in a comment.
 933//
 934// The receipt side is checked the same way by [InvariantReceipts].
 935func Invariant(owner address, sku string) (posted, derived int64, agrees bool) {
 936	it := mustItem(mustReg(owner), sku)
 937	d := it.pool.ChargedCost()
 938	return it.postedCOGS, d, it.postedCOGS == d
 939}
 940
 941// InvariantReceipts is [Invariant] for the receipt side:
 942//
 943//	postedRecv == pool.ReceivedCost()
 944func InvariantReceipts(owner address, sku string) (posted, derived int64, agrees bool) {
 945	it := mustItem(mustReg(owner), sku)
 946	d := it.pool.ReceivedCost()
 947	return it.postedRecv, d, it.postedRecv == d
 948}
 949
 950// ItemCrossFoot is p/lots' own cost identity for one item:
 951//
 952//	receivedCost == chargedCost + costOnHand
 953//
 954// Received and charged are counters; on-hand is a walk of the live lots. The
 955// identity therefore compares two independently maintained things, which is
 956// why p/lots keeps them apart.
 957func ItemCrossFoot(owner address, sku string) (received, charged, onHand int64, foots bool) {
 958	return mustItem(mustReg(owner), sku).pool.CrossFootCost()
 959}
 960
 961// ItemCrossFootQty is the quantity identity for one item:
 962//
 963//	receivedQty == consumedQty + qtyOnHand
 964//
 965// It is not implied by [ItemCrossFoot]: costs and quantities can disagree
 966// independently, and a bug that moved cost without moving quantity would be
 967// invisible to the cost identity alone.
 968func ItemCrossFootQty(owner address, sku string) (received, consumed, onHand int64, foots bool) {
 969	return mustItem(mustReg(owner), sku).pool.CrossFootQty()
 970}
 971
 972// CrossFootInventory is the register-wide check on 1300: the sum of every
 973// item's carrying value against the ledger balance.
 974//
 975// The identity is EXACT, not approximate, and the reason is structural: this
 976// realm exposes no general posting entrypoint, so it is its book's only
 977// writer. Adding a general Post would destroy this check, which is the cost of
 978// adding one.
 979func CrossFootInventory(owner address) (sumOnHand, balance int64, agrees bool) {
 980	r := mustReg(owner)
 981	var sum int64
 982	var failed bool
 983	r.items.Iterate("", "", func(_ string, v any) bool {
 984		c, err := v.(*item).pool.CostOnHand()
 985		if err != nil {
 986			failed = true
 987			return true
 988		}
 989		sum += c
 990		return false
 991	})
 992	b := InventoryDebit(owner)
 993	if failed {
 994		// A pool that cannot value itself makes the sum meaningless, so the
 995		// check REPORTS disagreement rather than panicking or quietly
 996		// returning a short sum. A partial total compared against a full
 997		// balance is the shape of a false pass.
 998		return 0, b, false
 999	}
1000	return sum, b, sum == b
1001}
1002
1003// CrossFootCOGS is the register-wide check on 5000: the sum of every item's
1004// charged cost against the ledger balance.
1005func CrossFootCOGS(owner address) (sumCharged, balance int64, agrees bool) {
1006	r := mustReg(owner)
1007	var sum int64
1008	r.items.Iterate("", "", func(_ string, v any) bool {
1009		sum += v.(*item).pool.ChargedCost()
1010		return false
1011	})
1012	b := COGSDebit(owner)
1013	return sum, b, sum == b
1014}
1015
1016// CrossFootContributed is the register-wide check on 3900: the sum of every
1017// item's received cost against the ledger balance.
1018func CrossFootContributed(owner address) (sumReceived, balance int64, agrees bool) {
1019	r := mustReg(owner)
1020	var sum int64
1021	r.items.Iterate("", "", func(_ string, v any) bool {
1022		sum += v.(*item).pool.ReceivedCost()
1023		return false
1024	})
1025	b := ContributedCredit(owner)
1026	return sum, b, sum == b
1027}
1028
1029// CrossFoot reports all four register-wide agreements as separate bools.
1030//
1031// They are returned SEPARATELY and there is deliberately no single aggregate
1032// bool: a one-word verdict over four checks is the shape that gets quoted
1033// without its coverage, and a reader who wants the figures behind any one of
1034// these has [CrossFootInventory], [CrossFootCOGS], [CrossFootContributed] and
1035// [Equation].
1036func CrossFoot(owner address) (inventoryAgrees, cogsAgrees, contributedAgrees, equationOffsets bool) {
1037	_, _, inv := CrossFootInventory(owner)
1038	_, _, cogs := CrossFootCOGS(owner)
1039	_, _, contrib := CrossFootContributed(owner)
1040	_, _, offsets := Equation(owner)
1041	return inv, cogs, contrib, offsets
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Render
1046// ---------------------------------------------------------------------------
1047
1048// Render shows the realm index at "/", one owner's register at "/<address>",
1049// and one item at "/<address>/<sku>".
1050func Render(path string) string {
1051	p := strings.TrimSpace(strings.Trim(path, "/"))
1052	if p == "" {
1053		return renderIndex()
1054	}
1055	if i := strings.Index(p, "/"); i >= 0 {
1056		return renderItem(address(p[:i]), p[i+1:])
1057	}
1058	return renderRegister(address(p))
1059}
1060
1061func renderIndex() string {
1062	var sb strings.Builder
1063	sb.WriteString("# inventory — a sub-ledger whose COGS is derived\n\n")
1064	sb.WriteString("Cost lots held as state, the cost of a shipment **derived** ")
1065	sb.WriteString("from those lots by the item's costing method, and **no ")
1066	sb.WriteString("custody of anything**.\n\n")
1067
1068	sb.WriteString("> A general ledger takes the amount from its caller, so it ")
1069	sb.WriteString("cannot tell a correctly costed shipment from a flattering ")
1070	sb.WriteString("one. This realm never takes an amount: `Ship` takes a ")
1071	sb.WriteString("quantity and returns the cost it derived. That is the whole ")
1072	sb.WriteString("product, and it proves arithmetic — not truth. Nothing here ")
1073	sb.WriteString("ties a cost figure to a real holding.\n\n")
1074
1075	sb.WriteString("## State\n\n")
1076	sb.WriteString("- registers opened: **" + strconv.Itoa(regCount) + "**\n")
1077	sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
1078
1079	sb.WriteString("## The chart is three accounts and is fixed\n\n")
1080	sb.WriteString("| code | name | kind | normal |\n")
1081	sb.WriteString("|---|---|---|---|\n")
1082	sb.WriteString("| `" + InventoryCode + "` | " + InventoryName + " | asset | debit |\n")
1083	sb.WriteString("| `" + COGSCode + "` | " + COGSName + " | expense | debit |\n")
1084	sb.WriteString("| `" + ContribCode + "` | " + ContribName + " | equity | credit |\n\n")
1085	sb.WriteString("A receipt is debit `" + InventoryCode + "` / credit `")
1086	sb.WriteString(ContribCode + "`. A shipment is debit `" + COGSCode)
1087	sb.WriteString("` / credit `" + InventoryCode + "` for the derived cost. ")
1088	sb.WriteString("There is no way to open a fourth account.\n\n")
1089	sb.WriteString("**`" + ContribCode + "` is a plug.** A debit to inventory ")
1090	sb.WriteString("needs a credit leg, and every candidate asserts something ")
1091	sb.WriteString("this realm cannot observe — a payable it would never ")
1092	sb.WriteString("relieve, a cash balance it does not hold, or a ")
1093	sb.WriteString("contribution. The contribution was chosen because it is ")
1094	sb.WriteString("the only one that stays coherent with no follow-up event, ")
1095	sb.WriteString("**not** because it is true. A register whose goods were ")
1096	sb.WriteString("bought on credit understates liabilities by exactly this ")
1097	sb.WriteString("balance.\n\n")
1098
1099	sb.WriteString("## Costing methods\n\n")
1100	sb.WriteString("| entrypoint | method | lots held |\n")
1101	sb.WriteString("|---|---|---|\n")
1102	sb.WriteString("| `AddFIFO` | " + lots.FIFO.String() + " | one per live receipt |\n")
1103	sb.WriteString("| `AddLIFO` | " + lots.LIFO.String() + " | one per live receipt |\n")
1104	sb.WriteString("| `AddWAVG` | " + lots.WAVG.String() + " | always exactly one |\n\n")
1105	sb.WriteString("Fixed at creation. Changing an item's method mid-life ")
1106	sb.WriteString("would recost nothing already charged but would change what ")
1107	sb.WriteString("the next shipment costs, which is a restatement a ")
1108	sb.WriteString("sub-ledger must not be able to make silently.\n\n")
1109	sb.WriteString("\"Always exactly one\" does not mean unconstrained. A ")
1110	sb.WriteString(lots.WAVG.String() + " item escapes the live-lot bound and ")
1111	sb.WriteString("gains a refusal the other two never evaluate: merging a ")
1112	sb.WriteString("receipt into the single lot can breach that lot's own ")
1113	sb.WriteString("overflow precondition, and whether it does depends on the ")
1114	sb.WriteString("item's accumulated history rather than on the receipt. So ")
1115	sb.WriteString("`LotCapacity` is the wrong headroom figure for a ")
1116	sb.WriteString(lots.WAVG.String() + " item — it reports the one limit that ")
1117	sb.WriteString("cannot bind there.\n\n")
1118
1119	sb.WriteString("## Bounds\n\n")
1120	sb.WriteString("| bound | value |\n")
1121	sb.WriteString("|---|---|\n")
1122	sb.WriteString("| items per register | " + strconv.Itoa(MaxItemsPerRegister) + " |\n")
1123	sb.WriteString("| live lots per item | " + strconv.Itoa(MaxLotsPerItem) + " |\n")
1124	sb.WriteString("| entries per register | " + strconv.Itoa(MaxEntriesPerRegister) + " |\n")
1125	sb.WriteString("| sku length | " + strconv.Itoa(MaxSKULen) + " |\n")
1126	sb.WriteString("| name length | " + strconv.Itoa(MaxItemNameLen) + " |\n\n")
1127	sb.WriteString("The entry bound is **register-wide and shared**, not per ")
1128	sb.WriteString("item, and that is the one bound here with a blast radius ")
1129	sb.WriteString("wider than the thing it limits. Every costed receipt and ")
1130	sb.WriteString("every costed shipment of every SKU spends one entry out of ")
1131	sb.WriteString("the same " + strconv.Itoa(MaxEntriesPerRegister) + ", and ")
1132	sb.WriteString("entries are never freed — exhausted lots are dropped, ")
1133	sb.WriteString("postings are not. So a single high-turnover SKU can spend ")
1134	sb.WriteString("the whole budget and leave the other ")
1135	sb.WriteString(strconv.Itoa(MaxItemsPerRegister-1) + " items unable to ")
1136	sb.WriteString("receive or ship, each with capacity of its own to spare. ")
1137	sb.WriteString("There is deliberately no archive and no reset — either ")
1138	sb.WriteString("would destroy the record the cross-foots are checked ")
1139	sb.WriteString("against — so the remedy is a second register under a ")
1140	sb.WriteString("second address, and busy SKUs are worth separating before ")
1141	sb.WriteString("they are busy. Watch `Capacity`'s second figure, which ")
1142	sb.WriteString("counts down for the register and not for one item.\n\n")
1143	sb.WriteString("A SKU may use only letters, digits, `.`, `_` and `-`: it is ")
1144	sb.WriteString("an index key, a page path and a table cell at once, and a ")
1145	sb.WriteString("charset checked on the way in beats escaping at three use ")
1146	sb.WriteString("sites. Case is significant.\n\n")
1147	sb.WriteString("There is **no write-off**: goods cannot leave without ")
1148	sb.WriteString("being charged to `" + COGSCode + "`, so shrinkage is ")
1149	sb.WriteString("indistinguishable from a sale. A real limitation, named ")
1150	sb.WriteString("rather than hidden.\n\n")
1151
1152	sb.WriteString("## Reading a register\n\n")
1153	sb.WriteString("`" + RealmPath + ":<address>`\n\n")
1154	sb.WriteString("`" + RealmPath + ":<address>/<sku>`\n")
1155	return sb.String()
1156}
1157
1158func renderRegister(owner address) string {
1159	r, ok := reg(owner)
1160	if !ok {
1161		return "# No register\n\nNo register exists for `" +
1162			sanitize.InlineText(owner.String()) + "`.\n"
1163	}
1164
1165	var sb strings.Builder
1166	sb.WriteString("# Register of " + sanitize.InlineText(owner.String()) + "\n\n")
1167	sb.WriteString("- opened at height: **" + strconv.FormatInt(r.openedAt, 10) + "**\n")
1168	sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n")
1169	sb.WriteString("- items: **" + strconv.Itoa(r.items.Size()) + "** / " +
1170		strconv.Itoa(MaxItemsPerRegister) + "\n")
1171	sb.WriteString("- entries: **" + strconv.Itoa(r.book.EntryCount()) + "** / " +
1172		strconv.Itoa(MaxEntriesPerRegister) + "\n\n")
1173
1174	sumOnHand, invBal, invOK := CrossFootInventory(owner)
1175	sumCharged, cogsBal, cogsOK := CrossFootCOGS(owner)
1176	sumRecv, contribBal, contribOK := CrossFootContributed(owner)
1177	dn, cn, offsets := Equation(owner)
1178
1179	sb.WriteString("## Cross-foot\n\n")
1180	sb.WriteString("| account | sum over items | ledger balance | agrees |\n")
1181	sb.WriteString("|---|---|---|---|\n")
1182	sb.WriteString("| `" + InventoryCode + "` on hand | " +
1183		strconv.FormatInt(sumOnHand, 10) + " | " +
1184		strconv.FormatInt(invBal, 10) + " | **" + yesno(invOK) + "** |\n")
1185	sb.WriteString("| `" + COGSCode + "` charged | " +
1186		strconv.FormatInt(sumCharged, 10) + " | " +
1187		strconv.FormatInt(cogsBal, 10) + " | **" + yesno(cogsOK) + "** |\n")
1188	sb.WriteString("| `" + ContribCode + "` received | " +
1189		strconv.FormatInt(sumRecv, 10) + " | " +
1190		strconv.FormatInt(contribBal, 10) + " | **" + yesno(contribOK) + "** |\n\n")
1191	sb.WriteString("| figure | value |\n|---|---|\n")
1192	sb.WriteString("| debit-normal total | " + strconv.FormatInt(dn, 10) + " |\n")
1193	sb.WriteString("| credit-normal total | " + strconv.FormatInt(cn, 10) + " |\n")
1194	sb.WriteString("| equation offsets | **" + yesno(offsets) + "** |\n")
1195	sb.WriteString("| book balanced | **" + yesno(r.book.IsBalanced()) + "** |\n\n")
1196	sb.WriteString("Those last two rows are **not two independent checks**, ")
1197	sb.WriteString("and a reader counting agreements would over-count here. ")
1198	sb.WriteString("The equation partitions accounts into debit-normal and ")
1199	sb.WriteString("credit-normal and then adds the two subtotals back ")
1200	sb.WriteString("together, so \"offsets\" reduces to total debits equal ")
1201	sb.WriteString("total credits — which is the first of the three things ")
1202	sb.WriteString("\"balanced\" checks. A balanced book always offsets. ")
1203	sb.WriteString("Neither row can see an account **classified** wrongly: ")
1204	sb.WriteString("reclassifying the whole chart moves the two totals above ")
1205	sb.WriteString("and leaves both booleans true. What pins the ")
1206	sb.WriteString("classification is the cross-foot table, where each ")
1207	sb.WriteString("account's ledger balance is compared against a figure ")
1208	sb.WriteString("derived from the items independently of the chart.\n\n")
1209
1210	sb.WriteString("## Items\n\n")
1211	if r.items.Size() == 0 {
1212		sb.WriteString("_None added yet._\n")
1213		return sb.String()
1214	}
1215	sb.WriteString("| sku | name | method | lots | on hand | cost on hand | received | charged | ok |\n")
1216	sb.WriteString("|---|---|---|---|---|---|---|---|---|\n")
1217
1218	shown := 0
1219	total := r.items.Size()
1220	r.items.Iterate("", "", func(_ string, v any) bool {
1221		if shown >= RenderItemLimit {
1222			return true
1223		}
1224		it := v.(*item)
1225		coh, err := it.pool.CostOnHand()
1226		ok := err == nil && it.postedCOGS == it.pool.ChargedCost()
1227		_, _, _, foots := it.pool.CrossFootCost()
1228		sb.WriteString("| [" + cell(it.sku) + "](" + RealmPath + ":" +
1229			owner.String() + "/" + it.sku + ") | " + cell(it.name) + " | " +
1230			cell(it.pool.Method().String()) + " | " +
1231			strconv.Itoa(it.pool.LotCount()) + " | " +
1232			strconv.FormatInt(it.pool.QtyOnHand(), 10) + " | " +
1233			strconv.FormatInt(coh, 10) + " | " +
1234			strconv.FormatInt(it.pool.ReceivedCost(), 10) + " | " +
1235			strconv.FormatInt(it.pool.ChargedCost(), 10) + " | " +
1236			yesno(ok && foots) + " |\n")
1237		shown++
1238		return false
1239	})
1240	if shown < total {
1241		sb.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
1242			strconv.Itoa(total) + " items._\n")
1243	}
1244	return sb.String()
1245}
1246
1247func renderItem(owner address, sku string) string {
1248	r, ok := reg(owner)
1249	if !ok {
1250		return "# No register\n\nNo register exists for `" +
1251			sanitize.InlineText(owner.String()) + "`.\n"
1252	}
1253	v := r.items.Get(sku)
1254	if v == nil {
1255		return "# No item\n\nNo item `" + sanitize.InlineText(sku) +
1256			"` in the register of `" + sanitize.InlineText(owner.String()) + "`.\n"
1257	}
1258	it := v.(*item)
1259
1260	var sb strings.Builder
1261	sb.WriteString("# " + cell(it.sku) + " — " + cell(it.name) + "\n\n")
1262	sb.WriteString("- owner: `" + sanitize.InlineText(owner.String()) + "`\n")
1263	sb.WriteString("- costing method: **" + cell(it.pool.Method().String()) + "**\n")
1264	sb.WriteString("- added at height: **" + strconv.FormatInt(it.addedAt, 10) + "**\n")
1265	sb.WriteString("- receipts taken: **" + strconv.FormatInt(it.pool.ReceiptCount(), 10) + "**\n")
1266	sb.WriteString("- live lots: **" + strconv.Itoa(it.pool.LotCount()) + "** / " +
1267		strconv.Itoa(MaxLotsPerItem) + "\n\n")
1268
1269	recv, charged, onHand, foots := it.pool.CrossFootCost()
1270	rq, cq, qOnHand, qFoots := it.pool.CrossFootQty()
1271	sb.WriteString("## Cross-foot\n\n")
1272	sb.WriteString("| identity | received | out | on hand | foots |\n")
1273	sb.WriteString("|---|---|---|---|---|\n")
1274	sb.WriteString("| cost | " + strconv.FormatInt(recv, 10) + " | " +
1275		strconv.FormatInt(charged, 10) + " | " +
1276		strconv.FormatInt(onHand, 10) + " | **" + yesno(foots) + "** |\n")
1277	sb.WriteString("| quantity | " + strconv.FormatInt(rq, 10) + " | " +
1278		strconv.FormatInt(cq, 10) + " | " +
1279		strconv.FormatInt(qOnHand, 10) + " | **" + yesno(qFoots) + "** |\n\n")
1280	sb.WriteString("| posted vs derived | posted | derived | agrees |\n")
1281	sb.WriteString("|---|---|---|---|\n")
1282	sb.WriteString("| cost of goods sold | " + strconv.FormatInt(it.postedCOGS, 10) +
1283		" | " + strconv.FormatInt(it.pool.ChargedCost(), 10) + " | **" +
1284		yesno(it.postedCOGS == it.pool.ChargedCost()) + "** |\n")
1285	sb.WriteString("| receipts | " + strconv.FormatInt(it.postedRecv, 10) +
1286		" | " + strconv.FormatInt(it.pool.ReceivedCost(), 10) + " | **" +
1287		yesno(it.postedRecv == it.pool.ReceivedCost()) + "** |\n\n")
1288
1289	sb.WriteString("## Live lots\n\n")
1290	all := it.pool.Lots()
1291	if len(all) == 0 {
1292		sb.WriteString("_No lots on hand._\n")
1293		return sb.String()
1294	}
1295	sb.WriteString("| # | qty | cost | consumed | remaining | remaining cost |\n")
1296	sb.WriteString("|---|---|---|---|---|---|\n")
1297	shown := 0
1298	for i, l := range all {
1299		if shown >= RenderLotLimit {
1300			break
1301		}
1302		rc, err := l.RemainingCost()
1303		rcs := strconv.FormatInt(rc, 10)
1304		if err != nil {
1305			// A lot that cannot value itself is shown as such rather than as
1306			// a zero: a zero in a cost column reads as a fact.
1307			rcs = "**invalid**"
1308		}
1309		sb.WriteString("| " + strconv.Itoa(i) + " | " +
1310			strconv.FormatInt(l.Qty, 10) + " | " +
1311			strconv.FormatInt(l.Cost, 10) + " | " +
1312			strconv.FormatInt(l.Consumed, 10) + " | " +
1313			strconv.FormatInt(l.Remaining(), 10) + " | " + rcs + " |\n")
1314		shown++
1315	}
1316	if shown < len(all) {
1317		sb.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
1318			strconv.Itoa(len(all)) + " lots._\n")
1319	}
1320	return sb.String()
1321}
1322
1323// cell renders a caller-supplied string into a table cell. A blank value would
1324// collapse the column, so it is shown as a dash.
1325func cell(s string) string {
1326	if strings.TrimSpace(s) == "" {
1327		return "—"
1328	}
1329	return sanitize.TableCell(s)
1330}
1331
1332func yesno(b bool) string {
1333	if b {
1334		return "yes"
1335	}
1336	return "no"
1337}