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:
Example
1OPENING 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:
Example
1Liabilities() == TotalPooled() + AccruedTotal() + FeesAccrued()
2Held() == 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.