cashflow
gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow on onyx-1 testnet
pure packageprimitiveaccountinglive on onyx-1
Understand where money comes from and where it goes.
Splits a period's cash movement into operating, investing and financing activity, and says so plainly when the split cannot be determined.
In detail curated
A pure DIRECT-METHOD statement of cash flows: how cash moved over one p/periods Window, split into operating/investing/financing activity, with an explicit refusal where the split is not determinate. The third primary financial statement -- p/statements derives the other two at an instant and p/periods derives one window's movement, and all three of those packages carry a doc note saying the INDIRECT method needs a cash-vs-accrual classification journal.Posting does not have. That note is TRUE and does not block this package: the DIRECT method needs something smaller and caller-suppliable -- a declaration of which chart codes hold cash -- so nothing about what a posting is had to change. THE CENTRAL CORRECTNESS DECISION IS THAT ATTRIBUTION IS PER ENTRY, NEVER PER LEG, and the natural per-leg design is wrong in a way that still BALANCES: on a part-paid purchase (debit Inventory 100 / credit Cash 60 / credit Payables 40) per-leg attribution reports 100 of outflow against the 60 that actually moved, inventing a 40 cash flow that never happened while the statement adds up. So an entry's net movement is computed from its CASH legs alone and that whole figure goes to one category. DETERMINACY IS A REFUSAL, NOT AN APPORTIONMENT: an entry is determinate iff it has at least one non-cash leg and all of them classify alike; otherwise it is named in Report.Unresolved with one of THREE reasons and its delta is carried in Report.Unattributed, so NetChange and Closing stay true even when the split is incomplete. ReasonUnbalanced exists separately because a ReasonConflict carrying an EMPTY Categories set is indistinguishable from a bug in the package -- found by writing the conflict test, not by reading the code. CLASSIFICATION IS PER CODE AND DELIBERATELY NOT PER journal.Kind: a kind-based mapping needs no caller declaration and is the first design anyone reaches for, and it puts the ENTIRE working-capital cycle in the wrong section, because receivables and inventory are assets whose movement is OPERATING. There is no default bucket of any sort; an undeclared code makes its entry unresolved, since silence is not assent. Cash must be a journal.Asset (ErrCashAccountNotAsset) or a debit to cash would stop meaning an inflow and nothing downstream could tell; the stated cost is that a bank overdraft carried as a liability cannot be declared as cash. Imports stdlib errors plus p/journal and p/periods; MaxAccounts/MaxEntries are reached THROUGH periods rather than restated. 18 sentinel errors; Cash, Rules and the chart are all strictly ascending because membership is a binary search in Derive's inner loop.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow"The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.
Called from real code
Not an example written for this page. These lines are quoted from cashflow, a deployed package in this portfolio that imports this one as cf, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.
if _, err := cf.Derive(
chartOf(owner), nil,
periods.Window{From: 0, Until: 1},
cf.Policy{Cash: c, Rules: r},
0,
); err != nil {rep, err := cf.Derive(chart, entries, w, cf.Policy{
Cash: copyCodes(pol.cash),
Rules: copyRules(pol.rules),
}, opening)rep, err := cf.Derive(chartOf(owner), es, r.rep.Window, cf.Policy{
Cash: copyCodes(r.pol.cash),
Rules: copyRules(r.pol.rules),
}, r.rep.Opening)Deployments
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 375,382 |
| Deploy transaction | e601754869a8a69a33ae598aa4eae806d88cedd51ec077f3913f588773c67763 look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 45,099,881 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | cashflow.gno gnomod.toml |
| Deployed bytes | cashflow.gno — 33,331 bytes |
| sha256 | 77dfe5927a4b026e029b991a6a9125a0f683c9d6b177529545e86ebc1859b226 |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | r/cashflow |
|---|
Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow$download&file=cashflow.gno' | shasum -a 256Expected: 77dfe5927a4b026e029b991a6a9125a0f683c9d6b177529545e86ebc1859b226 — 33,331 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 1 exported function, 7 types, 8 methods.
This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.
Overview
Package cashflow derives a DIRECT-METHOD statement of cash flows from a double-entry book: how cash moved over one window, split into operating, investing and financing activity, with an explicit refusal where the split is not determinate.
It is the third primary financial statement. p/statements derives the other two from a chart at an instant, and p/periods derives one window's movement; all three of those packages carry a note saying they do not do this one, and all three say the INDIRECT method needs a cash-vs-accrual classification journal.Posting does not carry. That is true. The direct method needs something different and smaller: a declaration of which chart codes hold cash. A declaration is something a caller can supply, which is what makes this package possible without changing what a posting is.
The sign convention, and why cash must be an asset
A debit to a cash account is an INFLOW and a credit is an OUTFLOW. That follows from cash being debit-normal, so Policy requires every declared cash code to be a journal.Asset and refuses anything else (ErrCashAccountNotAsset). Declaring a credit-normal account as cash would invert every figure in the report and nothing downstream could tell. The cost of the rule is real and is stated rather than hidden: a bank overdraft carried as a liability cannot be declared as cash, and a caller holding one must carry it as a contra-asset or leave it out.
Attribution is per ENTRY, never per leg
This is the package's central correctness decision, and the natural design is wrong in a way that produces numbers which balance. Take one ordinary part-paid purchase:
debit Inventory 100 credit Cash 60 credit AccountsPayable 40
Classifying each non-cash leg and attributing its own amount reports 100 of inventory-related outflow against the 60 of cash that actually moved. It invents a cash flow of 40 that did not happen, and the statement still adds up. So the unit of attribution is the entry:
- An entry with NO cash legs is skipped. It is accrual-only and moved no cash. This is the direct method's whole point.
- An entry whose net cash movement is ZERO is named in [Report.NetZero] and not classified. A transfer between two declared cash accounts is the common case; there is nothing to attribute, so determinacy is not demanded of it.
- Otherwise the entry's net cash movement is computed from its CASH legs alone — so the figure is what actually moved — and that whole figure is attributed to one category.
The determinacy rule, stated as a refusal
An entry touching cash is determinate iff it has at least one non-cash leg and all of its non-cash legs classify to the SAME category, in which case the entry's whole net cash delta goes there. Otherwise the entry is named in [Report.Unresolved] with the reason, and its cash delta is carried in [Report.Unattributed].
The "at least one" clause is not pedantry. An entry with nonzero net cash and no non-cash leg at all cannot come out of a balanced book, and it gets its own ReasonUnbalanced rather than being reported as a conflict with nothing to conflict — see that constant.
Under that rule the purchase above is determinate when Inventory and AccountsPayable both classify operating: 60 of operating outflow, which is right. An entry that settles a loan and buys equipment at once — one leg financing, one investing — is NOT determinate, and no apportionment is invented for it. Splitting it proportionally would be a guess wearing a number's clothes.
Why classification is per CODE and not per journal.Kind
A mapping from journal.Kind to a category would need no caller declaration at all, and it is the first design anyone reaches for. It is wrong on the most common accounts in any book: receivables and inventory are assets whose movement is OPERATING, not investing, and a kind-based mapping puts the entire working-capital cycle in the wrong section. So there is no kind-based default and no default bucket of any sort. A non-cash account with no declared category makes its entry unresolved (ReasonUndeclared) — silence is not assent.
What this package does not do
- **It never writes, and never sees a Book.** Derive returns figures; the caller owns persistence and idempotence.
- **It cannot verify the policy.** Which codes hold cash and what each other code's movement means are caller declarations. A chart that declares a receivable as cash produces a plausible, wrong statement. The declaration is the trust boundary.
- **It cannot verify the opening balance.** A window-filtered entry slice carries period movement, not a cumulative position, so [Report.Opening] is echoed back exactly as supplied and [Report.Closing] is only as good as it. This is p/periods' limitation inherited, not a new one.
- **No reversal handling.** A reversing entry that touches cash moved cash, and is classified like any other entry. That differs from p/periods, where an unresolved reversal is a census item, and the difference is deliberate: a cash-flow statement reports movement, and a reversal's movement is movement.
- **No paging and no merge.** MaxEntries is the hard bound. Merging reports across pages would make the unresolved set an artifact of the paging rather than of the data.
- **No audit.** Every figure is derived from what the caller supplied. A chart from one book and entries from another produce a plausible report and nothing here detects it.
- **Reconciling against p/periods proves nothing about the split.** Comparing [Report.NetChange] with the cash accounts' movement in a periods report is an arithmetic self-check that both packages added the same columns the same way. A statement that files every flow under the wrong category reconciles perfectly.
p/journal: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal p/periods: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods p/statements: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
Imports
errorsgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journalgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods
Constants and variables
MaxAccounts bounds the chart Derive will accept. It is periods' bound, which is statements' bound, which is journal's own account cap — one number, declared once, so a chart that clears one of these packages clears all three.
const MaxAccounts = periods.MaxAccounts
MaxEntries bounds the entry slice Derive will walk. It is periods' bound for the same reason: a caller feeding the same window to both packages must not have one of them refuse what the other accepted.
const MaxEntries = periods.MaxEntries
NumCategories is how many categories a statement of cash flows has, and so the length [Report.Lines] always has. It is a constant of the standard, not a configuration.
const NumCategories = 3
var (
// ErrInvalidWindow is a window failing [periods.Window.Valid].
ErrInvalidWindow = errors.New("cashflow: invalid window")
// ErrTooManyAccounts is a chart longer than [MaxAccounts].
ErrTooManyAccounts = errors.New("cashflow: chart length exceeds MaxAccounts")
// ErrChartNotSorted is a chart whose codes are not strictly ascending.
// Membership here is a binary search, so the ordering is load-bearing
// rather than cosmetic.
ErrChartNotSorted = errors.New("cashflow: chart codes not strictly ascending")
// ErrInvalidKind is a chart row whose Kind is outside journal's five.
ErrInvalidKind = errors.New("cashflow: invalid account kind")
// ErrTooManyEntries is an entry slice longer than [MaxEntries].
ErrTooManyEntries = errors.New("cashflow: entry count exceeds MaxEntries")
// ErrEntriesNotSorted is an entry slice not strictly ascending by ID.
ErrEntriesNotSorted = errors.New("cashflow: entry IDs not strictly ascending")
// ErrTooManyPostings is an entry with more than
// [journal.MaxPostingsPerEntry] postings. journal cannot produce one, and
// re-checking is what makes [MaxEntries]'s cost bound a proven ceiling
// rather than a hope.
ErrTooManyPostings = errors.New("cashflow: entry exceeds journal.MaxPostingsPerEntry")
// ErrInvalidPosting is an in-window posting with an invalid Side or a
// non-positive Amount. journal carries direction in Side and amounts are
// always positive, so a signed amount here is a posting from somewhere
// else.
ErrInvalidPosting = errors.New("cashflow: invalid posting")
// ErrUnknownAccount is an in-window posting naming a code absent from the
// chart. It is scoped to the window because no figure in the report
// depends on an entry outside it.
ErrUnknownAccount = errors.New("cashflow: posting names an account absent from the chart")
// ErrNoCashAccounts is an empty [Policy.Cash]. A statement of cash flows
// over a book with no declared cash is all zeroes and looks exactly like
// a quiet period, so the empty declaration is refused rather than
// reported.
ErrNoCashAccounts = errors.New("cashflow: policy declares no cash accounts")
// ErrCashNotSorted is a [Policy.Cash] not strictly ascending.
ErrCashNotSorted = errors.New("cashflow: policy cash codes not strictly ascending")
// ErrUnknownCashAccount is a declared cash code absent from the chart.
ErrUnknownCashAccount = errors.New("cashflow: policy declares a cash account absent from the chart")
// ErrCashAccountNotAsset is a declared cash code whose chart Kind is not
// [journal.Asset]. See the package doc on the sign convention: this is
// what keeps a debit to cash meaning an inflow.
ErrCashAccountNotAsset = errors.New("cashflow: policy declares a non-asset account as cash")
// ErrRulesNotSorted is a [Policy.Rules] not strictly ascending by Code.
ErrRulesNotSorted = errors.New("cashflow: policy rule codes not strictly ascending")
// ErrInvalidCategory is a category outside the declared three — in a
// policy rule, or passed to [Report.Line].
ErrInvalidCategory = errors.New("cashflow: unknown category")
// ErrUnknownRuleAccount is a policy rule naming a code absent from the
// chart.
ErrUnknownRuleAccount = errors.New("cashflow: policy rule names an account absent from the chart")
// ErrCashAccountClassified is a code declared both as cash and as a
// classified account. The two roles are exclusive: a cash account is what
// MOVES, and a classified account is what the movement is FOR. Accepting
// both would make the same code answer two different questions in one
// entry.
ErrCashAccountClassified = errors.New("cashflow: code is declared as cash and also classified")
// ErrOverflow is an int64 overflow in any accumulation, including the
// entry-level cash total and the closing balance.
ErrOverflow = errors.New("cashflow: int64 overflow")
)
The three activity categories of a statement of cash flows.
const (
CategoryOperating Category = 1 // trading: the working-capital cycle
CategoryInvesting Category = 2 // long-lived assets and investments
CategoryFinancing Category = 3 // capital and borrowings
)
The three reasons an entry is unresolved.
const (
// ReasonConflict is an entry whose non-cash legs classify to more than
// one category. [Unresolved.Categories] names them, and names at least
// two.
ReasonConflict Reason = 1
// ReasonUndeclared is an entry with a non-cash leg whose account has no
// policy rule. [Unresolved.Codes] names them. An undeclared account is
// named rather than defaulted, because a default bucket turns a missing
// declaration into a figure nobody questions.
//
// It takes precedence over [ReasonConflict] when an entry has both an
// undeclared code and two declared categories, because the undeclared
// code is the actionable half: declaring it may resolve the entry, and
// reporting a conflict while concealing a missing declaration would send
// the reader to re-examine a policy that is not yet complete.
ReasonUndeclared Reason = 2
// ReasonUnbalanced is an entry all of whose legs are declared cash
// accounts and whose cash legs do not net to zero.
//
// [journal.Book] cannot produce one: a balanced entry whose every leg is
// cash has equal debits and credits over cash and therefore nets to zero,
// which makes it a [Report.NetZero] item instead. So this reason is
// reachable only from a hand-built entry slice, the same class
// [p/periods] documents for its own hand-built Report.
//
// It exists rather than being folded into [ReasonConflict] because a
// conflict carrying an EMPTY [Unresolved.Categories] is indistinguishable
// from a bug in this package, and a census item a reader cannot tell from
// a defect is not a census item. Found by writing the test for the
// conflict case, not by reading the code.
ReasonUnbalanced Reason = 3
)
Types
type Category
type Category uint8
Category is which activity a cash flow belongs to. The zero value is deliberately invalid, so a forgotten field is an error rather than operating activity — which is the bucket a defaulting implementation would pick and the one that would hide the most.
The constants carry a Category prefix for the same reason [statements.Section]'s do: a bare Operating would read as a field name.
Category.String
func (c Category) String() string
String returns the category's name, or "invalid" for the zero value and any unrecognised code.
Category.Valid
func (c Category) Valid() bool
Valid reports whether c is one of the three categories.
type Classification
type Classification struct {
Code string
Category Category
}
Classification is one account code's activity category, declared by the caller. See the package doc on why this is per code rather than per journal.Kind.
type Line
type Line struct {
Category Category
// Inflows is the gross cash received, non-negative.
Inflows int64
// Outflows is the gross cash paid, non-negative.
Outflows int64
// Entries is how many in-window entries were attributed here.
Entries int
}
Line is one category's gross flows over the window.
Inflows and Outflows are both non-negative AS PRODUCED BY Derive, and gross rather than netted, because a category with 1,000 in and 1,000 out is not the same business as one with nothing happening and a net of zero is all a netted figure can say about either.
| Exported field | Type | Doc |
|---|---|---|
Category | Category | |
Inflows | int64 | Inflows is the gross cash received, non-negative. |
Outflows | int64 | Outflows is the gross cash paid, non-negative. |
Entries | int | Entries is how many in-window entries were attributed here. |
Line.Net
func (l Line) Net() int64
Net returns Inflows minus Outflows.
The subtraction is unchecked, and that is safe for any Line Derive produces: both columns are non-negative, so the difference is in range for every pair of int64 values they can hold. A hand-built Line with a negative column can wrap, and that is the only way it can.
The wrapped value does not stay local, which is the part worth stating: Report.Reconciles sums Net() over the lines, and a wrapped Net() can make it agree over a report whose columns say something else entirely. So Reconciles refuses a negative column outright rather than summing it — see the guard there. Net() itself stays unchecked because it has no way to report a refusal and a Line is a figure, not a claim.
This note previously compared the blast radius to periods.Activity.Movement and said that one was wider because a negative column there also inverts a sign convention. That comparative was wrong, and an audit of this package found it: both wrap a figure that then passes every downstream guard, and the real asymmetry is in where the wrapped value GOES, not in sign inversion. The note is kept, corrected, because the equivalent note in periods was also once wrong about its own blast radius and also had to be found by an audit — twice is a pattern, and the pattern is that a comparative claim about another package's hazard is the sentence nothing checks.
type Policy
type Policy struct {
// Cash is the codes that hold cash, strictly ascending. Must be
// non-empty, and every code must be a [journal.Asset] in the chart.
Cash []string
// Rules is the activity category for non-cash codes, strictly ascending
// by Code. A code absent from here is NOT defaulted; an entry touching it
// is unresolved with [ReasonUndeclared].
Rules []Classification
}
Policy is the caller's whole declaration: which chart codes hold cash, and what each other code's movement is for.
Both slices must be strictly ascending by code, because membership is a binary search in Derive's inner loop. The two sets must be disjoint (ErrCashAccountClassified). Neither is verified against anything but the chart — this package cannot know whether a code called "petty cash" holds cash, and says so in its doc rather than pretending the name is evidence.
| Exported field | Type | Doc |
|---|---|---|
Cash | []string | Cash is the codes that hold cash, strictly ascending. Must be non-empty, and every code must be a [journal.Asset] in the chart. |
Rules | []Classification | Rules is the activity category for non-cash codes, strictly ascending by Code. A code absent from here is NOT defaulted; an entry touching it is unresolved with [ReasonUndeclared]. |
type Reason
type Reason uint8
Reason is why Derive would not classify an entry. The zero value is deliberately invalid.
Reason.String
func (r Reason) String() string
String returns the reason's name, or "invalid".
Reason.Valid
func (r Reason) Valid() bool
Valid reports whether r is one of the three reasons.
type Report
type Report struct {
// Window is the window the figures are for, echoed back so a stored
// report cannot be read without it.
Window periods.Window
// Lines is the three categories, ascending by Category, always present
// and always of length [NumCategories] — a category with no activity is a
// zero line, not an absent one, because a statement of cash flows with a
// missing section is not a statement of cash flows. Use [Report.Line] to
// reach one by category rather than indexing by arithmetic.
Lines []Line
// Opening is the caller-supplied opening cash position, echoed back
// unchanged. This package cannot verify it; see the package doc. A
// negative value is accepted — an overdrawn position is a real one — so
// the sign is not constrained here.
Opening int64
// NetChange is the window's total net cash movement, attributed and
// unattributed together. It is the book's figure, not the statement's, so
// it is true even when the split is not complete.
NetChange int64
// Unattributed is the net cash movement of the entries in Unresolved.
// Zero exactly when every entry that moved cash was classified.
Unattributed int64
// Closing is Opening plus NetChange.
Closing int64
// Entries is how many entries fell in the window, including the ones that
// moved no cash. Zero is a legitimate report of a quiet period, not an
// error.
Entries int
// CashEntries is how many in-window entries touched a declared cash
// account at all, including the ones in NetZero and Unresolved.
CashEntries int
// NetZero is the IDs of in-window entries that touched cash but moved
// none on net — a transfer between two declared cash accounts, possibly
// alongside non-cash legs. Classification is deliberately not attempted:
// there is nothing to attribute, so demanding determinacy of them would
// manufacture unresolved entries out of non-events.
NetZero []uint64
// Unresolved is the entries whose category was not determinate.
Unresolved []Unresolved
}
Report is one window's statement of cash flows. Every slice is freshly allocated by Derive on every call and is never nil, so the caller owns it and an empty result is an empty slice rather than a nil to be distinguished from one.
The invariant that makes the report readable when the split is incomplete:
sum(Lines[i].Net()) + Unattributed == NetChange Opening + NetChange == Closing
Both hold on every Report Derive returns, including one with unresolved entries. Report.Reconciles asserts the first.
| Exported field | Type | Doc |
|---|---|---|
Window | periods.Window | Window is the window the figures are for, echoed back so a stored report cannot be read without it. |
Lines | []Line | Lines is the three categories, ascending by Category, always present and always of length [NumCategories] — a category with no activity is a zero line, not an absent one, because a statement of cash flows with a missing section is not a statement of cash flows. Use [Report.Line] to reach one by category rather than indexing by arithmetic. |
Opening | int64 | Opening is the caller-supplied opening cash position, echoed back unchanged. This package cannot verify it; see the package doc. A negative value is accepted — an overdrawn position is a real one — so the sign is not constrained here. |
NetChange | int64 | NetChange is the window's total net cash movement, attributed and unattributed together. It is the book's figure, not the statement's, so it is true even when the split is not complete. |
Unattributed | int64 | Unattributed is the net cash movement of the entries in Unresolved. Zero exactly when every entry that moved cash was classified. |
Closing | int64 | Closing is Opening plus NetChange. |
Entries | int | Entries is how many entries fell in the window, including the ones that moved no cash. Zero is a legitimate report of a quiet period, not an error. |
CashEntries | int | CashEntries is how many in-window entries touched a declared cash account at all, including the ones in NetZero and Unresolved. |
NetZero | []uint64 | NetZero is the IDs of in-window entries that touched cash but moved none on net — a transfer between two declared cash accounts, possibly alongside non-cash legs. Classification is deliberately not attempted: there is nothing to attribute, so demanding determinacy of them would manufacture unresolved entries out of non-events. |
Unresolved | []Unresolved | Unresolved is the entries whose category was not determinate. |
Report.Complete
func (r Report) Complete() bool
Complete reports whether every in-window entry that moved cash was attributed to a category.
A complete report is not a correct one: completeness is a property of the POLICY's coverage, and a policy that classifies every account wrongly is complete. See the package doc.
Report.Line
func (r Report) Line(c Category) (Line, error)
Line returns the line for c. An invalid category is ErrInvalidCategory; there is no default line.
This is the accessor to use. [Report.Lines] is ordered by Category and a consumer could index it as Lines[c-1], but that arithmetic is a second copy of the ordering invariant and the kind of thing that survives a reordering silently.
Report.Reconciles
func (r Report) Reconciles() bool
Reconciles reports whether the attributed lines plus the unattributed residual equal NetChange.
It is offered as API rather than asserted internally because a Report can be built by hand, and a consumer that stores one should be able to check it on the way back out. On a Report from Derive it is always true.
False means one of three things and does not distinguish them: the lines do not sum to NetChange, the sum overflows, or a line carries a negative column (which Line.Net would wrap). All three are "do not trust this report", which is the whole question a bool can answer.
type Unresolved
type Unresolved struct {
// EntryID is the entry's journal ID.
EntryID uint64
// Reason is why it could not be classified.
Reason Reason
// Net is the entry's net cash movement: positive for an inflow. Reported
// so a reader can see the size of what is unattributed rather than only
// that something is.
Net int64
// Categories is the conflicting set, ascending and of length at least
// two, when Reason is [ReasonConflict]; empty otherwise. Freshly
// allocated and never nil.
//
// Ascending rather than in posting order, so that two records of the same
// conflict compare equal whichever leg was posted first.
Categories []Category
// Codes is the undeclared account codes, in posting order and
// deduplicated, when Reason is [ReasonUndeclared]; empty otherwise.
// Freshly allocated and never nil.
//
// Posting order rather than ascending, because these are a worklist for
// whoever extends the policy and the order a reader meets them in the
// entry is the more useful one. The two slices differ on this
// deliberately: a conflict set is compared, a code list is worked
// through.
Codes []string
}
Unresolved is one in-window entry Derive would not classify. It is a census item, not an error: the entry's cash movement is real and is carried in [Report.Unattributed], so [Report.NetChange] and [Report.Closing] stay true even when the split is incomplete.
Whether an entry here SHOULD have been classifiable is an accounting judgement made outside this package. What is reported is that this policy could not decide it, and exactly what was missing or in conflict.
| Exported field | Type | Doc |
|---|---|---|
EntryID | uint64 | EntryID is the entry's journal ID. |
Reason | Reason | Reason is why it could not be classified. |
Net | int64 | Net is the entry's net cash movement: positive for an inflow. Reported so a reader can see the size of what is unattributed rather than only that something is. |
Categories | []Category | Categories is the conflicting set, ascending and of length at least two, when Reason is [ReasonConflict]; empty otherwise. Freshly allocated and never nil. Ascending rather than in posting order, so that two records of the same conflict compare equal whichever leg was posted first. |
Codes | []string | Codes is the undeclared account codes, in posting order and deduplicated, when Reason is [ReasonUndeclared]; empty otherwise. Freshly allocated and never nil. Posting order rather than ascending, because these are a worklist for whoever extends the policy and the order a reader meets them in the entry is the more useful one. The two slices differ on this deliberately: a conflict set is compared, a code list is worked through. |
Functions
Derive
func Derive(chart []journal.Account, entries []journal.Entry, w periods.Window, p Policy, opening int64) (Report, error)
Derive builds the statement for one window.
Preconditions, all refused rather than assumed: w must be valid; chart must be strictly ascending by Code, no longer than MaxAccounts, with every Kind one of journal's five; entries must be strictly ascending by ID and no longer than MaxEntries, with at most journal.MaxPostingsPerEntry postings each; and p must satisfy the rules on Policy.
Entries outside w are skipped, not refused — a caller passing the whole book is the expected case — and their postings are therefore not content-checked, because no figure in the report depends on them. ErrInvalidPosting and ErrUnknownAccount are scoped to the window for that reason; the postings-per-entry cap is not, since it is what makes MaxEntries's cost ceiling a bound rather than a hope.
Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.
Dependencies
chain-attested| Imports | errors, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods |
|---|---|
| First-party dependencies | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curated- THE BYTE-MATCH WAS EXACT. On-chain cashflow.gno == committed 77dfe592 at 33331 bytes; fourth consecutive byte-perfect /p/ deploy. The committed bytes were pre-measured before transcription (33295 chars, 872 newlines, 835 tabs, 0 CR, trailing newline, 0 trailing-whitespace lines, 18 non-ASCII all U+2014). THE PRE-BROADCAST TRANSCRIPTION HAD NO INDEPENDENT CONTROL and that is recorded rather than dressed up: a /tmp rehearsal hashes a DIFFERENT transcription from the one sent to gno_addpkg, so it cannot cover the payload that ships, and quoting it as a control would be the check-that-proves-nothing shape. The real controls were the post-broadcast vm/qfile byte-match (authoritative, EXACT) and the weaker signal that simulate and broadcast returned identical gas_used.
- GATE B RAN AFTER THE BROADCAST -- A SECTION-7a VIOLATION, REPORTED NOT WAIVED. 7a requires Gate B 'at TESTNET VALIDATION, immediately before deploy' and says to treat both gates as required. Gate A ran; Gate B did not, and the omission was caught by listing the artifact directory during record-writing, AFTER the irreversible write. When run it read EQUIVALENT with a digest identical to Gate A's (006eb57d), and the tree was provably quiescent across the interval -- payload digests matched HEAD immediately pre-broadcast and the only working-tree changes were two gate JSONs. So no wrong outcome followed, AND FAILING TO PRODUCE A WRONG OUTCOME IS NOT THE SAME AS HAVING RUN THE GATE. This is task #107 recurring AFTER it was filed, which is the finding worth more than the miss: A TASK FILED IS NOT A CONTROL INSTALLED.
- GATE A/B COMPARED 4 DEPENDENCIES, NOT A FULL CLOSURE. Residual stdlibs (6 at Gate B, 17 at Gate A with --include-tests) execute from the node binary and rest on the GNOROOT pin, which is SEALED, not attested.
- A BARE /p/ HOLDS NO STATE, so nothing on chain was mutated. The behavioural citations evaluate pure functions; vm/qeval discards writes and there are none to discard. A consumer realm persisting a Report would discharge this; none is deployed.
- DERIVE WAS NOT PROBED LIVE. It returns (Report, error) and qeval takes an expression, so a multi-valued return cannot be bound. Per-entry attribution and the three-reason refusal rest on the committed 55-test suite and the 93-case sabotage sweep under the pinned onyx toolchain. Statement support in qeval (upstream) or a consumer realm calling Derive would discharge it.
- THE POLICY IS THE TRUST BOUNDARY AND NOTHING CAN VERIFY IT. Which codes hold cash, and what each other code's movement means, are caller declarations; a chart declaring a receivable as cash produces a plausible, WRONG statement with every guard satisfied. This is a design property stated in the package doc, not a gap -- it is inherent to the direct method. Likewise the opening balance is echoed back unverified (p/periods' limitation inherited), so Closing is only as good as the Opening supplied.
- RECONCILING AGAINST p/periods PROVES NOTHING ABOUT THE SPLIT. Comparing Report.NetChange with the cash accounts' movement in a periods report is an arithmetic self-check that both packages added the same columns the same way. A statement that files every flow under the WRONG CATEGORY reconciles perfectly. Report.Complete() is the same shape: completeness is a property of the POLICY's coverage, and a policy classifying every account wrongly is complete.
- THE SIMULATE DID NOT TYPE-CHECK. onyx-1 runs the inert code-submission policy, so the dry run reports gas without compiling. Type-correctness rests on the local gno test under the onyx pin (55 passed, 0 failed), which is the control actually relied on.
- THE STAGED DISCOVERY.md ONYX-1 CORPUS WAS 10 FIRST-PARTY PACKAGES SHORT (AL31, task #115). The step-8 re-run returned REVIEW REQUIRED against staging's '10/10 terms zero'; cause measured, not assumed -- the staging corpus held 7 of the then-live 17 first-party onyx-1 packages, omitting exactly everything at height >= 327179, i.e. the whole general-ledger family, which is cashflow's own. Staging's '0 unreadable' line was TRUE AND USELESS: REACH IS NOT CURRENCY, and every control passed because a control proves the predicate ran over the corpus, never that the corpus is the population. THE VERDICT IS UNCHANGED -- the 5 subject hits in the fresh 684-path corpus are all first-party siblings' own negative declarations, already classified in DISCOVERY.md section 3 -- so what is wrong is two COVERAGE ROWS in the record, not the conclusion.
- AL29, 2026-10-15: LIMITATION 4's STATED REASON IS FALSE, and it is corrected here rather than edited because this record is committed (section 9). Limitation 4 reads 'DERIVE WAS NOT PROBED LIVE. It returns (Report, error) and qeval takes an expression, so a multi-valued return cannot be bound.' The first sentence is true; the second is an INFERENCE from a real mechanism and not a measurement of it, and it is wrong. func() T { ...statements...; return x }() is a SINGLE EXPRESSION whose body is statements, so a (*T, error) constructor binds and its methods run -- measured on onyx-1 the same day, stamped chain_id_bound: true, against p/journal (construct, open a chart, post a balanced entry, then REPLAY every posting -> (700 int64) (700 int64) (true bool), with the 700->600 fixture returning 'journal: entry does not balance, credits are short' as its non-vacuity control) and p/lots (FIFO 200 against LIFO 250 over identical layers, each value the other's control). CONSEQUENCE FOR THIS RECORD: Derive is UNPROBED, not unreachable, and the honest word matters because a ceiling declared closed suppresses the work that would reopen it. The obstacle that IS measured is a different one -- Derive names []journal.Account and []journal.Entry, and the imported identifier `journal` is not bound in a qeval expression context ('name journal not declared', measured twice on p/statements). Derive(nil, nil, periods.Window{}, Policy{}, 0) names no foreign value and has never been attempted; it is owed, not impossible. THE GENERAL RULE THIS ENTRY EXISTS TO CARRY: a record declaring a ceiling must CITE THE ATTEMPT that established it, so that 'unreachable' and 'never tried' stop reading identically. Nothing mechanical checks a declared limitation. See [an unpublished record] and onyx/AL29-behavioral-probes.json.
- AL29b, 2026-10-15: DERIVE IS NOW PROBED LIVE, AND THE ROUTE LIMITATION 9 NAMED AS THE THING TO TRY WAS ITSELF WRONG. Limitation 9 records `Derive(nil, nil, periods.Window{}, Policy{}, 0)` as un-attempted-not-impossible. That expression does NOT work: `periods.Window{}` names the unbound `periods` identifier exactly as `journal` is unbound, so the route recorded as the discharge reproduces the obstacle it was written to route around. Corrected here rather than edited, per section 9. THE WORKING ROUTE IS A FIELD SELECTOR: Report.Window is a periods.Window (cashflow.gno:435) and field names never name a package. Three probes, all predicted before the read, all stamped chain_id_bound: true, all agreeing (probes 25-27 in onyx/AL29-behavioral-probes.json): func() string { _, err := Derive(nil, nil, Report{}.Window, Policy{}, 0); return err.Error() }() -> 'cashflow: invalid window'; then with `var r Report; r.Window.From = 0; r.Window.Until = 10` giving a VALID window, Derive(nil, nil, r.Window, Policy{}, 0) -> 'cashflow: policy declares no cash accounts' (which proves guard 1 passed); then Policy{Cash: []string{"1000"}} -> 'cashflow: policy declares a cash account absent from the chart'. GENERALISED: when an imported identifier is unbound in a qeval expression, reach the foreign named type through any first-party struct field that already has that type. THE TRIO DISCRIMINATES GUARD ORDER in the shipped bytes -- window, then empty-cash, then chart-lookup -- with three distinct verbatim audited sentinels, each the others' non-vacuity control, which is the top of the citation-strength ordering. WHAT IS STILL OUT OF REACH, AND THIS CEILING CITES ITS ATTEMPT: no type in p/cashflow or p/statements has a field of type journal.Account or journal.Entry -- measured by grep, not assumed -- so a non-nil chart or entry slice cannot be built without binding `journal`. cashflow's ATTRIBUTED HAPPY PATH therefore remains unreached, since validatePolicy requires a cash code present in the chart; per-entry attribution and the three-reason refusal still rest on the committed 55-test suite and the 93-case sweep. A consumer realm is the instrument for those. NOTE FOR A FUTURE READER: limitation 4's first sentence ('DERIVE WAS NOT PROBED LIVE') and the identical sentence in [an unpublished record] Group 21 were TRUE WHEN WRITTEN and are superseded by this entry, not refuted as of their date -- only the stated reason was false, which limitation 9 already corrected. Do not re-file either as a false-ceiling instance.
- C1: Derive's outflow negation at the minInt64 edge wraps unchecked (-net before checkedAdd can see it), so the documented non-negative-column invariant is false for HAND-BUILT entry slices (a journal.Book cannot produce the shape; Reconciles() catches the result downstream). Importer-conditional; fix staged for the mainnet twin pre-deploy decision / successor: periods' magnitude() shape. Audit 2026-11-04, disclosed per the operator's disclose-and-queue-successor rule; details and severity in [an unpublished record].
Source
| Source file | onyx/p/cashflow/cashflow.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
|---|---|
| Matches the deployed bytes | yes — byte-identical |