hanzoai/ci keeps the reusable at .github/workflows/build.yml — the one path
both planes resolve, since github.com reads a reusable only from there and the
forge reads it too. The v1 tag moved with it, so a caller still naming
.hanzo/workflows/build.yml asks for a blob that is not in the tree:
InsertRun: read hanzoai/ci@v1:.hanzo/workflows/build.yml: object does not exist
The caller itself stays at .hanzo/workflows/cicd.yml on purpose: github.com has
no runner for these labels, so a caller placed there could never be scheduled.
Co-authored-by: Hanzo Dev <[email protected]>
github.com has no runner for hanzo-build-linux-amd64, so a caller under
.github/workflows is a gate that can never be scheduled. This is the ~7-line
caller on the plane that can (git.hanzo.ai git-runner fleet), pinned @v1.
Co-authored-by: Hanzo Dev <[email protected]>
HIP-0137 "One License" (hanzoai/hips, HIPs/hip-0137-one-license.md) puts
BSD-3-Clause out of scope for hanzoai: original Hanzo work takes the standard
MIT OR Apache-2.0 dual licence, and only forks keep an upstream licence.
LICENSE is now the dual declaration; LICENSE-APACHE and LICENSE-MIT carry the
two texts beside it. The existing copyright holder and years are preserved
verbatim.
Provenance: the big.Int core was written from scratch; the only dependency is
hanzoai/decimal, relicensed identically. Eleven per-file BSD-3-Clause headers
moved with it.
Co-authored-by: Hanzo Dev <[email protected]>
ScaleMinor rounds half away from zero, which is right for an amount that
merely has to be correct and wrong for one somebody decided always falls
one way: a platform fee the house rounds up, an affiliate commission the
house rounds down and keeps the remainder of. Those callers reach for
math.Ceil(float64(units)*rate) instead, and that does not round the
product up — it rounds the FLOAT up, which is a different number.
A float rate is not the rate. 0.07 is held a hair above 0.07, so 700
minor units at 7% lands at 49.000000000000007 and ceilings to 50: a cent
invented on an amount that was exactly 49, charged to a customer who owed
49. 0.29 is held a hair below, so 100 at 29% lands at 28.999999999999996
and floors to 28: a cent destroyed on an amount that was exactly 29, out
of an affiliate's commission. Truncation at least errs consistently and
always toward the house; this errs on whichever side the binary
representation fell, on products that had no fraction to round at all.
Both form the product in big.Int and let only the true fraction decide,
so an exact product comes back unchanged from either — rounding a whole
number is a no-op whichever way you were headed. They name the
infinities, not the magnitudes, because a refund is negative and
"toward +inf" and "away from zero" are not the same instruction there.
Overflow is reported rather than wrapped, the contract ScaleMinor keeps.
Co-authored-by: Hanzo Dev <[email protected]>
ParseMinor and MajorString gave the ledger edge its two exact conversions.
The third one — an amount scaled by a rate — had no home, so callers grew
their own, and every copy truncated.
commerce alone spells a percentage discount six different ways across five
files: int(math.Floor(float64(cents) * float64(pct) * 0.01)), the same
without the int(), the same with / 100 instead of * 0.01, a bare
currency.Cents(float64(price) * rate) that truncates toward zero on
conversion, and two integer forms that floor. Six spellings, one answer
they all agree on, and it is the wrong one.
The float is not what makes them wrong, which is worth stating because it
is the obvious suspect. Those callers start from an integer count of minor
units, so entering float64 is lossless and the product is exact; scanning
every whole-cent case up to 200000 minor units against every percentage
from 1 to 100 finds zero results where the float lands on the wrong side
of an integer. The error is entirely the truncation. 10% off 1999 is
199.9, and dropping the fraction gives 199 — a cent, on the same side of
the trade, every time. Over a 1c-to-$100 price book at 10/15/33% that is
15000 minor units withheld across 30000 lines: half a cent per line, never
random, never self-cancelling.
So ScaleMinor rounds HALF AWAY FROM ZERO, the rule ParseMinor and Rescale
already use, and it is the rule rather than a discount-specific one so an
amount cannot depend on which conversion carried it. A refund rounds like
the charge it reverses. The arithmetic runs in big.Int, so the rate is
applied exactly at any magnitude and only the final answer has to fit an
int64; when it does not, that is reported, because a scaled amount that
silently wraps is a balance nobody holds.
RateFromFloat comes with it, for the schemas that predate this package and
store a rate as a float64. It takes the shortest decimal that round-trips,
so the 0.1 a human typed comes back as 0.1. Rendering at a fixed precision
instead is the trap: %.2f turns a 0.375% fee into 0.38%, and two decimals
of a fraction turn it into 0.00. NaN and Inf are reported rather than
mapped to zero, for the same reason ParseMinor refuses to: a zero rate is
a legal rate, and a caller that cannot tell "no discount" from "unreadable
discount" charges full price and calls it a sale.
Co-authored-by: Hanzo Dev <[email protected]>
ParseMinor gave the ledger edge one exact decimal-string -> minor-units
conversion. The reverse had no home here, so every caller grew its own, and
they were wrong in two different ways.
String() looks like the reverse and is not: it renders the value's own scale,
so FromCents(100) is "1", not "1.00". Both parse back to 100, which is why the
round-trip law held and nobody noticed, but an API whose schema says two
decimals does not accept "1".
The hand-rolled spelling -- print cents/100, a dot, then cents%100 -- is worse.
Go's % keeps the sign of the dividend, so -1999 renders "-19.-99" and -1 gives
"0.-1". A refund is the most common negative amount in a payment system.
MajorString derives the digits from the rescaled integer and pads to the
currency's decimals, so it is fixed-scale, sign-correct, and float-free.
Display now shares its one scale-and-pad step, so the wire form and the human
form cannot drift.
Co-authored-by: Hanzo Dev <[email protected]>
Upstream billing APIs (DigitalOcean, AWS, GCP) report money as a decimal
STRING and every ledger stores an integer, so this conversion sits on the
edge of the money path. It had been rewritten by hand three times — vm,
commerce and ai each carried a private copy — and the copies disagreed
about real inputs: "1,234.56" was 123456 in one and 0 in the other two;
"-5.00" was 0, 0 and -500. Three answers means at least two are wrong,
and nothing in the code said which.
All three shared one root cause: ParseFloat, then multiply by 100. That
loses a cent on ordinary prices (19.99 has no exact binary form, so
f*100 is 1998.999… and truncation gives 1998) and the error is unbounded
above 2^53 minor units. This is the module that already holds exact
decimals and the FromMinor/FromCents constructors; the inverse belongs
here beside them, not three times in three callers.
Two policies the copies got wrong are fixed rather than reproduced:
- an unreadable amount returns an ERROR, never 0. Zero is a legal
amount, so a caller that cannot tell "spent nothing" from "could not
read what was spent" reports a confident $0 and the spend vanishes.
- a negative parses and STAYS negative. On these APIs it is a credit
or a refund; clamping it to zero overstates spend by the refund.
int64 overflow is refused too. Wrapping is worse than refusing: it
reports a balance of the wrong sign.
Co-authored-by: Hanzo Dev <[email protected]>
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.
One mechanism, in one place, instead of ~350 copies of a cron.
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.
Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
The v0.2.0 artifact resolved decimal v0.1.1 transitively via consumers' MVS,
but the require line still declared v0.1.0. Make the floor honest so any
adopter that pins only money gets the exact 18-dp decimal. No API change.