payroll
gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll on onyx-1 testnet
realmapplicationrecurring-paymentslive on onyx-1
Pay recurring salaries from an employer-funded pot.
Wages accrue on a schedule and each employee claims their own pay, so nobody waits on a manual transfer going out on time.
In detail curated
Employer-funded recurring salary disbursement with REAL ugnot custody -- the first custodial realm this factory has deployed since r/crowdfund, and the first whose liability accrues with the BLOCK CLOCK rather than on a caller's instruction. An employer funds a pool (Fund, at least MinFunding = 1_000_000) and hires an employee on fixed terms (perPeriod, periodBlocks, startHeight, endHeight) with endHeight == OpenEnded(0) meaning indefinite; thereafter ANYONE may call Accrue, which settles WHOLE elapsed periods out of the pool into the employee's claimable balance, charging AccrueFeeBps = 100 (1%) to the FeeCollector. The accrual is DERIVED, never supplied: no entrypoint anywhere accepts an amount to pay, so the debt is a pure function of (perPeriod, periodBlocks, startHeight, endHeight, paidPeriods, ChainHeight) and duePeriods is the single arbiter. THE PERIOD IS ALL-OR-NOTHING: a part-elapsed period is owed NOTHING, which is what keeps salary from being paid in advance, and a pool that cannot cover ONE WHOLE period refuses rather than part-paying -- so periods clear whole or not at all and the pool bounds HOW MANY periods clear, never the amount of any one of them. Claim is employee-only and pull-based; WithdrawPool/WithdrawPoolAll return the REMAINING pool rather than the amount withdrawn, and the empty-pool case PANICS rather than succeeding with a zero transfer so a caller cannot read success as evidence that coins moved. Terminate (employer-only) stops the clock by moving endHeight to now and SETTLES NOTHING -- the debt survives, which is the design's one deliberate asymmetry; Release destroys the position record and is refused while anything is due or the position has not ended, and it does NOT touch the employee ledger, so an accrued balance survives its position's destruction and stays claimable. Held() is the realm's actual chain-banker balance and Liabilities() is the realm's own bookkeeping sum (pooled + accrued + fees); Surplus() is the difference and SweepSurplus is the only path by which an unaccounted coin leaves. The two figures are maintained by INDEPENDENT means and their agreement is the whole solvency argument -- it held at every step of the live lifecycle. Guards: rejectStraySend on every mutating entrypoint, checkedAdd/checkedMul on every arithmetic path (DueAmount refuses an overflowing salary rather than reporting a negative), MaxRoster = 64 per employer, MinPerPeriod = 1_000 and MinPeriodBlocks = 100 floors so a fee-free or clock-free position cannot be created by accident, an IsValid()+lowercase check on the employee address, and a backdated start refusal. FeeCollector is ADDRESS-AS-DATA -- a hard-coded g1ut6... constant that any mainnet port must classify before namespace substitution rewrites it (the namespace_tree.py blind-replace hazard), documented in-source for exactly that reason.
Use it
A realm holds state. Reading it is a free query; changing it is a transaction you sign yourself.
Read it — no key, no account, no GNOT
gnokey query vm/qeval \
--data 'gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll.AccruedTotal()' \
--remote https://rpc.onyx.testnets.gno.land:443AccruedTotal is read-only, so this is a query rather than a transaction: it costs nothing, signs nothing and needs no funded account. Every exported function is listed under the API below; swap the name and the arguments to read any of the others.
Change its state
Every state change is a transaction you sign with your own key — this site never asks for one and holds none. You do not have to hand-write the transaction: gnoweb generates the exact gnokey maketx call command for any of this realm's 10 crossing functions, with the argument names filled in.
Read the known limitations further down this page first. They record what was not verified on chain, which is usually the part that matters.
Import it from another realm
import "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll"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 payslips, a deployed package in this portfolio that imports this one as payroll, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.
if !payroll.HasPosition(employer, who) {rosterAt: payroll.RosterOf(employer),openAt: payroll.OpenPositions(),Deployments
onyx-1 testnet onyx-1
RenderedSourceCall builderState
chain-attested| Import path | gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 376,441 |
| Deploy transaction | b32b9dde74aa597537c1a85a46715ed99faff2c2a61e22201798d350ebdfce23 look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 45,573,567 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | payroll.gno gnomod.toml |
| Deployed bytes | payroll.gno — 33,703 bytes |
| sha256 | 6b07ca0e4aa51afffbb367e252d8b0af4cbbe81549f4f4c327eddd23e62f540a |
| Realm address | g1kfj7rqm4yhcceyt4w2w7gd4n8u5zucutg6pzd9 confirmed against the realm itself |
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/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll$download&file=payroll.gno' | shasum -a 256Expected: 6b07ca0e4aa51afffbb367e252d8b0af4cbbe81549f4f4c327eddd23e62f540a — 33,703 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 30 exported functions.
Every function below deep-links to gnoweb's call builder, which generates a ready-to-run gnokey maketx call for it:
AccruableAmountAccrueAccruedTotalBalanceOfClaimDueAmountDuePeriodsEmployersEndHeightOfFeesAccruedFundHasPositionHeightHeldHireLiabilitiesOpenPositionsPaidPeriodsOfPerPeriodOfPoolOfReleaseRenderRosterOfSurplusSweepSurplusTerminateTotalPooledWithdrawFeesWithdrawPoolWithdrawPoolAll
Overview
Package payroll is employer-funded recurring salary disbursement with real ugnot custody.
The one idea
An allowance refills; a salary ACCRUES. That single sentence is why this realm exists beside budgets rather than inside it.
budgets (this portfolio, live on onyx-1) grants a delegate a rate-limited spending authority through p/spendbook: the delegate may draw at most PerPeriod per window, and a window that passes unused is gone. Use-it-or-lose-it is exactly right for a spending authority — an unspent budget is not a debt.
A salary is the dual. An employee who does not collect for three periods is owed three periods. Unclaimed pay is a LIABILITY of the employer, not an expired permission. No refilling primitive can model that, because the whole point of a refill is that the previous window is forgotten.
So the arithmetic here is accrual arithmetic, and it is the one genuinely new mechanism in this realm. Everything that touches coins is delegated to primitives this portfolio has already audited and run live: p/coinio for custody and payout guards, p/feeledger for per-employee balances and the liabilities total.
Paid in arrears
A period must COMPLETE before it is owed. At the instant a position starts, nothing is due; the first period becomes payable at StartHeight + PeriodBlocks. Paying in advance would mean the realm disbursing for time that has not elapsed, and there is no mechanism on chain to take it back if the position ends early.
A consequence worth stating plainly: a position whose PeriodBlocks never fully elapses before it is terminated pays NOTHING. That is not a rounding loss hidden in the implementation, it is the arrears rule applied honestly, and Terminate's documentation repeats it.
Accrual is permissionless, and whole-period
Gno has no autonomous execution, so "recurring" always means someone sends a transaction later. Accrue may therefore be called by ANYONE — the employee, the employer, a keeper realm, a stranger. It does only what the employer already committed to when the position was opened, it moves coins from that employer's own pool to that employee's own ledger balance, and the caller pays the gas. There is no third party who gains, so there is nothing to grief with. This is also what makes the realm composable with upkeep, the portfolio's keeper-incentive realm.
Accrual settles WHOLE periods only. If the pool covers two of three due periods, two are credited and the third stays due — it is not partially paid, and no capacity is burned. The pool bounds HOW MANY periods clear, never WHETHER a period is paid in full.
Solvency, and the lesson taken from budgets
budgets' package documentation records that a budget is authority and not a reservation: over-authorization is legal there, and solvency is enforced per-owner at draw time. The same choice is made here for the same reason, and the honest consequence is named rather than hidden:
OPENING A POSITION DOES NOT RESERVE ANY COINS.
An employer may hire more people than the pool can ever pay. Nothing in this realm prevents it, because a future funding transaction is a perfectly ordinary way to intend to meet an obligation, and refusing the hire would only move the problem to the moment the pool dips. What the realm guarantees is narrower and checkable: a period either clears in full or stays due, and no employee's accrued ledger balance can be reduced by anyone.
The monetary invariant
Two statements, because one of them is a decomposition and the other is a solvency claim, and collapsing them is how the wrong identity gets written down:
Liabilities() == TotalPooled() + AccruedTotal() + FeesAccrued() Held() == Liabilities() + Surplus()
Liabilities() is every coin owed to anyone: the employers' unallocated pools PLUS the feeledger's own total, which is itself the accrued employee balances plus the fee pot. Surplus() is coins pushed at the realm outside every entrypoint. Both lines hold after every operation and are pinned by TestMonetaryInvariant. It is the same shape budgets carries, deliberately: a reader who has checked one can check the other.
Only the first line can fail. The second is definitional — Surplus() is computed as Held()-Liabilities() — so asserting it is a tautology, and what the test actually checks is Held() >= Liabilities() beside the decomposition. A reader who takes the second line for a check has been reassured by arithmetic.
This paragraph read `Held() == TotalPooled() + Liabilities() + Surplus()` until the pre-deploy audit, which double-counts the pools because Liabilities() already contains TotalPooled(). The code was never wrong and payroll_test.gno encoded the right decomposition the whole time; the comment was, and a comment is immutable after addpkg.
Termination is two steps, and that is a protection
Terminate stops the clock. Release frees the storage. They are separate because collapsing them would destroy earned-but-unaccrued pay: an employer whose pool is short could remove a position owing three periods and the debt would vanish with the record.
So Terminate sets the end height to now and leaves the position in place, with whatever is due still due and still accruable by anyone. Release removes it, and only once nothing is due. Release is permissionless and the chain refunds the freed storage deposit to the caller, which is the standing incentive to tidy up someone else's settled position — the same mechanism budgets uses for Reap.
What this realm does NOT do
- It does not value work, verify it happened, or arbitrate a dispute about it. A position is a standing instruction from an employer, and nothing more.
- It holds ugnot and only ugnot.
- It has no admin. The deploying namespace receives protocol fees and swept surplus and has no other privilege: no setter, no pause, no ability to touch a pool or a balance. Funding a pool is what makes an address an employer, exactly as in budgets.
STORAGE
An employer record is paid for by that employer's first Fund; a position record by the Hire that opens it. Release removes a position record and the chain refunds the freed deposit to the transaction that frees it.
Imports
chainchain/runtimechain/runtime/unsafegno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coiniogno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledgergno.land/p/nt/avl/v0gno.land/p/nt/markdown/sanitize/v0strconvstrings
Constants and variables
AccrueFeeBps is the fee charged on each accrual, on the gross amount, floor-rounded in the EMPLOYEE's favour: 100 bps = 1%. It matches the most conservative live fee in this portfolio (fee_split, crowdfund, budgets). It is a compile-time constant and there is no setter, so a position's economics cannot be changed under the employee after the position is opened.
const AccrueFeeBps = int64(100)
Denom is the only asset this realm holds.
const Denom = "ugnot"
FeeCollector receives withdrawn fees and swept surplus. Compile-time constant: there is no setter and no transfer path. It is the deploying namespace's address — stated plainly: the operator of this realm.
NOTE FOR ANY RE-NAMESPACED PORT: this is the address-as-data hazard that a namespace substitution rewrites silently while every byte-comparison still reports the tree faithful, because the rewrite IS the intended transformation. Re-state it deliberately per chain; never let it be derived.
const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
MaxFeeBps is the hard ceiling on any accrual fee this realm could ever charge, enforced by the feeledger the realm is constructed with — a feeBps above it is refused by the primitive's own validation, not by a check in this file. 500 bps = 5%.
const MaxFeeBps = int64(500)
MaxRoster caps simultaneously-open positions per employer. Hire is permissionless to anyone with a funded pool, so this is an AVAILABILITY BOUND and it is documented rather than hidden: an employer can exhaust their OWN roster and no one else's, because the roster is per-employer. The cost of filling one is gas plus the storage deposit on every position record, held until Release.
const MaxRoster = 64
MinFunding keeps dust pools out: 1 GNOT per Fund call.
const MinFunding = int64(1_000_000)
MinPerPeriod is the smallest salary a position may carry. Below the fee denominator a 1% fee floors to zero, so a per-period amount of a few ugnot would be a fee-free position by accident rather than by design; 1000 ugnot keeps the fee arithmetic meaningful.
const MinPerPeriod = int64(1_000)
MinPeriodBlocks is the shortest period a position may use. A period of a handful of blocks is a rate rather than a salary, and it makes the arrears rule meaningless. Unlike budgets, a period of 0 is NOT accepted here: there is no "one lifetime window" reading of a salary, and dividing by it would panic.
const MinPeriodBlocks = int64(100)
OpenEnded is the EndHeight value meaning "no end": the position accrues until it is terminated.
const OpenEnded = int64(0)
RealmPath is this realm's own path, used to build Render links.
const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll"
Functions
AccruableAmount
func AccruableAmount(employer, employee address) int64
AccruableAmount is the gross an Accrue would settle right now, pool bound included. It is the honest number, and it can be less than DueAmount without anything being wrong — see the package doc on solvency.
Accrue
func Accrue(cur realm, employer, employee address) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asAccrue(cross(cur), ...).
Accrue settles every whole period that has completed and not yet been paid, as far as the employer's pool reaches, and credits the gross minus the accrual fee to the employee's ledger balance.
ANYONE may call this — see the package doc. It returns the gross amount settled.
AccruedTotal
func AccruedTotal() int64
AccruedTotal is the sum of every employee's unclaimed balance.
BalanceOf
func BalanceOf(employee address) int64
BalanceOf is an employee's accrued, unclaimed balance.
Claim
func Claim(cur realm) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asClaim(cross(cur), ...).
Claim pays the caller their whole accrued balance. The employee only — there is no "claim on behalf of", because that would let a third party choose the moment someone else's coins move.
DueAmount
func DueAmount(employer, employee address) int64
DueAmount is the gross that DuePeriods would settle if the pool were unbounded. Read it beside AccruableAmount: the difference between them is exactly the employer's shortfall.
It is the one arithmetic here with NO pool bound behind it, so it is the one place an int64 can wrap: perPeriod has no ceiling and due grows with the chain. Accrue cannot wrap, because its product is bounded by the pool it is about to debit (see the comment there). An unpayable position is a legal state in this realm, so the overflow is reachable in principle, and this panics rather than returning a negative salary — a view that refuses is recoverable, a view that lies is not.
DuePeriods
func DuePeriods(employer, employee address) int64
DuePeriods is duePeriods for a live position, at the current height.
Employers
func Employers() int
Employers is the number of addresses that have ever funded a pool. Note the tense: an employer record survives a pool going to zero, so this is not a count of currently-funded employers.
EndHeightOf
func EndHeightOf(employer, employee address) int64
EndHeightOf is a position's end height, or OpenEnded.
FeesAccrued
func FeesAccrued() int64
FeesAccrued is the fee pot awaiting WithdrawFees.
Fund
func Fund(cur realm) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asFund(cross(cur), ...).
Fund adds the attached coins to the caller's own pool. Direct EOA calls with -send only (the receipt-guaranteed shape); exactly one positive ugnot coin, at least MinFunding. Anyone may fund; funding is what makes an address an employer.
HasPosition
func HasPosition(employer, employee address) bool
HasPosition reports whether a position is open, without panicking on absence — every other position accessor panics, which makes them unusable as an existence probe.
Height
func Height() int64
Height is the current chain height, exposed so a caller can compute period boundaries against the same clock this realm uses.
Held
func Held() int64
Held is the realm's actual ugnot balance, read from the chain.
Hire
func Hire(cur realm, employee address, perPeriod, periodBlocks, startHeight, endHeight int64)
Crossing function. Callable from a transaction via
MsgCall, and from another realm asHire(cross(cur), ...).
Hire opens a position. The caller is the employer and must already have a funded pool, which is this realm's only barrier to opening records at someone else's expense.
startHeight must not be in the past. A backdated start would make a large number of periods due in the same transaction that opened the position, draining the pool to one employee in one call — the hire would look like a schedule and behave like a withdrawal. Refusing it is cheaper to reason about than bounding it.
perPeriod is bounded below and NOT above, and the slot cost is permanent
MinPerPeriod is a floor; there is no ceiling. A position opened with a perPeriod larger than any pool that can be funded is unsettleable, because Accrue's solvency check is integer division — e.pool/perPeriod is 0 for every reachable pool, so Accrue panics on "cannot cover one period" forever. Once that position's first period completes, duePeriods is permanently non-zero, so Release refuses forever too, and the record consumes one of the employer's MaxRoster slots for good.
This is a DECLARED LIMITATION rather than a defect, and the reason is that it is strictly self-harm: owner comes from cur.Previous().Address() below and is never a parameter, so no caller can open an unpayable position on anyone else's roster, and the pool itself stays fully withdrawable through WithdrawPool. The mitigation is available and narrow: Terminate then Release BEFORE the first period completes, while duePeriods is still zero. After that the slot does not come back.
Liabilities
func Liabilities() int64
Liabilities is every coin this realm owes to anyone: pooled coins plus the ledger's own total (employee balances plus the fee pot).
OpenPositions
func OpenPositions() int
OpenPositions is the number of open positions across every employer.
PaidPeriodsOf
func PaidPeriodsOf(employer, employee address) int64
PaidPeriodsOf is how many whole periods a position has settled.
PerPeriodOf
func PerPeriodOf(employer, employee address) int64
PerPeriodOf is a position's gross salary per period.
PoolOf
func PoolOf(employer address) int64
PoolOf is an employer's unallocated pool. Zero for an address that has never funded one.
Release
func Release(cur realm, employer, employee address)
Crossing function. Callable from a transaction via
MsgCall, and from another realm asRelease(cross(cur), ...).
Release removes an ended, fully-settled position and frees its storage. Permissionless: the chain refunds the freed storage deposit to the caller, which is the standing incentive to tidy up a settled position that is not your own.
It refuses while anything is due, which is what makes it safe to let a stranger call: a Release cannot destroy an unpaid obligation.
Render
func Render(path string) string
RosterOf
func RosterOf(employer address) int
RosterOf is the number of open positions an employer holds.
Surplus
func Surplus() int64
Surplus is coins held beyond every obligation — what SweepSurplus would move. It is zero when every coin arrived through an entrypoint.
SweepSurplus
func SweepSurplus(cur realm) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asSweepSurplus(cross(cur), ...).
SweepSurplus sends coins held beyond every obligation to FeeCollector. The reserve passed to coinio is the realm's full liability — pooled coins plus ledger liabilities — so a sweep can never reach a coin that an employer or an employee is owed.
Terminate
func Terminate(cur realm, employee address)
Crossing function. Callable from a transaction via
MsgCall, and from another realm asTerminate(cross(cur), ...).
Terminate stops the clock on a position. The employer only.
It does NOT remove the record and does not settle anything: whatever was due stays due, and anyone may still Accrue it. That separation is the employee's protection — see the package doc. Use Release to remove a settled position.
If the position has not yet reached its first complete period, this pays nothing, by the arrears rule.
TotalPooled
func TotalPooled() int64
TotalPooled is the sum of every employer's unallocated pool.
WithdrawFees
func WithdrawFees(cur realm) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asWithdrawFees(cross(cur), ...).
WithdrawFees moves the accrued fee pot to FeeCollector. Anyone may call it; the destination is a compile-time constant, so the caller cannot influence where the coins go.
WithdrawPool
func WithdrawPool(cur realm, amount int64) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asWithdrawPool(cross(cur), ...).
WithdrawPool returns unallocated pool coins to the employer. There is no lock and no notice period, and that is a DOCUMENTED asymmetry rather than an oversight: see the package doc on solvency. An employer may withdraw a pool while positions are open, which leaves those positions accruing against an empty pool — due but unsettleable until the pool is refunded. What an employer can never do is reduce a balance already accrued to an employee's ledger account.
WithdrawPoolAll
func WithdrawPoolAll(cur realm) int64
Crossing function. Callable from a transaction via
MsgCall, and from another realm asWithdrawPoolAll(cross(cur), ...).
WithdrawPoolAll is WithdrawPool for the whole pool. It panics on an empty pool rather than succeeding with a zero transfer, so a caller cannot read success as evidence that coins moved.
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 | chain, chain/runtime, chain/runtime/unsafe, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger, gno.land/p/nt/avl/v0, gno.land/p/nt/markdown/sanitize/v0, strconv, strings |
|---|---|
| First-party dependencies | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curated- THE DEPLOYED BYTES WERE THE WORKING TREE, NOT THE COMMIT, AND THE RECONCILIATION RUNS THE OTHER WAY FROM r/inventory's. The addpkg at height 376441 was composed from the working tree at a moment when payroll.gno carried the AUDIT.md remediation's comment-only edits and HEAD was d1c2c10e, which did not. So for the interval between the broadcast and the commit that carries this entry, the committed tree DID NOT match the chain -- source divergence from the verified commit, which CLAUDE.md section 8 lists as a hard boundary. It is recorded rather than dressed up, and three things bound it. The divergence was comment-only (the edits were doc comments produced by the audit remediation), so compiled behaviour is provably identical. The post-broadcast vm/qfile byte-match CAUGHT and MEASURED it rather than a reviewer noticing later. And the resolution was to commit the deployed bytes, so committed == live from this commit forward and every behavioural verification transfers. WHAT IS LOST is that the audited artifact and the broadcast artifact were two different files for the length of one deploy, and the only reason that was harmless is a property of the edits rather than a property of the process. r/inventory hit the same class from the opposite side (the chain got a byte the commit did not have); this one is the commit lacking bytes the chain got. Two instances, two directions, one missing control. DISCHARGED BY: composing the addpkg body from the git blob rather than the worktree, which is task #107's neighbour and is NOT yet installed.
- GATE B RAN BEFORE THE BROADCAST THIS TIME, AND THE MARGIN WAS NINE MINUTES. Stated explicitly because the IMMEDIATELY PRECEDING record (p/cashflow, Group 21) carries the opposite finding -- Gate B after the broadcast, a section 7a violation. Here Gate B's JSON was written 2026-10-13T14:45:40Z and the deploy block's header time is 2026-10-13T14:54:38Z, so the gate preceded the irreversible write by 8m58s, and Gate B's recorded payroll.gno digest (6b07ca0e...) is byte-identical to the deployed bytes. THIS IS NOT EVIDENCE THAT THE CONTROL IS FIXED. The sequence was correct because it was remembered, which is exactly what task #107 says is insufficient, and one compliant deploy following one non-compliant deploy is the signature of a remembered step rather than an installed one. Note also that payroll.gno's mtime moved to 15:06:50Z, AFTER the broadcast -- the sabotage harness restores the file from backup -- so an mtime-based quiescence argument would have been WRONG here; the digest is what proves the content never changed. DISCHARGED BY: the same mechanical pre-broadcast gate as #107.
- THE LIVE LIFECYCLE WAS ONE EMPLOYER AND ONE EMPLOYEE, BOTH THE SAME KEY. Fourteen transactions carried the realm from empty through Fund/Hire/Accrue/Claim/WithdrawFees/WithdrawPool, a refill, a second Accrue, Terminate, Release, a second Claim, WithdrawFees and WithdrawPoolAll to a fully drained realm -- the most complete on-chain economic validation this factory has run. But the agent key was BOTH the employer and the employee, so no authorization boundary was crossed by a DIFFERENT address on chain. Claim-is-employee-only, Terminate-is-employer-only, WithdrawPool-is-employer-only and fees-go-only-to-the-collector are proven by the committed 39-test suite and the 58-case sabotage sweep, and by nothing on this chain. The authorization is structural (no entrypoint takes an employer or employee parameter it then trusts for identity; both come from cur.Previous().Address()), so there is no address to forge, but 'untested on chain with two keys' is the honest word. DISCHARGED BY: a second funded onyx key, which this harness has not provisioned.
- THE TWO REFUSAL PROBES ARE THE RECORD'S BEST BEHAVIOURAL EVIDENCE AND NEITHER IS A SECTION 9a CITATION. The insolvency guard (panic 'employer's pool cannot cover one period', payroll.gno:498) and the MinFunding floor (panic 'funding is below MinFunding') were both reached live against the deployed bytes, and both arrive as a TOOL ERROR with no structuredContent -- hence no chain_id_bound key at all -- and section 9a gates on == true with absence explicitly not a third state. So the probes whose output is most specific to the audited source are precisely the ones that cannot be cited, while the value-returning views that CAN be cited are the ones a wrong implementation would most easily reproduce. This is a structural property of the stamp riding on the success payload, not a gap in this deploy. DISCHARGED BY: nothing available here; the swap is named in the memory note panic-probe-carries-no-9a-stamp.
- NO PART-PERIOD, OVERFLOW, ROSTER-BOUND OR MULTI-POSITION PATH WAS EXERCISED ON CHAIN. The live run settled two whole periods at a single position. MaxRoster = 64, MaxFeeBps = 500, the checkedAdd/checkedMul overflow refusals, DueAmount's negative-on-overflow refusal, the duplicate-Hire refusal, the uppercase/invalid-address refusal, the backdated-start refusal, the endHeight-ordering refusal, the term-shorter-than-one-period refusal, SweepSurplus, and the two-employers-one-employee partition are all carried by the committed suite and the sweep under the pinned onyx toolchain. Skipped, not passed. DISCHARGED BY: broadcasting those paths, which costs real ugnot per refusal and was traded against shipping the next product.
- FeeCollector IS A HARD-CODED ADDRESS CONSTANT AND A MAINNET PORT MUST CLASSIFY IT BEFORE namespace_tree.py TOUCHES THIS FILE. The constant is address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3") -- the same string as the namespace segment of every first-party import path in the file -- and namespace substitution is a BARE str.replace, so a mainnet compose rewrites the fee destination silently while every obligation still reports FAITHFUL. This is the r/crowdfund address-as-data hazard, the second instance in a custodial realm. It is documented in-source for exactly this reason. DISCHARGED BY: an explicit classification in the mainnet port, as was done for mainnet/r/budgets (task #22).
- THE SOLVENCY ARGUMENT IS Held() == Liabilities(), AND IT IS A CONSISTENCY CHECK RATHER THAN A PROOF OF CORRECTNESS. The two figures come from independent means -- Held() from the chain banker, Liabilities() from the realm's own pooled+accrued+fees sum -- and they agreed at every step of the live lifecycle including the terminal 0 == 0, which is the strongest economic citation this realm can emit. But a bookkeeping error that MISATTRIBUTES value between pool, accrued and fees leaves the sum unchanged and so is invisible to it. The per-component live reads (PoolOf 600000 / BalanceOf 990000 / FeesAccrued 10000 after two accruals, an exact 1% split) are what cover that, and they cover it at one employer. DISCHARGED BY: per-component invariants over multiple employers on chain.
- THE SIMULATE DID NOT TYPE-CHECK. onyx-1 runs the inert code-submission policy, so the dry run reports gas without compiling, and a green simulate is therefore not evidence about correctness. Type-correctness rests entirely on the local gno test under the onyx pin (39 passed, 0 failed, 0 skipped), which is the control actually relied on. DISCHARGED BY: a chain running a non-inert policy.
Source
| Source file | onyx/r/payroll/payroll.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 |