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.

cashflow.gno

62.92 Kb · 1553 lines
   1// Package cashflow publishes a direct-method statement of cash flows over a
   2// book this realm does not own, cannot write, and did not supply.
   3//
   4// # What it is, in one sentence
   5//
   6// An owner of a book in [r/books] declares which of their account codes hold
   7// cash and what every other code's movement is for; this realm then reads the
   8// entries in a window straight out of that ledger, hands them to [p/cashflow],
   9// and freezes the resulting statement at a sequence number.
  10//
  11// # A PUBLISHED STATEMENT IS A CLAIM ABOUT A CLAIM — and here, about TWO
  12//
  13// [r/statements] already says this of itself and it is true here with one
  14// extra layer. A book is a set of claims by its owner; nothing ties an account
  15// reading `Cash 5000` to 5000 real ugnot anywhere. A cash-flow statement
  16// derived from such a book inherits that completely, and then adds a SECOND
  17// caller declaration on top: which codes are cash at all.
  18//
  19// [p/cashflow] states the consequence exactly and it is repeated here rather
  20// than linked, because it is the single most important sentence about this
  21// realm: *"A realm that declares a receivable as cash produces a plausible,
  22// wrong statement. The declaration is the trust boundary."*
  23//
  24// So what this realm guarantees is narrow:
  25//
  26//   - the ENTRIES are read from [r/books], not supplied by the publisher;
  27//   - the CLASSIFICATION is the publisher's own declared policy, frozen with
  28//     the statement so a reader sees what it was;
  29//   - the ARITHMETIC is [p/cashflow]'s, which nobody here can parameterise.
  30//
  31// That the chart describes anything real, and that the codes called cash hold
  32// cash, are not claimed here and are not checkable from here.
  33//
  34// # Derived, not supplied — and the one figure that is supplied
  35//
  36// Every figure comes from [r/books] through its own read surface, except one.
  37// [p/cashflow] cannot derive an OPENING cash position from a window-filtered
  38// entry slice — a window carries period movement, not a cumulative position —
  39// so `opening` is an argument to [Publish] and is echoed into the report
  40// unchanged. [Closing] is `Opening + NetChange` and is therefore only as good
  41// as a number the publisher typed.
  42//
  43// This realm does not leave it at that. See [OpeningReconciled]: when the
  44// window extends past every entry the book holds, the sum of the declared cash
  45// accounts' balances is the cumulative cash position, and that is comparable
  46// to [Closing]. Where the comparison is available, [Publish] REFUSES to
  47// publish a statement that fails it. Where it is not available, the report
  48// says so and the figure stands as a declaration.
  49//
  50// # The window is REFUSED when it is too wide, never chunked
  51//
  52// [p/cashflow] is explicit that it does no paging and no merging, because
  53// merging reports across pages would make the unresolved set an artifact of
  54// the paging. Its entry cap is 4096, exactly [books.MaxEntriesPerBook], so the
  55// primitive can always accept a whole book and the binding constraint is not
  56// its cap — it is the cross-realm call budget, two reads per entry.
  57//
  58// So this realm declares [MaxWindowEntries] and refuses a window holding more,
  59// telling the caller to narrow it. A chunk-and-merge would be cheaper to use
  60// and would violate the primitive's own stated rule.
  61//
  62// # Window selection rests on a premise OWNED BY ANOTHER REALM
  63//
  64// Entries are located by binary search over entry ID, which is correct only
  65// because `At` is non-decreasing in ID. That is not a property of [p/journal],
  66// which refuses only a negative `at` and one at or before the close cutoff. It
  67// is a property of [r/books], which dates every entry with
  68// `runtime.ChainHeight()` at all three of its posting call sites and exposes
  69// no `at`-taking entrypoint. [r/books] declares the obligation in its own
  70// package doc.
  71//
  72// The failure mode if that premise ever breaks is the dangerous kind: a
  73// statement over FEWER entries than the window holds, which still balances and
  74// still reconciles. So the premise is checked rather than assumed —
  75// [scan] verifies `At` is non-decreasing across every entry it reads (free,
  76// since each `At` is already in hand) and probes the two entries immediately
  77// outside the located range to prove the range is maximal. A violation panics
  78// instead of publishing.
  79//
  80// RESIDUAL, stated with its boundary: that makes a violation at or adjacent to
  81// the window edges loud. A violation deep outside the scanned range is
  82// invisible without a full scan, which is the cost this design exists to
  83// avoid. [OpeningReconciled] closes it for the one window shape where the
  84// cumulative cross-check applies, and nowhere else.
  85//
  86// # Entry IDs are relied upon to be dense, and the argument is structural
  87//
  88// The binary search indexes entries 1..[books.EntryCount]. That is sound
  89// because [p/journal] allocates `id := nextID; nextID++` and writes the entry
  90// immediately, in one function with no error return between the two, into a
  91// tree that has no delete; a post that fails panics and reverts the whole
  92// transaction. So there are no gaps and no reuse. It is stated here because it
  93// is a premise, not an observation: [EntryInfo] panics on an absent ID, so a
  94// gap would abort the scan loudly rather than skew a figure.
  95//
  96// # A reversal lands in the period it was MADE
  97//
  98// [r/books] dates a reversing entry at the current height, not at the reversed
  99// entry's date, and [p/cashflow] deliberately does not special-case reversals:
 100// a reversing entry that touches cash moved cash. So reversing a prior-period
 101// entry shows as movement in the CURRENT period and the corrected period's
 102// published statement does not change. That is right for a cash-flow statement
 103// and surprising to a reader expecting otherwise, which is why it is here.
 104//
 105// # NO CUSTODY, and no re-derivation seal
 106//
 107// There is no coinio import, no payable path, no fee, no withdrawal, no admin.
 108// [Publish] and [DeclarePolicy] refuse a transaction carrying coins.
 109//
 110// There is deliberately NO `Verify` accessor re-deriving a stored report and
 111// comparing. For a window that closed before the publication height such a
 112// check is a CONSTANT TRUE by construction: entries are dated at the chain
 113// height and the journal is append-only, so no entry can later fall into a
 114// closed window, and the chart cannot lose an account. Shipping it would be
 115// the fifth instance of the vacuous-true class this project keeps finding in
 116// its own work. What ships instead is [Rescan], whose non-vacuity is exact and
 117// narrow: it re-locates the window NOW and so tests the selection premise
 118// above, not the ledger's contents.
 119//
 120// # No cross-owner aggregate beyond a count
 121//
 122// [r/books] scopes every read to one owner, and a cash-flow statement mixing
 123// owners would assert a claim no single owner can make. [Total] is a count and
 124// there is no way to enumerate publishers.
 125//
 126// # Limits, stated plainly
 127//
 128//   - `opening` is caller-supplied, and verified only where
 129//     [OpeningReconciled] says it was.
 130//   - The policy is caller-declared and is the trust boundary. A wrong cash
 131//     declaration yields a plausible, wrong statement and no on-chain check
 132//     here can detect it.
 133//   - The cost bound is reasoned against a live precedent ([r/statements]
 134//     performs roughly 130 cross-realm reads per report), not measured in gas.
 135//   - Memos and account codes and names are an owner's free text in [r/books]
 136//     and are unsanitized there. Every one of them goes through [cell] before
 137//     it reaches [Render].
 138//   - The chart reconstruction here is a second copy of [r/statements]'
 139//     unexported `chartOf`. Promoting it would modify a live realm.
 140//
 141// [r/books]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books
 142// [r/statements]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
 143// [p/cashflow]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow
 144// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
 145// [p/periods]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods
 146package cashflow
 147
 148import (
 149	"chain/runtime"
 150	"chain/runtime/unsafe"
 151	"strconv"
 152	"strings"
 153
 154	cf "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow"
 155	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
 156	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods"
 157	"gno.land/p/nt/avl/v0"
 158	"gno.land/p/nt/markdown/sanitize/v0"
 159	"gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books"
 160)
 161
 162// RealmPath is this realm's own path, used to build Render links.
 163const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow"
 164
 165// BooksPath is the ledger realm every entry here is read from.
 166//
 167// Taken BY VALUE from that realm's own exported constant rather than typed out
 168// again, so this is the compile-time pin itself and not a second copy that
 169// could drift. A reader checking which ledger a statement describes is reading
 170// the import.
 171const BooksPath = books.RealmPath
 172
 173// MaxWindowEntries bounds how many entries one [Publish] may read.
 174//
 175// This is the §7.4 refusal bound and the number has a derivation rather than a
 176// feel. Reading one entry costs TWO cross-realm reads — [books.EntryInfo] for
 177// the header and [books.EntryLegs] for the postings — because r/books returns
 178// them separately. Add the chart reconstruction, which is one [books.Accounts],
 179// one [books.Codes] at the chart cap and one [books.AccountInfo] per account,
 180// and the live precedent [r/statements] pays about 130 reads per report. Add
 181// the search: 2*ceil(log2(N+1)) + 4 header reads to locate and prove the range.
 182//
 183// At 256 the whole publish is about 666 plain reads, roughly five times a
 184// report [r/statements] performs on this chain today. At r/books' own entry cap
 185// of 4096 it would be about 8,350, which is the figure the discovery gate for
 186// r/receivables used to defer this product.
 187//
 188// It is a REFUSAL and not a truncation: a window holding more entries than this
 189// panics and names the count, and the caller narrows the window. Publishing a
 190// statement over a silently-truncated entry set is the one failure this product
 191// is least able to detect, because a short statement still balances.
 192const MaxWindowEntries = 256
 193
 194// PolicyIsReplaceable records a decision a reader will look for.
 195//
 196// [DeclarePolicy] overwrites an owner's previous declaration and keeps no
 197// history of it. That is safe only because every [Publish] FREEZES a copy of
 198// the policy it used into the record: a published statement is readable with
 199// the classification that produced it forever, via [PolicyOf], and changing
 200// the live policy cannot restate a published figure.
 201//
 202// The alternative — versioned policies with reports pointing at a version — is
 203// the same information with an extra indirection and one more place for the
 204// two to disagree.
 205const PolicyIsReplaceable = true
 206
 207// PublicationsAreUncapped mirrors [r/statements]' decision for the same three
 208// reasons, which hold here unchanged: keys are per owner so an owner can only
 209// fill their own history; no accessor and no [Render] path walks a publisher's
 210// whole history; and there is no engine cap above this realm to mirror.
 211const PublicationsAreUncapped = true
 212
 213// RenderPublicationLimit caps how many of a publisher's statements one
 214// [Render] call lists.
 215const RenderPublicationLimit = 20
 216
 217// RenderCensusLimit caps how many unresolved or net-zero rows one [Render]
 218// call emits. A window may hold [MaxWindowEntries] entries and in a book with
 219// no policy rules every one of them is unresolved.
 220const RenderCensusLimit = 25
 221
 222// ---------------------------------------------------------------------------
 223// State
 224// ---------------------------------------------------------------------------
 225
 226// policy is one owner's frozen declaration. The slices are allocated here from
 227// the caller's parsed strings and never handed out: [PolicyCash] and
 228// [PolicyRules] re-encode them into a fresh string.
 229type policy struct {
 230	cash  []string
 231	rules []cf.Classification
 232	at    int64
 233}
 234
 235// record is one frozen statement.
 236//
 237// It stores the [cf.Report] whole rather than copying its fields into a
 238// parallel struct — a second field set is a second place for the two to
 239// disagree — plus the three things the report cannot carry: the policy that
 240// produced it, the entry-ID range that was read, and the cumulative
 241// cross-check result.
 242//
 243// There is deliberately no seq field. Every read path reaches a record by
 244// constructing pubKey from a seq it already holds, so a stored copy would only
 245// ever be checked against its own source. [r/statements] records the same
 246// decision and the reason it was found: a sabotage harness could not make
 247// dropping the assignment fail any test.
 248type record struct {
 249	at  int64
 250	rep cf.Report
 251	pol policy
 252
 253	// lo and hi are the inclusive entry-ID range read. hi < lo means the
 254	// window held no entries, which is a legitimate quiet period.
 255	lo uint64
 256	hi uint64
 257
 258	// reconciled is whether the cumulative cross-check APPLIED. It is never
 259	// stored false-after-failing: a failed check panics in [Publish], so this
 260	// distinguishes "checked and agreed" from "not checkable", never
 261	// "checked and disagreed".
 262	reconciled bool
 263
 264	// cash is the declared cash accounts' summed balance at publication, read
 265	// from [books.Balance]. Meaningful only when reconciled.
 266	cash int64
 267}
 268
 269// policies maps an owner's address string to *policy.
 270var policies = avl.NewTree()
 271
 272// published maps "<owner>|<padded seq>" to *record.
 273//
 274// The pointer never leaves this file. Every accessor copies scalars out, so no
 275// caller receives a handle onto stored state.
 276var published = avl.NewTree()
 277
 278// counts maps an owner's address string to how many statements they have
 279// published. It is the sequence allocator as well as the count.
 280var counts = avl.NewTree()
 281
 282// pubCount is the global total, maintained alongside the tree so the index
 283// page never iterates.
 284var pubCount int
 285
 286// ---------------------------------------------------------------------------
 287// Guards
 288// ---------------------------------------------------------------------------
 289
 290// refuseCoins panics if a direct caller attached coins.
 291//
 292// This realm has no banker, no withdrawal path and nothing that can spend what
 293// arrives, so a silently-accepted send would be stranded permanently and "we
 294// hold no funds" would be true of the design and false of the balance.
 295//
 296// The residual is stated rather than guarded: coins can still reach this
 297// realm's address through an intermediary's explicit banker send, which no
 298// predicate here can see. What makes that survivable is the absence of any
 299// consumption path, not this guard.
 300//
 301// The IsCurrent check is a no-op at both of today's call sites — a crossing
 302// function's first `cur` is runtime-current by construction and stays current
 303// when forwarded through a non-crossing call. It is here because this realm is
 304// IMMUTABLE after addpkg, so a future third caller cannot be made safe later,
 305// and a helper that reads Previous() off an unchecked realm parameter is a
 306// designation-forgery shape whether or not it is reachable today.
 307func refuseCoins(cur realm) {
 308	if !cur.IsCurrent() {
 309		panic("cashflow: refuseCoins called with a realm that is not current")
 310	}
 311	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
 312		panic("cashflow: this realm holds no coins and has no withdrawal path; send none")
 313	}
 314}
 315
 316// ---------------------------------------------------------------------------
 317// The cross-realm reads
 318// ---------------------------------------------------------------------------
 319
 320// parseKind turns one of [r/books]' five kind words back into a
 321// [journal.Kind] and PANICS on anything else.
 322//
 323// This is the same seam [r/statements] names in its own copy, and the reason
 324// it panics is the same and is specific: the zero [journal.Kind] is reported
 325// CREDIT-NORMAL by IsDebitNormal, so a silently-unparsed word would not drop
 326// the row — it would misfile it and produce a statement that balances and
 327// misstates. The five words are that type's own String output, and the suite
 328// pins the round trip over all five rather than trusting it to stay true.
 329func parseKind(s string) journal.Kind {
 330	switch s {
 331	case "asset":
 332		return journal.Asset
 333	case "liability":
 334		return journal.Liability
 335	case "equity":
 336		return journal.Equity
 337	case "revenue":
 338		return journal.Revenue
 339	case "expense":
 340		return journal.Expense
 341	}
 342	panic("cashflow: " + BooksPath + " reported an unknown account kind " +
 343		strconv.Quote(s))
 344}
 345
 346// chartOf reads the owner's whole chart of accounts in one pass.
 347//
 348// ONE call for the codes, at r/books' own account cap. That iterator has no
 349// seek and honours `from` by skipping, so one call walks the whole chart
 350// however small the limit is — paging it would cost ceil(n/limit) full walks,
 351// and [books.Codes]' `from` is INCLUSIVE so every paging caller also owes
 352// itself a boundary dedup. A missed one double-counts an account, and a
 353// double-counted balance is a defect this product cannot see: the statement
 354// would still balance.
 355//
 356// The agreement check is not decoration. [books.Accounts] and [books.Codes]
 357// are two reads through different code paths; if they disagree this refuses
 358// rather than deriving over a truncated chart, because [cf.Derive] rejects a
 359// posting naming an account absent from the chart and a truncated chart turns
 360// an ordinary entry into ErrUnknownAccount pointing at the wrong layer.
 361//
 362// TAINT: the []string [books.Codes] returns was allocated under that realm and
 363// arrives readonly-tainted. Nothing here mutates it or stores it — codes are
 364// read out by value and the rows are allocated here.
 365//
 366// This is a second copy of [r/statements]' unexported chartOf, forced: that
 367// realm is live and §6 forbids modifying it to promote the helper.
 368func chartOf(owner address) []journal.Account {
 369	n := books.Accounts(owner) // panics if the owner has no book
 370	codes := books.Codes(owner, "", books.MaxAccountsPerBook)
 371	if len(codes) != n {
 372		panic("cashflow: " + BooksPath + " reports " + strconv.Itoa(n) +
 373			" accounts but enumerated " + strconv.Itoa(len(codes)) +
 374			"; refusing to report on a partial chart")
 375	}
 376
 377	rows := make([]journal.Account, 0, n)
 378	for _, code := range codes {
 379		name, kind, debits, credits, balance := books.AccountInfo(owner, code)
 380		a := journal.Account{
 381			Code:    code,
 382			Name:    name,
 383			Kind:    parseKind(kind),
 384			Debits:  debits,
 385			Credits: credits,
 386		}
 387		// State its reach exactly, because the flattering reading is
 388		// available: BOTH sides are journal.Account.Balance over the same two
 389		// columns, so this is a CONSTANT TRUE against any disagreement between
 390		// the two realms' arithmetic and must never be quoted as cross-checking
 391		// it. What it catches is a transcription error in the four assignments
 392		// above — swapping Debits and Credits, or reading a column off the
 393		// wrong code — which is this realm's own code and the only place in
 394		// this path where that could happen.
 395		if a.Balance() != balance {
 396			panic("cashflow: balance mismatch for account " + code +
 397				"; this realm transcribed the chart wrongly")
 398		}
 399		rows = append(rows, a)
 400	}
 401	return rows
 402}
 403
 404// lowerBound is the smallest entry ID in 1..n whose At is >= target, or n+1 if
 405// there is none.
 406//
 407// Correct only under the non-decreasing-At premise in the package doc. A
 408// violation makes this land somewhere arbitrary rather than wrong-by-one,
 409// which is why [scan] probes the located boundaries rather than trusting the
 410// result.
 411func lowerBound(owner address, n uint64, target int64) uint64 {
 412	lo, hi := uint64(1), n+1
 413	for lo < hi {
 414		mid := lo + (hi-lo)/2
 415		at, _, _, _, _ := books.EntryInfo(owner, mid)
 416		if at >= target {
 417			hi = mid
 418		} else {
 419			lo = mid + 1
 420		}
 421	}
 422	return lo
 423}
 424
 425// scan locates and reads every entry in w, and returns them with the inclusive
 426// ID range and whether the window extends past every entry the book holds.
 427//
 428// The last return value is what makes the cumulative cross-check sound; see
 429// [OpeningReconciled]. It is true for an empty book as well, where every
 430// balance is necessarily zero because balances come only from postings.
 431//
 432// Every check here is a panic rather than a return, because this is reached
 433// from a mutating path and in Gno only a panic reverts a transaction.
 434func scan(owner address, w periods.Window) ([]journal.Entry, uint64, uint64, bool) {
 435	n := books.EntryCount(owner) // panics if the owner has no book
 436	if n == 0 {
 437		return nil, 1, 0, true
 438	}
 439	last := uint64(n)
 440
 441	firstAt, _, _, _, _ := books.EntryInfo(owner, 1)
 442	lastAt, _, _, _, _ := books.EntryInfo(owner, last)
 443	if lastAt < firstAt {
 444		panic("cashflow: " + BooksPath + " entry 1 is dated " +
 445			strconv.FormatInt(firstAt, 10) + " and entry " +
 446			strconv.FormatUint(last, 10) + " is dated " +
 447			strconv.FormatInt(lastAt, 10) +
 448			"; entry dates are not non-decreasing and window selection is unsound")
 449	}
 450	pastEnd := w.Until > lastAt
 451
 452	lo := lowerBound(owner, last, w.From)
 453	hi := lowerBound(owner, last, w.Until) - 1
 454
 455	// THERE ARE NO MAXIMALITY PROBES HERE, DELIBERATELY. Two used to sit at
 456	// this point — reading the entry just below lo and the entry just above hi
 457	// and refusing if either fell inside the window — and they were REMOVED
 458	// because they are tautologies that can never fire. Both probed conditions
 459	// are negations of [lowerBound]'s own loop invariants:
 460	//
 461	//	hi is only ever assigned a mid at which at(mid) >= target was
 462	//	OBSERVED, so an exit value L <= n carries at(L) >= target, and
 463	//	`at(hi+1) < w.Until` is unreachable.
 464	//	lo is only ever assigned mid+1 at which at(mid) < target was
 465	//	OBSERVED, so an exit value L > 1 carries at(L-1) < target, and
 466	//	`at(lo-1) >= w.From` is unreachable.
 467	//
 468	// Neither derivation uses the non-decreasing-At premise, which is what
 469	// makes them dead rather than merely redundant: they were written to catch
 470	// a VIOLATION of that premise and are vacuous in exactly that case. They
 471	// cost two cross-realm reads per Publish and advertised a guarantee they
 472	// never provided. Measured, with a sabotage control, in
 473	// TestLowerBoundsPostconditionsHoldOverAScrambledBook.
 474	//
 475	// WHAT CATCHES A MISLOCATED RANGE INSTEAD, all three live and pinned:
 476	//   - the endpoint check above, when entry 1 outranks entry n;
 477	//   - the `at < prev` check in the read loop below;
 478	//   - the `!w.Contains(at)` check in the read loop below, which is the one
 479	//     that fires on the common dip case.
 480	// And when a dip makes the search return an EMPTY range — hi < lo, so the
 481	// loop never runs and none of the three can speak — the net is Publish's
 482	// cumulative cross-check against the live cash balance, which is why that
 483	// check is not an optional nicety. See [OpeningReconciled] for its scope,
 484	// and note the residual honestly: a window that does not extend past the
 485	// last entry has no cumulative check, so a dip that produces an empty
 486	// range inside a closed window is under-reported and only
 487	// OpeningReconciled's false reading discloses it.
 488	if hi < lo {
 489		return nil, lo, hi, pastEnd
 490	}
 491	count := hi - lo + 1
 492	if count > uint64(MaxWindowEntries) {
 493		panic("cashflow: the window holds " + strconv.FormatUint(count, 10) +
 494			" entries and the limit is " + strconv.Itoa(MaxWindowEntries) +
 495			"; narrow the window. This realm refuses rather than reading a " +
 496			"partial set, because a statement over fewer entries than the " +
 497			"window holds still balances")
 498	}
 499
 500	out := make([]journal.Entry, 0, int(count))
 501	prev := int64(-1)
 502	for id := lo; id <= hi; id++ {
 503		at, memo, legs, reversalOf, total := books.EntryInfo(owner, id)
 504		if at < prev {
 505			panic("cashflow: entry " + strconv.FormatUint(id, 10) + " is dated " +
 506				strconv.FormatInt(at, 10) + " after entry " +
 507				strconv.FormatUint(id-1, 10) + " dated " +
 508				strconv.FormatInt(prev, 10) +
 509				"; entry dates are not non-decreasing and window selection is unsound")
 510		}
 511		prev = at
 512		if !w.Contains(at) {
 513			panic("cashflow: entry " + strconv.FormatUint(id, 10) + " is dated " +
 514				strconv.FormatInt(at, 10) + " and is outside the window; the " +
 515				"located range is wrong")
 516		}
 517		e := journal.Entry{
 518			ID:         id,
 519			At:         at,
 520			Memo:       memo,
 521			Postings:   parseLegs(owner, id, books.EntryLegs(owner, id)),
 522			ReversalOf: reversalOf,
 523		}
 524		if len(e.Postings) != legs {
 525			panic("cashflow: entry " + strconv.FormatUint(id, 10) + " reports " +
 526				strconv.Itoa(legs) + " legs and encoded " +
 527				strconv.Itoa(len(e.Postings)) + "; refusing a partial entry")
 528		}
 529		// Same reach as chartOf's balance check and worth the same caution:
 530		// both sides are journal.Entry.Total over the same postings, so this
 531		// catches a decoding error in parseLegs — a dropped leg, a flipped
 532		// side, a misparsed amount — and is NOT a cross-check of r/books'
 533		// arithmetic. A flipped side is the one that matters: it inverts the
 534		// cash direction of the leg and the statement would still reconcile.
 535		if e.Total() != total {
 536			panic("cashflow: entry " + strconv.FormatUint(id, 10) +
 537				" totals " + strconv.FormatInt(e.Total(), 10) +
 538				" as decoded and " + strconv.FormatInt(total, 10) +
 539				" as reported; this realm decoded the legs wrongly")
 540		}
 541		out = append(out, e)
 542	}
 543	return out, lo, hi, pastEnd
 544}
 545
 546// parseLegs decodes the "code:side:amount,..." form [books.EntryLegs] returns.
 547//
 548// That form is [books.PostMulti]'s input format, so this is the inverse of a
 549// parser living in the realm the string came from. It is duplicated rather
 550// than shared for the same reason chartOf is: r/books is live.
 551//
 552// It is STRICTER than r/books' own parser in one direction and deliberately
 553// so. r/books accepts "D", "DR" and "DEBIT" for a side because a human types
 554// its input; [books.EntryLegs] emits only "D" or "C", so anything else here
 555// means the format changed underneath this realm and the right outcome is a
 556// refusal rather than a tolerant read. It is not stricter about leg count:
 557// r/books refuses fewer than two on the way IN, and re-asserting that on the
 558// way out would duplicate another realm's invariant in a place that cannot
 559// enforce it.
 560func parseLegs(owner address, id uint64, legs string) []journal.Posting {
 561	where := " in entry " + strconv.FormatUint(id, 10) + " of " + owner.String()
 562	if legs == "" {
 563		panic("cashflow: " + BooksPath + " returned no legs" + where)
 564	}
 565	parts := strings.Split(legs, ",")
 566	if len(parts) > journal.MaxPostingsPerEntry {
 567		panic("cashflow: " + strconv.Itoa(len(parts)) + " legs" + where +
 568			" exceeds journal.MaxPostingsPerEntry")
 569	}
 570	out := make([]journal.Posting, 0, len(parts))
 571	for i, raw := range parts {
 572		at := " at leg " + strconv.Itoa(i+1) + where
 573		f := strings.Split(raw, ":")
 574		if len(f) != 3 {
 575			panic("cashflow: expected code:side:amount" + at)
 576		}
 577		if f[0] == "" {
 578			panic("cashflow: empty account code" + at)
 579		}
 580		var side journal.Side
 581		switch f[1] {
 582		case "D":
 583			side = journal.Debit
 584		case "C":
 585			side = journal.Credit
 586		default:
 587			panic("cashflow: " + BooksPath + " encoded side " +
 588				strconv.Quote(f[1]) + at + "; want D or C")
 589		}
 590		amount, err := strconv.ParseInt(f[2], 10, 64)
 591		if err != nil {
 592			panic("cashflow: amount is not an integer" + at)
 593		}
 594		if amount <= 0 {
 595			panic("cashflow: amount must be positive" + at)
 596		}
 597		out = append(out, journal.Posting{Account: f[0], Side: side, Amount: amount})
 598	}
 599	return out
 600}
 601
 602// liveCash sums the declared cash accounts' balances now.
 603//
 604// Every cash code is a [journal.Asset] — [cf.Derive] refuses a policy that
 605// declares a non-asset as cash — so each balance is debit-normal and the sum
 606// is the book's cumulative net cash movement since its first posting. That is
 607// the figure [OpeningReconciled] compares against.
 608func liveCash(owner address, cash []string) int64 {
 609	total := int64(0)
 610	for _, code := range cash {
 611		b, ok := books.Balance(owner, code)
 612		if !ok {
 613			panic("cashflow: declared cash account " + code +
 614				" is not in the owner's chart")
 615		}
 616		sum, ok2 := checkedAdd(total, b)
 617		if !ok2 {
 618			panic("cashflow: the declared cash accounts' balances overflow int64")
 619		}
 620		total = sum
 621	}
 622	return total
 623}
 624
 625// ---------------------------------------------------------------------------
 626// Declaring a policy
 627// ---------------------------------------------------------------------------
 628
 629// DeclarePolicy records the caller's own classification policy for their own
 630// book, replacing any previous one.
 631//
 632// `cash` is a comma-separated list of account codes that hold cash, strictly
 633// ascending — for example `1010,1020`. It must be non-empty and every code
 634// must be an asset in the caller's chart.
 635//
 636// `rules` is a comma-separated list of `code:category` pairs, strictly
 637// ascending by code, where category is `operating`, `investing` or
 638// `financing` — for example `4000:operating,1500:investing,3000:financing`.
 639// A code absent from `rules` is NOT defaulted to anything: an entry touching
 640// it is reported unresolved, by name, because a default bucket turns a missing
 641// declaration into a figure nobody questions. `rules` may be empty.
 642//
 643// The owner is `cur.Previous().Address()` and there is NO owner parameter.
 644// [r/books] derives a book's owner the same way and no mutating function there
 645// takes one either, so the only party who can declare a policy for a book is
 646// the only party who can write to it. An owner parameter would move the trust
 647// boundary to whoever called this.
 648//
 649// The declaration is validated against the caller's LIVE chart by deriving
 650// over an empty entry set: [cf.Derive] validates the window, the chart and the
 651// policy before it reads an entry, so a nil entry slice exercises exactly the
 652// declaration's validity and nothing else. A policy that is valid now stays
 653// valid, because [r/books] has no way to remove an account from a chart.
 654func DeclarePolicy(cur realm, cash, rules string) {
 655	refuseCoins(cur)
 656	owner := cur.Previous().Address()
 657
 658	c := parseCash(cash)
 659	r := parseRules(rules)
 660
 661	if _, err := cf.Derive(
 662		chartOf(owner), nil,
 663		periods.Window{From: 0, Until: 1},
 664		cf.Policy{Cash: c, Rules: r},
 665		0,
 666	); err != nil {
 667		panic("cashflow: " + err.Error())
 668	}
 669
 670	policies.Set(owner.String(), &policy{
 671		cash:  c,
 672		rules: r,
 673		at:    runtime.ChainHeight(),
 674	})
 675}
 676
 677// parseCash decodes the cash-code list. Order and duplication are NOT checked
 678// here: [cf.Derive]'s validatePolicy requires strictly ascending codes and
 679// says so with its own error, and a second copy of that rule here would be a
 680// second place for the two to disagree.
 681func parseCash(cash string) []string {
 682	if cash == "" {
 683		panic("cashflow: no cash accounts declared; format is \"1010,1020\"")
 684	}
 685	parts := strings.Split(cash, ",")
 686	if len(parts) > books.MaxAccountsPerBook {
 687		panic("cashflow: " + strconv.Itoa(len(parts)) +
 688			" cash codes exceeds books.MaxAccountsPerBook")
 689	}
 690	out := make([]string, 0, len(parts))
 691	for i, raw := range parts {
 692		code := strings.TrimSpace(raw)
 693		if code == "" {
 694			panic("cashflow: empty cash code at position " + strconv.Itoa(i+1))
 695		}
 696		out = append(out, code)
 697	}
 698	return out
 699}
 700
 701// parseRules decodes the code:category list. An empty string is a legitimate
 702// empty policy — every entry touching a non-cash account is then unresolved,
 703// which is a true report of a book whose owner has declared nothing.
 704func parseRules(rules string) []cf.Classification {
 705	if strings.TrimSpace(rules) == "" {
 706		return nil
 707	}
 708	parts := strings.Split(rules, ",")
 709	if len(parts) > books.MaxAccountsPerBook {
 710		panic("cashflow: " + strconv.Itoa(len(parts)) +
 711			" rules exceeds books.MaxAccountsPerBook")
 712	}
 713	out := make([]cf.Classification, 0, len(parts))
 714	for i, raw := range parts {
 715		at := " at rule " + strconv.Itoa(i+1)
 716		f := strings.Split(strings.TrimSpace(raw), ":")
 717		if len(f) != 2 {
 718			panic("cashflow: expected code:category" + at)
 719		}
 720		code := strings.TrimSpace(f[0])
 721		if code == "" {
 722			panic("cashflow: empty account code" + at)
 723		}
 724		out = append(out, cf.Classification{
 725			Code:     code,
 726			Category: parseCategory(strings.TrimSpace(f[1])),
 727		})
 728	}
 729	return out
 730}
 731
 732// parseCategory turns a category word into a [cf.Category] and panics on
 733// anything else. The three words are that type's own String output; the suite
 734// pins the round trip over all three rather than trusting another package's
 735// method to stay put.
 736//
 737// It panics rather than returning the zero Category because the zero value is
 738// deliberately invalid in [p/cashflow] and a silently-zero rule would reach
 739// validatePolicy as ErrInvalidCategory, pointing a reader at the wrong layer.
 740func parseCategory(s string) cf.Category {
 741	switch s {
 742	case "operating":
 743		return cf.CategoryOperating
 744	case "investing":
 745		return cf.CategoryInvesting
 746	case "financing":
 747		return cf.CategoryFinancing
 748	}
 749	panic("cashflow: unknown category " + strconv.Quote(s) +
 750		"; want operating, investing or financing")
 751}
 752
 753// ---------------------------------------------------------------------------
 754// Publishing
 755// ---------------------------------------------------------------------------
 756
 757// Publish freezes a statement of cash flows over [from, until) and returns its
 758// sequence number, which counts from 1 per publisher.
 759//
 760// The window is half-open: `from` is included and `until` is not. The unit is
 761// whatever [r/books] dates with, which is block height.
 762//
 763// `opening` is the caller's declared opening cash position. It cannot be
 764// derived from a windowed entry set and is echoed into the report unchanged;
 765// see [OpeningReconciled] for the one window shape where this realm can and
 766// does check it.
 767//
 768// The owner is `cur.Previous().Address()`. There is NO owner parameter and no
 769// way to publish over someone else's book, for the reason given at
 770// [DeclarePolicy].
 771//
 772// It PANICS, rather than returning an error, on: no book, no declared policy,
 773// an invalid window, a window holding more than [MaxWindowEntries] entries, a
 774// violation of the entry-dating premise at either window edge, a disagreement
 775// between the chart's two reads or an entry's two reads, any [cf.Derive] error,
 776// and a failed cumulative cross-check. An error return would not revert a Gno
 777// transaction; only a panic does.
 778func Publish(cur realm, from, until, opening int64) int64 {
 779	refuseCoins(cur)
 780	owner := cur.Previous().Address()
 781
 782	pol := mustPolicy(owner)
 783	w := periods.Window{From: from, Until: until}
 784	if !w.Valid() {
 785		panic("cashflow: window [" + strconv.FormatInt(from, 10) + ", " +
 786			strconv.FormatInt(until, 10) + ") is not a period; want 0 <= from < until")
 787	}
 788
 789	chart := chartOf(owner)
 790	entries, lo, hi, pastEnd := scan(owner, w)
 791
 792	// A COPY of the stored policy goes to Derive, not the stored slices. The
 793	// primitive does not mutate a Policy, but handing stored state to code in
 794	// another package is the kind of thing that stays safe only until that
 795	// package changes, and this realm is immutable after addpkg.
 796	rep, err := cf.Derive(chart, entries, w, cf.Policy{
 797		Cash:  copyCodes(pol.cash),
 798		Rules: copyRules(pol.rules),
 799	}, opening)
 800	if err != nil {
 801		panic("cashflow: " + err.Error())
 802	}
 803
 804	// Derive filters the window itself, so this asserts that the scan handed
 805	// it exactly the window's entries and nothing it had to discard. A
 806	// disagreement would mean scan's range check above is wrong, and the two
 807	// are independent code.
 808	if rep.Entries != len(entries) {
 809		panic("cashflow: " + strconv.Itoa(len(entries)) +
 810			" entries were read and the report counts " +
 811			strconv.Itoa(rep.Entries) + " in the window")
 812	}
 813
 814	// THE CUMULATIVE CROSS-CHECK. Sound only when the window extends past
 815	// every entry the book holds: then the window's net change plus a correct
 816	// opening is the book's whole cash movement, which is what the declared
 817	// cash accounts' balances sum to. See [OpeningReconciled] for why a
 818	// failure is a refusal rather than a flag on the record.
 819	live := int64(0)
 820	if pastEnd {
 821		live = liveCash(owner, pol.cash)
 822		if live != rep.Closing {
 823			panic("cashflow: refusing to publish. The window extends past every " +
 824				"entry in the book, so the declared opening of " +
 825				strconv.FormatInt(opening, 10) + " plus the window's net change of " +
 826				strconv.FormatInt(rep.NetChange, 10) + " should equal the declared " +
 827				"cash accounts' balance of " + strconv.FormatInt(live, 10) +
 828				", and it is " + strconv.FormatInt(rep.Closing, 10) +
 829				". Either the opening is wrong or the entry scan is incomplete")
 830		}
 831	}
 832
 833	seq := int64(ownerCount(owner.String()) + 1)
 834	published.Set(pubKey(owner.String(), seq), &record{
 835		at:         runtime.ChainHeight(),
 836		rep:        rep,
 837		pol:        policy{cash: copyCodes(pol.cash), rules: copyRules(pol.rules), at: pol.at},
 838		lo:         lo,
 839		hi:         hi,
 840		reconciled: pastEnd,
 841		cash:       live,
 842	})
 843	counts.Set(owner.String(), int(seq))
 844	pubCount++
 845	return seq
 846}
 847
 848// ---------------------------------------------------------------------------
 849// Reading a published statement
 850// ---------------------------------------------------------------------------
 851
 852// Count is how many statements an owner has published.
 853func Count(owner address) int { return ownerCount(owner.String()) }
 854
 855// Latest is the newest sequence number for an owner, and false if they have
 856// published none.
 857func Latest(owner address) (int64, bool) {
 858	n := ownerCount(owner.String())
 859	if n == 0 {
 860		return 0, false
 861	}
 862	return int64(n), true
 863}
 864
 865// Total is how many statements every publisher has published together. There
 866// is deliberately no way to enumerate the publishers; see the package doc.
 867func Total() int { return pubCount }
 868
 869// Has reports whether a statement exists.
 870func Has(owner address, seq int64) bool {
 871	return published.Has(pubKey(owner.String(), seq))
 872}
 873
 874// PublishedAt is the block height a statement was frozen at.
 875func PublishedAt(owner address, seq int64) int64 {
 876	return mustRecord(owner, seq).at
 877}
 878
 879// Window is the period a statement covers, half-open: `from` is in, `until` is
 880// out.
 881func Window(owner address, seq int64) (from, until int64) {
 882	w := mustRecord(owner, seq).rep.Window
 883	return w.From, w.Until
 884}
 885
 886// Totals is the four cumulative figures. `closing` is `opening + netChange`,
 887// and `opening` was supplied by the publisher — see [OpeningReconciled] for
 888// whether this realm was able to check it.
 889//
 890// `unattributed` is the net movement of the entries in the unresolved census
 891// and is part of `netChange`, not additional to it.
 892func Totals(owner address, seq int64) (opening, netChange, unattributed, closing int64) {
 893	r := mustRecord(owner, seq).rep
 894	return r.Opening, r.NetChange, r.Unattributed, r.Closing
 895}
 896
 897// LineOf is one category's gross flows. `category` is `operating`, `investing`
 898// or `financing`; anything else panics.
 899//
 900// Inflows and outflows are GROSS and both non-negative. A category with 1,000
 901// in and 1,000 out is not the same business as one with nothing happening, and
 902// a net of zero is all a netted figure could say about either.
 903func LineOf(owner address, seq int64, category string) (inflows, outflows, net int64, entries int) {
 904	l, err := mustRecord(owner, seq).rep.Line(parseCategory(category))
 905	if err != nil {
 906		panic("cashflow: " + err.Error())
 907	}
 908	return l.Inflows, l.Outflows, l.Net(), l.Entries
 909}
 910
 911// Scope is the statement's own coverage line, and it is the thing to read
 912// before quoting any figure above.
 913//
 914//   - `entries` is how many entries fell in the window, including the ones
 915//     that moved no cash. Zero is a quiet period, not an error.
 916//   - `cashEntries` is how many touched a declared cash account at all.
 917//   - `netZero` is how many touched cash and moved none on net — a transfer
 918//     between two cash accounts. Classification is not attempted for them:
 919//     there is nothing to attribute.
 920//   - `unresolved` is how many could not be classified. A statement with
 921//     unresolved entries is still arithmetically whole — the residual is in
 922//     `unattributed` — but its SPLIT is incomplete.
 923func Scope(owner address, seq int64) (entries, cashEntries, netZero, unresolved int) {
 924	r := mustRecord(owner, seq).rep
 925	return r.Entries, r.CashEntries, len(r.NetZero), len(r.Unresolved)
 926}
 927
 928// EntryRange is the inclusive [r/books] entry-ID range this statement was
 929// derived from. `ok` is false when the window held no entries.
 930//
 931// It is stored because it is the evidence for the statement's scope: a reader
 932// can take the two IDs to [r/books] and read the entries themselves. A
 933// statement is the only artifact here, and this is what makes it checkable
 934// against its source.
 935func EntryRange(owner address, seq int64) (lo, hi uint64, ok bool) {
 936	r := mustRecord(owner, seq)
 937	if r.hi < r.lo {
 938		return 0, 0, false
 939	}
 940	return r.lo, r.hi, true
 941}
 942
 943// Complete reports whether every in-window entry that moved cash was
 944// attributed to a category.
 945//
 946// A complete statement is not a correct one. Completeness is a property of the
 947// POLICY's coverage, and a policy that classifies every account wrongly is
 948// complete. [p/cashflow] says this of its own accessor and it is worth
 949// repeating at the realm boundary, where the word will be quoted.
 950func Complete(owner address, seq int64) bool {
 951	return mustRecord(owner, seq).rep.Complete()
 952}
 953
 954// Reconciles reports whether the attributed lines plus the unattributed
 955// residual equal the net change.
 956//
 957// On a statement published here it is ALWAYS TRUE, because [cf.Derive]
 958// produces it that way and nothing between there and storage touches the
 959// figures. It is exposed anyway, and the reason is narrow: it is the one check
 960// a reader can run against a stored report without re-reading the ledger, and
 961// an exposed constant-true that is DOCUMENTED as one is better than a reader
 962// assuming the invariant holds without ever being told where it comes from.
 963// It must not be quoted as evidence that a statement is right.
 964func Reconciles(owner address, seq int64) bool {
 965	return mustRecord(owner, seq).rep.Reconciles()
 966}
 967
 968// OpeningReconciled reports whether this realm was able to check the
 969// publisher's declared opening position against the ledger — and, because a
 970// failed check REFUSES to publish, it being true means the check passed.
 971//
 972// # When it applies
 973//
 974// Only when the window extended past every entry the book held at publication.
 975// Then the window's net change covers all movement from `from` onward, the
 976// declared cash accounts' summed balance covers all movement ever, and the
 977// difference between them is exactly the movement before `from` — which is
 978// what a correct opening is. An empty book qualifies too: with no postings
 979// every balance is zero, so the check requires an opening of zero.
 980//
 981// It does NOT apply to a window that closed before later entries were posted.
 982// There the balances carry post-window movement the statement rightly excludes,
 983// and comparing them would be comparing two different periods.
 984//
 985// # What it proves, and the second thing it proves
 986//
 987// First, that the declared opening is consistent with the ledger. Second — and
 988// this is why it is worth its cost — that THE ENTRY SCAN WAS COMPLETE. A scan
 989// that silently missed entries produces a short net change, and a short net
 990// change fails this comparison. That is the one mechanical control this realm
 991// has against the silent-under-reporting failure mode the package doc names,
 992// and it covers exactly the window shape where it is available.
 993//
 994// # What it does not prove
 995//
 996// Nothing about whether the codes called cash hold cash. Both sides of the
 997// comparison are the same owner's self-report, read through the same realm.
 998// It is an internal consistency check, not a custody attestation.
 999func OpeningReconciled(owner address, seq int64) bool {
1000	return mustRecord(owner, seq).reconciled
1001}
1002
1003// ReconciledCash is the declared cash accounts' summed balance at publication,
1004// and false if [OpeningReconciled] does not apply to this statement. The
1005// figure is meaningless when ok is false and is returned as zero.
1006func ReconciledCash(owner address, seq int64) (int64, bool) {
1007	r := mustRecord(owner, seq)
1008	if !r.reconciled {
1009		return 0, false
1010	}
1011	return r.cash, true
1012}
1013
1014// UnresolvedCount is how many entries could not be classified.
1015func UnresolvedCount(owner address, seq int64) int {
1016	return len(mustRecord(owner, seq).rep.Unresolved)
1017}
1018
1019// UnresolvedAt is one unresolved entry, by index into the census.
1020//
1021//   - `reason` is `conflict` (the non-cash legs classify to more than one
1022//     category), `undeclared` (a non-cash leg's account has no policy rule) or
1023//     `unbalanced`.
1024//   - `net` is the entry's net cash movement, which is real movement the
1025//     statement could not attribute.
1026//   - `categories` is the conflicting categories, comma-separated, for a
1027//     conflict.
1028//   - `codes` is the undeclared account codes, comma-separated, for an
1029//     undeclared entry. Declaring them may resolve the entry, which is why
1030//     `undeclared` takes precedence over `conflict` when both apply.
1031func UnresolvedAt(owner address, seq int64, i int) (id uint64, reason string, net int64, categories, codes string) {
1032	rows := mustRecord(owner, seq).rep.Unresolved
1033	if i < 0 || i >= len(rows) {
1034		panic("cashflow: unresolved index " + strconv.Itoa(i) + " out of range 0.." +
1035			strconv.Itoa(len(rows)-1))
1036	}
1037	u := rows[i]
1038	cats := make([]string, 0, len(u.Categories))
1039	for _, c := range u.Categories {
1040		cats = append(cats, c.String())
1041	}
1042	return u.EntryID, u.Reason.String(), u.Net,
1043		strings.Join(cats, ","), strings.Join(u.Codes, ",")
1044}
1045
1046// NetZeroCount is how many in-window entries touched cash and moved none on
1047// net.
1048func NetZeroCount(owner address, seq int64) int {
1049	return len(mustRecord(owner, seq).rep.NetZero)
1050}
1051
1052// NetZeroAt is one net-zero entry's ID, by index.
1053func NetZeroAt(owner address, seq int64, i int) uint64 {
1054	ids := mustRecord(owner, seq).rep.NetZero
1055	if i < 0 || i >= len(ids) {
1056		panic("cashflow: net-zero index " + strconv.Itoa(i) + " out of range 0.." +
1057			strconv.Itoa(len(ids)-1))
1058	}
1059	return ids[i]
1060}
1061
1062// PolicyOf is the classification policy that produced a published statement,
1063// re-encoded in [DeclarePolicy]'s own input format.
1064//
1065// It is the statement's policy, frozen with it, NOT the owner's current one.
1066// [PolicyIsReplaceable] explains why both exist: a reader of a published
1067// figure needs the declaration that produced it, and an owner needs to be able
1068// to correct a declaration without restating history.
1069func PolicyOf(owner address, seq int64) (cash, rules string) {
1070	p := mustRecord(owner, seq).pol
1071	return encodeCash(p.cash), encodeRules(p.rules)
1072}
1073
1074// ---------------------------------------------------------------------------
1075// Reading the live ledger
1076// ---------------------------------------------------------------------------
1077
1078// HasPolicy reports whether an owner has declared a policy.
1079func HasPolicy(owner address) bool { return policies.Has(owner.String()) }
1080
1081// PolicyCash is the owner's CURRENT declared cash codes, comma-separated.
1082func PolicyCash(owner address) string {
1083	return encodeCash(mustPolicy(owner).cash)
1084}
1085
1086// PolicyRules is the owner's CURRENT declared rules, comma-separated
1087// `code:category` pairs.
1088func PolicyRules(owner address) string {
1089	return encodeRules(mustPolicy(owner).rules)
1090}
1091
1092// PolicyDeclaredAt is the height the owner's current policy was declared at.
1093func PolicyDeclaredAt(owner address) int64 { return mustPolicy(owner).at }
1094
1095// LiveCash is the declared cash accounts' summed balance right now.
1096//
1097// This is the book's cumulative net cash movement since its first posting, and
1098// it is a LIVE figure — it moves with every posting. It is not a published
1099// statement and must not be quoted as one.
1100func LiveCash(owner address) int64 {
1101	return liveCash(owner, mustPolicy(owner).cash)
1102}
1103
1104// Rescan re-locates a published statement's window in the ledger NOW and
1105// reports whether the entry count and the net change still agree with what was
1106// frozen.
1107//
1108// # Why this exists and the re-derivation accessor does not
1109//
1110// The package doc says there is no `Verify` here, because re-deriving a
1111// statement over a window that has closed is a CONSTANT TRUE: entries are
1112// dated at the chain height and the journal is append-only, so nothing can
1113// later fall into a closed window.
1114//
1115// This is the part of that comparison which is NOT constant. Window selection
1116// is a binary search resting on a premise owned by [r/books] — that entry
1117// dates are non-decreasing in entry ID — and nothing freezes that premise.
1118// A future change there could break it silently. Re-locating the range and
1119// comparing is therefore a real test of the selection, and it is the only
1120// check here whose answer can change for a reason that matters.
1121//
1122// # What it costs and what it does not cover
1123//
1124// It is a plain read, so the caller pays. It re-reads the window's entries,
1125// which is up to 2*[MaxWindowEntries] cross-realm reads plus the search.
1126//
1127// It compares the entry COUNT and the NET CHANGE, not every line. The net
1128// change is the figure that moves if the entry set changed, and it is derived
1129// from the postings rather than from the policy, so a disagreement points at
1130// the ledger or the selection rather than at a reclassification. A false
1131// return means do not trust the statement and does not say which of the two
1132// it is.
1133func Rescan(owner address, seq int64) (entries int, netChange int64, agrees bool) {
1134	r := mustRecord(owner, seq)
1135	es, _, _, _ := scan(owner, r.rep.Window)
1136	rep, err := cf.Derive(chartOf(owner), es, r.rep.Window, cf.Policy{
1137		Cash:  copyCodes(r.pol.cash),
1138		Rules: copyRules(r.pol.rules),
1139	}, r.rep.Opening)
1140	if err != nil {
1141		panic("cashflow: " + err.Error())
1142	}
1143	return rep.Entries, rep.NetChange,
1144		rep.Entries == r.rep.Entries && rep.NetChange == r.rep.NetChange
1145}
1146
1147// Height is the current block height, so a reader can tell how old a
1148// statement is without a second query.
1149func Height() int64 { return runtime.ChainHeight() }
1150
1151// Ledger is the realm every entry here is read from.
1152func Ledger() string { return BooksPath }
1153
1154// ---------------------------------------------------------------------------
1155// Internals
1156// ---------------------------------------------------------------------------
1157
1158// pubKey is owner|paddedSeq. The sequence is left-padded to 19 digits — the
1159// width of MaxInt64 — so lexicographic order over an owner's prefix is numeric
1160// order over their statements. An unpadded decimal would sort "10" before "9".
1161// The convention is r/invoices' invKey, adopted rather than re-derived.
1162func pubKey(owner string, seq int64) string {
1163	s := strconv.FormatInt(seq, 10)
1164	for len(s) < 19 {
1165		s = "0" + s
1166	}
1167	return owner + "|" + s
1168}
1169
1170// ownerCount reads a publisher's count, treating an absent key as zero.
1171//
1172// avl.Tree.Get returns ONE value and cannot distinguish an absent key from a
1173// nil-valued one, so the two-value type assertion carries the existence answer
1174// in one traversal. Only this file ever Sets, and it only ever Sets an int.
1175func ownerCount(owner string) int {
1176	n, ok := counts.Get(owner).(int)
1177	if !ok {
1178		return 0
1179	}
1180	return n
1181}
1182
1183func mustRecord(owner address, seq int64) *record {
1184	r, ok := published.Get(pubKey(owner.String(), seq)).(*record)
1185	if !ok {
1186		panic("cashflow: no statement " + strconv.FormatInt(seq, 10) +
1187			" for " + owner.String())
1188	}
1189	return r
1190}
1191
1192func mustPolicy(owner address) *policy {
1193	p, ok := policies.Get(owner.String()).(*policy)
1194	if !ok {
1195		panic("cashflow: " + owner.String() + " has declared no policy; call " +
1196			"DeclarePolicy first. Which codes hold cash cannot be guessed and " +
1197			"a guess would be a figure nobody questions")
1198	}
1199	return p
1200}
1201
1202func copyCodes(in []string) []string {
1203	out := make([]string, len(in))
1204	copy(out, in)
1205	return out
1206}
1207
1208func copyRules(in []cf.Classification) []cf.Classification {
1209	out := make([]cf.Classification, len(in))
1210	copy(out, in)
1211	return out
1212}
1213
1214func encodeCash(cash []string) string { return strings.Join(cash, ",") }
1215
1216func encodeRules(rules []cf.Classification) string {
1217	parts := make([]string, 0, len(rules))
1218	for _, r := range rules {
1219		parts = append(parts, r.Code+":"+r.Category.String())
1220	}
1221	return strings.Join(parts, ",")
1222}
1223
1224// checkedAdd is int64 addition that reports overflow instead of wrapping.
1225// Local because a /p/ import for one function would be a dependency this
1226// realm's closure does not need, and the same four lines appear in p/journal,
1227// p/periods and p/cashflow.
1228func checkedAdd(a, b int64) (int64, bool) {
1229	s := a + b
1230	if (a > 0 && b > 0 && s < 0) || (a < 0 && b < 0 && s >= 0) {
1231		return 0, false
1232	}
1233	return s, true
1234}
1235
1236// ---------------------------------------------------------------------------
1237// Render
1238// ---------------------------------------------------------------------------
1239
1240// Render serves the index at "", a publisher's page at "<address>", and one
1241// statement at "<address>/<seq>".
1242//
1243// Every caller-supplied string reaching this page — an account code, an entry
1244// memo — is an owner's free text in [r/books] and arrives unsanitized, because
1245// that realm does not sanitize what it stores. Free text reaching a page
1246// unescaped is the one injection-shaped surface a realm with no custody still
1247// has, and the bytes coming from a THIRD realm's state does not make them
1248// safer. Everything goes through [cell].
1249func Render(path string) string {
1250	p := strings.TrimSpace(strings.Trim(path, "/"))
1251	if p == "" {
1252		return renderIndex()
1253	}
1254	if i := strings.Index(p, "/"); i >= 0 {
1255		owner := address(p[:i])
1256		seq, err := strconv.ParseInt(p[i+1:], 10, 64)
1257		if err != nil {
1258			return "# Not found\n\nExpected `<address>/<sequence>`.\n"
1259		}
1260		return renderStatement(owner, seq)
1261	}
1262	return renderPublisher(address(p))
1263}
1264
1265func renderIndex() string {
1266	var sb strings.Builder
1267	sb.WriteString("# Statements of cash flows\n\n")
1268	sb.WriteString("Direct-method cash-flow statements derived from books in `")
1269	sb.WriteString(BooksPath)
1270	sb.WriteString("`.\n\n")
1271	sb.WriteString("A statement here is **a claim about a claim**. The entries are read ")
1272	sb.WriteString("from the ledger rather than supplied, and the arithmetic is ")
1273	sb.WriteString("`p/cashflow`'s — but which accounts hold *cash* is declared by the ")
1274	sb.WriteString("book's own owner, and nothing on chain can check that declaration. ")
1275	sb.WriteString("The opening position is declared too; a statement says whether this ")
1276	sb.WriteString("realm was able to reconcile it.\n\n")
1277	sb.WriteString("- Statements published: **")
1278	sb.WriteString(strconv.Itoa(pubCount))
1279	sb.WriteString("**\n- Window entry limit: **")
1280	sb.WriteString(strconv.Itoa(MaxWindowEntries))
1281	sb.WriteString("** entries — a wider window is refused, never truncated\n- Height: **")
1282	sb.WriteString(strconv.FormatInt(runtime.ChainHeight(), 10))
1283	sb.WriteString("**\n\n")
1284	sb.WriteString("There is no way to enumerate publishers here: a cash-flow statement ")
1285	sb.WriteString("is one owner's, and an index of them would be a list this realm has ")
1286	sb.WriteString("no standing to publish. View one at `")
1287	sb.WriteString(RealmPath)
1288	sb.WriteString(":<address>`.\n")
1289	return sb.String()
1290}
1291
1292func renderPublisher(owner address) string {
1293	n := ownerCount(owner.String())
1294	if n == 0 {
1295		return "# No statements\n\n`" + cell(owner.String()) +
1296			"` has published none.\n"
1297	}
1298	var sb strings.Builder
1299	sb.WriteString("# Statements of cash flows\n\n**Publisher:** `")
1300	sb.WriteString(cell(owner.String()))
1301	sb.WriteString("`\n\n**Published:** ")
1302	sb.WriteString(strconv.Itoa(n))
1303	sb.WriteString("\n\n")
1304
1305	shown := n
1306	if shown > RenderPublicationLimit {
1307		shown = RenderPublicationLimit
1308	}
1309	sb.WriteString("| seq | window | net change | closing | entries | unresolved | opening reconciled |\n")
1310	sb.WriteString("|---|---|---|---|---|---|---|\n")
1311	for seq := int64(n); seq > int64(n-shown); seq-- {
1312		r := mustRecord(owner, seq)
1313		sb.WriteString("| [")
1314		sb.WriteString(strconv.FormatInt(seq, 10))
1315		sb.WriteString("](")
1316		sb.WriteString(RealmPath)
1317		sb.WriteString(":")
1318		sb.WriteString(owner.String())
1319		sb.WriteString("/")
1320		sb.WriteString(strconv.FormatInt(seq, 10))
1321		sb.WriteString(") | [")
1322		sb.WriteString(strconv.FormatInt(r.rep.Window.From, 10))
1323		sb.WriteString(", ")
1324		sb.WriteString(strconv.FormatInt(r.rep.Window.Until, 10))
1325		sb.WriteString(") | ")
1326		sb.WriteString(strconv.FormatInt(r.rep.NetChange, 10))
1327		sb.WriteString(" | ")
1328		sb.WriteString(strconv.FormatInt(r.rep.Closing, 10))
1329		sb.WriteString(" | ")
1330		sb.WriteString(strconv.Itoa(r.rep.Entries))
1331		sb.WriteString(" | ")
1332		sb.WriteString(strconv.Itoa(len(r.rep.Unresolved)))
1333		sb.WriteString(" | ")
1334		sb.WriteString(yesNo(r.reconciled))
1335		sb.WriteString(" |\n")
1336	}
1337	if n > shown {
1338		sb.WriteString("\n_Showing the newest ")
1339		sb.WriteString(strconv.Itoa(shown))
1340		sb.WriteString(" of ")
1341		sb.WriteString(strconv.Itoa(n))
1342		sb.WriteString("._\n")
1343	}
1344	return sb.String()
1345}
1346
1347func renderStatement(owner address, seq int64) string {
1348	if !published.Has(pubKey(owner.String(), seq)) {
1349		return "# Not found\n\nNo statement " + strconv.FormatInt(seq, 10) +
1350			" for `" + cell(owner.String()) + "`.\n"
1351	}
1352	r := mustRecord(owner, seq)
1353	var sb strings.Builder
1354
1355	sb.WriteString("# Statement of cash flows #")
1356	sb.WriteString(strconv.FormatInt(seq, 10))
1357	sb.WriteString("\n\n**Publisher:** `")
1358	sb.WriteString(cell(owner.String()))
1359	sb.WriteString("`\n\n**Window:** [")
1360	sb.WriteString(strconv.FormatInt(r.rep.Window.From, 10))
1361	sb.WriteString(", ")
1362	sb.WriteString(strconv.FormatInt(r.rep.Window.Until, 10))
1363	sb.WriteString(") — half-open, by block height\n\n**Frozen at height:** ")
1364	sb.WriteString(strconv.FormatInt(r.at, 10))
1365	sb.WriteString("\n\n**Ledger:** `")
1366	sb.WriteString(BooksPath)
1367	sb.WriteString("`\n\n")
1368
1369	sb.WriteString("## Activities\n\n| category | inflows | outflows | net | entries |\n")
1370	sb.WriteString("|---|---|---|---|---|\n")
1371	for _, l := range r.rep.Lines {
1372		sb.WriteString("| ")
1373		sb.WriteString(l.Category.String())
1374		sb.WriteString(" | ")
1375		sb.WriteString(strconv.FormatInt(l.Inflows, 10))
1376		sb.WriteString(" | ")
1377		sb.WriteString(strconv.FormatInt(l.Outflows, 10))
1378		sb.WriteString(" | ")
1379		sb.WriteString(strconv.FormatInt(l.Net(), 10))
1380		sb.WriteString(" | ")
1381		sb.WriteString(strconv.Itoa(l.Entries))
1382		sb.WriteString(" |\n")
1383	}
1384
1385	sb.WriteString("\n## Reconciliation\n\n| | |\n|---|---|\n| opening (declared) | ")
1386	sb.WriteString(strconv.FormatInt(r.rep.Opening, 10))
1387	sb.WriteString(" |\n| net change | ")
1388	sb.WriteString(strconv.FormatInt(r.rep.NetChange, 10))
1389	sb.WriteString(" |\n| of which unattributed | ")
1390	sb.WriteString(strconv.FormatInt(r.rep.Unattributed, 10))
1391	sb.WriteString(" |\n| closing | ")
1392	sb.WriteString(strconv.FormatInt(r.rep.Closing, 10))
1393	sb.WriteString(" |\n\n")
1394
1395	if r.reconciled {
1396		sb.WriteString("> **Opening reconciled.** The window extended past every entry ")
1397		sb.WriteString("in the book, so the declared cash accounts' balance of **")
1398		sb.WriteString(strconv.FormatInt(r.cash, 10))
1399		sb.WriteString("** is the whole book's cash movement, and it equals the closing ")
1400		sb.WriteString("figure above. That also means the entry scan was complete. It ")
1401		sb.WriteString("says nothing about whether those accounts hold cash.\n\n")
1402	} else {
1403		sb.WriteString("> **Opening NOT reconciled.** The window closed before later ")
1404		sb.WriteString("entries were posted, so the cash accounts' current balances ")
1405		sb.WriteString("carry movement this statement rightly excludes and cannot be ")
1406		sb.WriteString("compared to it. The opening position above is the publisher's ")
1407		sb.WriteString("declaration and this realm did not check it.\n\n")
1408	}
1409
1410	sb.WriteString("## Scope\n\n")
1411	if r.hi < r.lo {
1412		sb.WriteString("- Entries read: **none** — the window holds no entries\n")
1413	} else {
1414		sb.WriteString("- Entries read: **")
1415		sb.WriteString(strconv.FormatUint(r.hi-r.lo+1, 10))
1416		sb.WriteString("**, ledger IDs ")
1417		sb.WriteString(strconv.FormatUint(r.lo, 10))
1418		sb.WriteString("–")
1419		sb.WriteString(strconv.FormatUint(r.hi, 10))
1420		sb.WriteString(" (read them in `")
1421		sb.WriteString(BooksPath)
1422		sb.WriteString("` to check this statement against its source)\n")
1423	}
1424	sb.WriteString("- In-window entries: **")
1425	sb.WriteString(strconv.Itoa(r.rep.Entries))
1426	sb.WriteString("**, of which **")
1427	sb.WriteString(strconv.Itoa(r.rep.CashEntries))
1428	sb.WriteString("** touched a declared cash account\n- Window entry limit: **")
1429	sb.WriteString(strconv.Itoa(MaxWindowEntries))
1430	sb.WriteString("**\n- Split complete: **")
1431	sb.WriteString(yesNo(r.rep.Complete()))
1432	sb.WriteString("**\n\n")
1433
1434	sb.WriteString("## Policy as published\n\n- Cash accounts: `")
1435	sb.WriteString(cell(encodeCash(r.pol.cash)))
1436	sb.WriteString("`\n- Rules: `")
1437	rules := encodeRules(r.pol.rules)
1438	if rules == "" {
1439		rules = "(none declared)"
1440	}
1441	sb.WriteString(cell(rules))
1442	sb.WriteString("`\n- Declared at height: ")
1443	sb.WriteString(strconv.FormatInt(r.pol.at, 10))
1444	sb.WriteString("\n\nThis is the policy that produced the figures above, frozen with ")
1445	sb.WriteString("them. The owner's current policy may differ. A code absent from the ")
1446	sb.WriteString("rules is not defaulted — an entry touching it is listed below.\n\n")
1447
1448	sb.WriteString(renderCensus(r))
1449	return sb.String()
1450}
1451
1452func renderCensus(r *record) string {
1453	var sb strings.Builder
1454
1455	sb.WriteString("## Unresolved\n\n")
1456	if len(r.rep.Unresolved) == 0 {
1457		sb.WriteString("None. Every in-window entry that moved cash was attributed.\n\n")
1458	} else {
1459		sb.WriteString("These entries moved cash that the policy could not attribute. ")
1460		sb.WriteString("Their net movement is in **unattributed** above, so the ")
1461		sb.WriteString("arithmetic is whole and the *split* is not.\n\n")
1462		sb.WriteString("| entry | reason | net | detail |\n|---|---|---|---|\n")
1463		shown := len(r.rep.Unresolved)
1464		if shown > RenderCensusLimit {
1465			shown = RenderCensusLimit
1466		}
1467		for i := 0; i < shown; i++ {
1468			u := r.rep.Unresolved[i]
1469			sb.WriteString("| ")
1470			sb.WriteString(strconv.FormatUint(u.EntryID, 10))
1471			sb.WriteString(" | ")
1472			sb.WriteString(u.Reason.String())
1473			sb.WriteString(" | ")
1474			sb.WriteString(strconv.FormatInt(u.Net, 10))
1475			sb.WriteString(" | ")
1476			sb.WriteString(cell(censusDetail(u)))
1477			sb.WriteString(" |\n")
1478		}
1479		if len(r.rep.Unresolved) > shown {
1480			sb.WriteString("\n_Showing ")
1481			sb.WriteString(strconv.Itoa(shown))
1482			sb.WriteString(" of ")
1483			sb.WriteString(strconv.Itoa(len(r.rep.Unresolved)))
1484			sb.WriteString("._\n")
1485		}
1486		sb.WriteString("\n")
1487	}
1488
1489	sb.WriteString("## Net-zero cash entries\n\n")
1490	if len(r.rep.NetZero) == 0 {
1491		sb.WriteString("None.\n")
1492		return sb.String()
1493	}
1494	sb.WriteString("These touched a declared cash account and moved none on net — a ")
1495	sb.WriteString("transfer between cash accounts. They are not classified because ")
1496	sb.WriteString("there is nothing to attribute.\n\n")
1497	shown := len(r.rep.NetZero)
1498	if shown > RenderCensusLimit {
1499		shown = RenderCensusLimit
1500	}
1501	ids := make([]string, 0, shown)
1502	for i := 0; i < shown; i++ {
1503		ids = append(ids, strconv.FormatUint(r.rep.NetZero[i], 10))
1504	}
1505	sb.WriteString("Entries: ")
1506	sb.WriteString(strings.Join(ids, ", "))
1507	if len(r.rep.NetZero) > shown {
1508		sb.WriteString(" _(")
1509		sb.WriteString(strconv.Itoa(shown))
1510		sb.WriteString(" of ")
1511		sb.WriteString(strconv.Itoa(len(r.rep.NetZero)))
1512		sb.WriteString(")_")
1513	}
1514	sb.WriteString("\n")
1515	return sb.String()
1516}
1517
1518// censusDetail is the actionable half of an unresolved row: the undeclared
1519// codes for an undeclared entry, the conflicting categories for a conflict.
1520func censusDetail(u cf.Unresolved) string {
1521	if len(u.Codes) > 0 {
1522		return "undeclared: " + strings.Join(u.Codes, ", ")
1523	}
1524	if len(u.Categories) > 0 {
1525		cats := make([]string, 0, len(u.Categories))
1526		for _, c := range u.Categories {
1527			cats = append(cats, c.String())
1528		}
1529		return "categories: " + strings.Join(cats, ", ")
1530	}
1531	return "—"
1532}
1533
1534// cell truncates and escapes a string for a markdown table cell.
1535//
1536// The truncation is first and is not cosmetic: an account name may be
1537// books.MaxNameLen bytes and a memo books.MaxMemoLen, and a table row carrying
1538// either whole is unreadable. The escape is sanitize.TableCell, which is what
1539// keeps an owner's free text from breaking out of the cell.
1540func cell(s string) string {
1541	const maxRunes = 48
1542	if r := []rune(s); len(r) > maxRunes {
1543		s = string(r[:maxRunes]) + "…"
1544	}
1545	return sanitize.TableCell(s)
1546}
1547
1548func yesNo(b bool) string {
1549	if b {
1550		return "yes"
1551	}
1552	return "no"
1553}