depreciation
gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation on onyx-1 testnet
pure packageprimitiveaccountinglive on onyx-1
Know what a capital asset is worth after a given number of periods.
Spreads an asset's cost over its useful life so the carrying amount is a calculation rather than a judgement call.
In detail curated
The carrying amount of a capital asset as a PURE FUNCTION of how many whole periods it has been in service -- not an accumulator, and that distinction is the whole design. The obvious implementation stores a carrying amount, subtracts a per-period charge and advances an anchor; because the charge is an integer division it truncates on every call and forfeits the part-period the call lands in, so RUNNING THE SCHEDULE MORE OFTEN DEPRECIATES LESS -- a misstatement of asset value, not a rounding preference. Here CarryingAt(n) is a pure function of the period count and Charge(from,to) is its difference at two points, so charges TELESCOPE exactly -- Charge(a,b)+Charge(b,c) == Charge(a,c) -- by construction rather than by careful anchor arithmetic, and there is no stored carrying amount that could drift at all. The read path cannot disagree with the write path because there is one function and AccumulatedAt and Charge are both defined in terms of it. Two methods: StraightLine (closed form) and DecliningBalance (a bounded loop -- integer declining balance has NO closed form, because period n is the rate applied n times with the floor taken each period, and taking the floor once at the end is a different and wrong number). The loop is bounded by the asset's own declared useful life, validated against MaxLife=1200 at construction, so the cost of a call is bounded by a number the caller declared and this package checked, never by how long ago the asset was bought. Overflow is excluded at CONSTRUCTION rather than per call: check() establishes Basis <= maxInt64/Life and Cost <= maxInt64/RateDen, which is what makes CarryingAt and Charge total functions on a constructed Schedule. Holds no coins, reads no chain API, owns no package-level state, and imports only `errors`.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation"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 assets, a deployed package in this portfolio that imports this one as depreciation, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.
s, err := depreciation.New(cost, salvage, life)s, err := depreciation.NewDeclining(cost, salvage, life, num, den)n, err := depreciation.Periods(a.placedAt, runtime.ChainHeight(), a.period)Deployments
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 332,075 |
| Deploy transaction | 5148670fd8a24b673ff0b30f95aeecb852e0c98c7f31e01958a942f81b85e439 look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 28,059,876 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | gnomod.toml depreciation.gno |
| Deployed bytes | depreciation.gno — 19,983 bytes |
| sha256 | 6f0b9c40246de49c47307e839b7cb76996e14b4cea61c476f4c0ec75ee30bbe2 |
| 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/assets |
|---|
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/depreciation$download&file=depreciation.gno' | shasum -a 256Expected: 6f0b9c40246de49c47307e839b7cb76996e14b4cea61c476f4c0ec75ee30bbe2 — 19,983 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 3 exported functions, 2 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 depreciation is the carrying amount of a capital asset as a pure function of how many whole periods it has been in service.
A realm stores the plan and the date the asset was placed in service, and asks this package what it is worth now and what to charge for the span since it last asked. Nothing here reads the chain, holds state, or knows what a ledger is, so a realm can test a decade of its own depreciation without a node.
s, err := New(120000, 0, 120) // 120000 over 120 months n, _ := Periods(placedAt, now, Monthly) charge, _ := s.Charge(booked, n) // what to post carrying, _ := s.CarryingAt(n) // what it is worth
Why this is a function of n and not an accumulator
The obvious implementation stores the carrying amount, subtracts a per-period charge, and advances an anchor to now. It is wrong in a way that is specific and quiet: the per-period charge is an integer division, so each call truncates, and the part-period the call lands in is forfeited. Run twice as often and you forfeit twice as much — which for an asset register means *running the schedule more often depreciates less*. That is a misstatement of asset value, not a rounding preference.
p/moul/x/games/accrual names this bug and fixes it by advancing the anchor only by the whole periods it actually paid for, leaving the remainder on the clock, and it states the invariant that rules the bug out:
Advance(s, a, c) == Advance(Advance(s, a, b), b, c) for a <= b <= c
That invariant is adopted here, and the way it is obtained is different and stronger. Nothing in this package is an accumulator: Schedule.CarryingAt is a pure function of the period count, and Schedule.Charge is the difference of it at two points. So charges **telescope** —
Charge(a, b) + Charge(b, c) == Charge(a, c)
— by construction rather than by careful anchor arithmetic, and there is no stored carrying amount that could drift from the schedule at all. A realm that loses track of what it has booked can recompute it from the dates. [TestChargesTelescope] asserts it over pseudo-random partitions, because an implementation that truncates per call passes every round span and fails only on the ones that land mid-period.
The read path cannot disagree with the write path for the same reason: there is one function, and `Charge` is defined in terms of it.
Why a bounded loop is the right answer here and O(1) is not available
accrual must be closed form because elapsed time is unbounded: the player who returns after a month is exactly the one whose Render times out. Depreciation has no such exposure, and declining balance has no integer closed form — period n is the rate applied n times, with the floor taken each period, and taking the floor once at the end is a different and wrong number.
What bounds the loop instead is the asset's own **declared useful life**: Schedule.CarryingAt clamps at `Life` periods, `Life` is validated against MaxLife at construction, and the loop exits early once the salvage floor is reached. So the cost of a call is bounded by a number the caller declared and this package checked, not by how long ago the asset was bought. That is a different safety argument from closed-form and it is the one that makes declining balance implementable at all. Straight line *is* closed form and takes no loop.
What this package does not claim
- **No novelty for the straight-line arithmetic.** A quantity moving at a fixed rate per period toward a bound is shipped elsewhere in this ecosystem more than once; see DISCOVERY.md § 1. What was not found in the searched sources is the domain model — a salvage floor, a carrying amount, a declining-balance schedule over integers.
- **Nothing about money.** A carrying amount is a number in a plan. This package never sees a coin, and a realm using it must not infer custody from it.
- **No period convention beyond whole periods.** An asset placed in service mid-period depreciates from its placed-in-service date, not from the start of the calendar period it fell in. Half-year and mid-month conventions are a caller's job: shift `placedAt`. See § 4 of AUDIT.md.
- **No switch to straight line.** Textbook declining balance often crosses over to straight line once the latter charges more. This does not; see Schedule.CarryingAt on what happens at `Life` instead.
p/moul/x/games/accrual: /p/moul/x/games/accrual/v0
Imports
errors
Constants and variables
Period lengths in seconds, for a caller that keeps time in unix seconds. They are conveniences and not a calendar: Monthly is 30 days and Yearly is 365, so neither tracks month lengths or leap years. A caller that needs a real calendar must compute its own period count and pass it to the CarryingAt/Charge pair directly, which take period counts and not times.
const (
Daily int64 = 86400
Monthly int64 = 30 * Daily
Yearly int64 = 365 * Daily
)
MaxLife bounds the declared useful life in whole periods, and so bounds the declining-balance loop in Schedule.CarryingAt. 1200 is a century of monthly periods, which is past the point where any real schedule stops being meaningful.
What the bound costs is stated here rather than waved at, because the earlier wording claimed the loop "is not a gas hazard even in a Render" and no gas was ever measured. The figures that were: CarryingAt runs the loop n times with n < Life, so at most MaxLife-1 = 1199 iterations per call, and Schedule.Charge evaluates CarryingAt twice, so one Charge can run 2398. The early exit at the salvage floor is an optimisation and not part of the bound — a schedule whose per-period charge truncates to zero (Cost*RateNum < RateDen) never moves off Cost, never reaches the floor, and so runs the full 1199 on every call for the asset's whole life. A realm that walks many assets in one transaction, or renders them, must size that against the gas it has. This package bounds the work; it does not price it.
const MaxLife = 1200
var (
// ErrBadCost is returned when cost is not positive. A zero-cost asset
// has nothing to depreciate and makes Basis degenerate.
ErrBadCost = errors.New("depreciation: cost must be positive")
// ErrBadSalvage is returned when salvage is negative, or is not less
// than cost. Salvage equal to cost is a non-depreciating asset, which
// is legitimate bookkeeping but is not a schedule: there is no charge
// to post, ever, and silently accepting it would have a realm open
// accounts and post nothing.
ErrBadSalvage = errors.New("depreciation: salvage must be in [0, cost)")
// ErrBadLife is returned when the useful life is not positive or
// exceeds MaxLife.
ErrBadLife = errors.New("depreciation: life must be in [1, MaxLife]")
// ErrBadMethod is returned for a method outside the declared set.
ErrBadMethod = errors.New("depreciation: unknown method")
// ErrBadRate is returned when a declining-balance factor is not a
// proper fraction in (0, 1). A rate of 1 writes the asset off in one
// period and a rate of 0 never charges anything; both are better said
// another way, so neither is accepted here.
ErrBadRate = errors.New("depreciation: rate must satisfy 0 < num < den")
// ErrBadPeriod is returned when a period length is not positive.
ErrBadPeriod = errors.New("depreciation: period must be positive")
// ErrBackwards is returned when now falls before the placed-in-service
// time. Time moving backwards is a caller bug — a stored timestamp from
// another clock, a test that rewound — and treating it as zero elapsed
// would hide it.
ErrBackwards = errors.New("depreciation: now falls before placedAt")
// ErrBadSpan is returned by Charge when the span runs backwards, which
// would make a depreciation charge negative and credit an expense.
ErrBadSpan = errors.New("depreciation: charge span must not run backwards")
// ErrOverflow is returned when the arithmetic would wrap. Most
// overflow is excluded at construction instead; see New.
ErrOverflow = errors.New("depreciation: int64 overflow")
)
const (
// StraightLine charges the basis evenly across the life. Closed form.
StraightLine Method = 1
// DecliningBalance charges a fixed fraction of the remaining carrying
// amount each period, so early periods charge more. Not closed form
// over integers; see the package doc on why the loop is bounded.
DecliningBalance Method = 2
)
Types
type Method
type Method uint8
Method is how the basis is spread across the useful life.
Method.String
func (m Method) String() string
String names the method. The zero Method is "invalid" rather than empty, so a caller that renders it without checking Valid prints something that reads as wrong instead of something that reads as missing.
Method.Valid
func (m Method) Valid() bool
Valid reports whether m is a method this package implements.
type Schedule
type Schedule struct {
// Cost is the depreciable basis at placed-in-service.
Cost int64
// Salvage is the floor. The carrying amount never falls below it and
// reaches it exactly at Life.
Salvage int64
// Life is the useful life in whole periods.
Life int32
// Method is how the basis is spread.
Method Method
// RateNum and RateDen are the declining-balance factor as a proper
// fraction. Both are zero for StraightLine.
RateNum int64
RateDen int64
}
Schedule is the depreciation plan for one asset. It is a value, carries no state about what has been booked, and is safe to copy. Construct with New or NewDeclining so the invariants — including the overflow preconditions — are checked once rather than on every call.
| Exported field | Type | Doc |
|---|---|---|
Cost | int64 | Cost is the depreciable basis at placed-in-service. |
Salvage | int64 | Salvage is the floor. The carrying amount never falls below it and reaches it exactly at Life. |
Life | int32 | Life is the useful life in whole periods. |
Method | Method | Method is how the basis is spread. |
RateNum | int64 | RateNum and RateDen are the declining-balance factor as a proper fraction. Both are zero for StraightLine. |
RateDen | int64 |
Schedule.AccumulatedAt
func (s Schedule) AccumulatedAt(n int32) (int64, error)
AccumulatedAt returns accumulated depreciation after n whole periods: the contra-asset balance, Cost - CarryingAt(n). It is defined in terms of CarryingAt so the two can never disagree, which is the same reason Charge is.
Schedule.Basis
func (s Schedule) Basis() int64
Basis is Cost - Salvage: the total that will ever be charged across the whole life, under either method.
It does not validate, and it is the only exported method here that does not. That is forced rather than chosen: check() calls Basis to establish one of its own overflow preconditions, so Basis cannot call check(). It is therefore a bare subtraction over whatever fields it was handed, and on an ill-formed schedule it returns an arbitrary number — Schedule{Cost: maxInt64, Salvage: -1} wraps to minInt64. A zero result means "invalid, or zero cost", never "fully depreciated"; depreciation booked to date is Schedule.AccumulatedAt, which does validate. Guard a caller-supplied schedule with Schedule.Valid before quoting Basis from it.
Schedule.CarryingAt
func (s Schedule) CarryingAt(n int32) (int64, error)
CarryingAt returns the carrying amount after n whole periods in service.
It is monotone non-increasing in n, equals Cost at n <= 0, and equals Salvage exactly at every n >= Life. That last clamp is a declared convention and is doing real work under DecliningBalance, which approaches the floor asymptotically and would otherwise never reach it: the final period absorbs whatever remains. The consequence is worth stating because it is visible in the numbers — a declining-balance schedule's last charge can be much larger than the one before it. Textbooks usually avoid that by crossing over to straight line partway; this package does not, because the crossover rule is a policy choice that belongs to the caller and burying it here would make the schedule unexplainable from its own fields.
A declining-balance schedule whose per-period charge truncates to zero never depreciates at all. The charge is (c*RateNum)/RateDen in integers, so whenever Cost*RateNum < RateDen it is 0, c never moves off Cost, and CarryingAt returns Cost at every n below Life and Salvage at Life — the whole of the basis falls in the last period. That is integer truncation behaving correctly and it is accepted rather than guarded, because the alternative is a minimum-charge rule, which is a policy a caller may reasonably want either way and cannot be inferred from the fields. It is documented because it is invisible in the ordinary numbers and is the one shape that also defeats the loop's early exit; see MaxLife on the cost.
Under StraightLine the result is closed form. Under DecliningBalance it costs O(min(n, Life)) iterations, bounded by MaxLife and exiting early at the floor; see the package doc.
Schedule.Charge
func (s Schedule) Charge(from, to int32) (int64, error)
Charge returns the depreciation expense for moving a schedule from `from` whole periods booked to `to`.
It is the difference of CarryingAt at the two points, which makes charges telescope exactly:
Charge(a, b) + Charge(b, c) == Charge(a, c) for a <= b <= c
So how often a realm runs the schedule cannot change the total it books, and a realm that has booked through period `from` can post Charge(from, now) with no stored carrying amount to drift. The result is never negative; a span that runs backwards is ErrBadSpan rather than a credit to an expense account.
Schedule.FullyDepreciatedAt
func (s Schedule) FullyDepreciatedAt(placedAt, period int64) (int64, error)
FullyDepreciatedAt returns the time at which the asset first carries only its salvage value, for a realm that wants to tell a reader when the charges stop. It is placedAt + Life*period.
Under DecliningBalance the carrying amount may sit very close to salvage well before this time, and under both methods it is not at salvage until this time, because Life is what the clamp in CarryingAt uses.
Schedule.Valid
func (s Schedule) Valid() bool
Valid reports whether the schedule is well-formed. A zero Schedule is not.
Functions
New
func New(cost, salvage int64, life int32) (Schedule, error)
New validates and returns a straight-line schedule.
NewDeclining
func NewDeclining(cost, salvage int64, life int32, num, den int64) (Schedule, error)
NewDeclining validates and returns a declining-balance schedule charging num/den of the remaining carrying amount each period. Double-declining over a 10-period life is num=2, den=10.
Periods
func Periods(placedAt, now, period int64) (int32, error)
Periods returns the whole periods elapsed between placedAt and now.
A partial period in progress is not counted and is not lost: it is counted by a later call, once it completes. That is what makes the telescoping property above reach the right total however the caller slices time.
The span is measured in uint64 on purpose, and the reason is the whole correctness argument. `now >= placedAt` is already enforced above, so the true elapsed span is non-negative; its largest value is maxInt64 - minInt64 = 2^64 - 1, which does not fit in an int64 and fits *exactly* in a uint64. int64 subtraction wraps two's-complement, so the bit pattern of now - placedAt is already the low 64 bits of the true difference, and reading that pattern as a uint64 recovers the difference with no loss, no clamp and no reachable wrap. ErrOverflow therefore means exactly one thing here — the quotient does not fit in an int32 — and the subtraction needs no guard at all.
The guard it replaces, `placedAt < 0 && now > 0 && elapsed < 0`, was wrong in *both* directions, measured by exhaustive differential sweep against exact arithmetic rather than reasoned about: it returned (0, nil) for Periods(minInt64, 0, 1) and even a negative count for Periods(minInt64, 0, 2^62), and it rejected representable answers such as Periods(-1, maxInt64, maxInt64), whose count is 1. See § 5 of AUDIT.md.
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 |
|---|---|
| First-party dependencies | none |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curated- NOTHING CONSUMES THIS PACKAGE AND NO PROBE OF IT CAN CHANGE THAT -- phrased deliberately narrowly, because p/journal's record carried the structural-sounding version of this sentence ('and no probe can change that') and it went FALSE IN ONE DAY. consumed_by is empty and that is the honest state. A /p/ package declares no crossing functions, so MsgCall has nothing to dispatch to, and vm/qeval needs an exported realm function a /p/ does not have; so there is no transaction and no evaluation that executes this code without a consuming realm. What would change it is DEPLOYING A CONSUMER: onyx/r/assets is written against these exact bytes and staged (task #72), and this package's deploy is what unblocked it. Until then every behavioural claim rests on the 30 test functions (36 subtests, 66 --- PASS) and the 72 sabotage cases run locally under the pinned onyx toolchain, with the vm/qfile byte-match transferring that local evidence to this chain.
- THE LIVE-VERIFICATION ARGUMENT FOR THIS ENTRY IS A BYTE-MATCH AND A SOURCE READ, NOT A BEHAVIOURAL PROBE, and the two halves carry different provenance. The mechanical byte-match -- chain depreciation.gno 19,983 bytes, sha256 6f0b9c40... == the committed bytes, with the 88-byte committed gnomod.toml an exact prefix of the chain's 203 after the VM appended its [addpkg] stanza -- was taken by RAW-RPC vm/qfile and is therefore UNSTAMPED: it is bound only by the URL dialled and is not a 9a citation. The 9a-stamped reads are three, all chain_id_bound: true: the gnomod.toml read showing [addpkg] height 332075 and creator, the two gno_account reads bracketing the cost, and a gno_read symbols=[Periods] whose returned source is the POST-FIX implementation (uint64 span, no placedAt<0 && now>0 guard). That last one is the closest available substitute for a live-call probe: it proves which bytes onyx-1 holds at this path, specifically that they are the audit-fixed ones, and it is still a read of source rather than an execution of it.
- THE MUTATION SWEEP IS A FLOOR AND THIS PACKAGE IS THE PROOF OF THAT, MEASURED ON ITSELF. The harness read FULLY COVERED over 70 sabotages at the moment Periods was returning a NEGATIVE PERIOD COUNT WITH A NIL ERROR, and it was not malfunctioning: it measures whether the suite notices a changed term, and this defect needed an INPUT THE SUITE NEVER PASSED. The sweep is now 72 cases, 0 uncaught, and three of them put the defect back three ways (quotient taken in int64, bound compared after a signed narrowing, old guard literally reinstated) -- the third is caught ONLY by the fixtures added with the fix, which is the proof those fixtures are load-bearing. None of that makes the sweep a correctness proof.
- THE AUDIT FOUND A REAL DEFECT AND WAS ALSO WRONG ABOUT IT, IN BOTH DIRECTIONS. It reported Periods' wrap guard YELLOW with two named witnesses at 99% confidence; the second was REFUTED by computing it (-1 - minInt64 fits in an int64 with nothing to spare, so the old code's answer was correct). And the defect it did find had a second class it never reported, FIVE TIMES LARGER: 52 rows where the guard REFUSED A REPRESENTABLE ANSWER against 10 where it returned a wrong one, four of those returning a negative count with a nil error. None of that was visible from the audit text -- all of it came out of running the arithmetic. The lesson this entry carries forward is that an arithmetic claim gets COMPUTED, never reasoned about.
- THE DIFFERENTIAL SWEEP IS AN EMULATION, NOT EVIDENCE ABOUT THE VM. periods_sweep.py models int64 wrapping in Python and reports 0 disagreements for the fixed implementation over 1,960 stated triples and 2,268,109 wider ones, against 62 for the old one. It LOCATED the rows; depreciation_test.gno asserts them under the pinned onyx toolchain, and that is where the evidence is. A model cannot be evidence about the VM, which is why the rows are in the suite too.
- THE CLOSURE VERDICT IS 'NO CHAIN DEPENDENCIES AT ALL', WHICH IS NOT 'A CLOSURE WAS VERIFIED'. Gates A and B are both EQUIVALENT over ZERO compared dependencies, both digests e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -- the sha256 of an empty input. Per CLAUDE.md 7a that must be stated the first way and never the second. The package imports only `errors`, so this is a permanent property and not a gap. The stdlib residual is outside the comparison by construction: stdlibs execute from the node binary, so equivalence for them rests on the sealed onyx GNOROOT matching the node's build, which the node does not expose, and the seal proves only that nothing has changed since the pin was written -- not that it was right when written.
- GATE B WAS RUN BEFORE THE BROADCAST, WHICH IS A CORRECTION OF THIS PROJECT'S OWN PRECEDENT RATHER THAN A RESTATEMENT OF THE RULE. Group 12 recorded against itself that its Gate B ran AFTER the broadcast, a deviation from CLAUDE.md 7a. Here the record is onyx/p/depreciation/depclosure-gateB.json, exit 0, timestamped before tx 5148670f... was composed. The same ordering defect remains possible on every future deploy until task #107 makes the Gate A/Gate B sequence mechanical rather than remembered.
- simulate=true ON onyx-1 DOES NOT TYPE-CHECK (the chain's inert code-submission policy), and the tool said so in its own next_steps, so the dry run before this broadcast was a gas/fee/authorization probe ONLY. The correctness substitute is `gno lint .` against the onyx release at rc=0 on exactly these bytes, which was established as part of the readiness pass and confirmed for this deploy by digest -- all three recorded sha256s matched and `git status` on the package was empty, which is why the readiness table's figures describe these bytes rather than some earlier ones. Digest confirmation in place of re-running is a deliberate and stated choice, not an omission.
- NO GAS FIGURE FOR A CALL, ONLY FOR THE DEPLOY. MaxLife's doc states the loop's ITERATION bound -- at most 1,199 per CarryingAt, 2,398 per Charge, and the degenerate schedule whose per-period charge truncates to zero (Cost*RateNum < RateDen) runs the full bound on every call for the asset's whole life, defeating the early exit -- and explicitly refuses to price it. `gno test` reports one GAS line per test FUNCTION, which cannot be read as a per-call cost. Discharged by: a realm that renders many assets measuring its own. onyx/r/assets is that realm and is not deployed.
- NO PERIOD CONVENTION BEYOND WHOLE PERIODS, AND NO TAX OR ACCOUNTING STANDARD HAS BEEN CHECKED AGAINST. An asset placed in service mid-period depreciates from its placed-in-service date, not from the start of the calendar period it fell in. Half-year and mid-month conventions are a caller's job -- shift placedAt, which is sound because every function is pure in the resulting count. Daily/Monthly/Yearly are conveniences and not a calendar: Monthly is 30 days and Yearly is 365, so neither tracks month lengths or leap years. AUDIT.md section 4 states the mechanism and states plainly that it is a mechanism and not a standard.
- Basis() IS THE ONE EXPORTED METHOD THAT DOES NOT VALIDATE, AND THAT IS FORCED RATHER THAN CHOSEN: check() calls Basis to establish one of its own overflow preconditions, so Basis cannot call check(). On an ill-formed schedule it returns an arbitrary number -- Schedule{Cost: maxInt64, Salvage: -1} wraps to minInt64 -- and a zero result means 'invalid, or zero cost', never 'fully depreciated'. A consumer must guard a caller-supplied Schedule with Valid() before quoting Basis from it. Separately, a DecliningBalance schedule's LAST charge can be much larger than the one before it, because the clamp at Life absorbs whatever the asymptotic approach left; textbooks usually avoid that by crossing over to straight line, and this package deliberately does not, because the crossover rule is the caller's policy.
- THE 7 ECOSYSTEM-ABSENCE PHRASING IS USED, AND IT IS NOT A UNIQUENESS CLAIM. [an unpublished record] records 'No relevant existing implementation was found in the searched sources', names the two candidates the gate KILLED, and explicitly claims no novelty for the straight-line arithmetic -- a quantity moving at a fixed rate per period toward a bound is shipped elsewhere in this ecosystem more than once. What was not found is the domain model: a salvage floor, a carrying amount, a declining-balance schedule over integers. That document also predicted its own expiry and was right: it enumerated 639 onyx paths, and the step-8 re-enumeration immediately before this deploy found 669.
- DP1: same temporal-authority class on Periods(placedAt, now, period), weaker -- telescoping bounds total damage at Basis (mis-timing, not fabrication). Same consumer obligation as aging A1. 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/depreciation/depreciation.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 |